mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-30 08:32:11 +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:
1 parent
730a1017d1
commit
a84b0b5b6d
111 files changed
+14996
-1057
No files matched your search
@@ -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
|
||||
+19
-11
@@ -153,7 +153,7 @@ ngrok forwards only the tunnel port. The security property comes from **physical
|
||||
| `POST /pair` | root-only | 404 | Pairing mint — local operator action |
|
||||
| `POST /tunnel/{start,stop}` | root-only | 404 | Daemon configuration |
|
||||
| `POST /token`, `DELETE /token/:id` | root-only | 404 | Scoped token mint/revoke |
|
||||
| `GET /cookie-picker`, `GET /cookie-picker/*` | public UI, auth API | 404 | Local-only — reads local browser DBs |
|
||||
| `GET /cookie-picker`, `/cookie-picker/*` | one-use code/session for UI; Bearer or picker session for API | 404 | Local-only — reads local browser DBs |
|
||||
| `GET /inspector`, `/inspector/events`, etc. | auth | 404 | Extension callback, local-only |
|
||||
| `GET /welcome` | public | 404 | GStack Browser landing page, local-only |
|
||||
| `GET /refs` | auth | 404 | Ref map — internal state |
|
||||
@@ -167,31 +167,39 @@ ngrok forwards only the tunnel port. The security property comes from **physical
|
||||
|
||||
**SSE session cookies.** EventSource can't send Authorization headers, so the extension POSTs `/sse-session` once at bootstrap with the root Bearer and receives a 30-minute view-only cookie (`gstack_sse`, HttpOnly, SameSite=Strict). The cookie is valid ONLY for `/activity/stream` and `/inspector/events` — it is NOT a scoped token and cannot be used on `/command`. Scope isolation is enforced by the module boundary: `sse-session-cookie.ts` has no imports from `token-registry.ts`.
|
||||
|
||||
**Non-goal in this wave** (tracked as #1136): the cookie-import-browser path launches Chrome with `--remote-debugging-port=<random>`. On Windows with App-Bound Encryption v20, a same-user local process can connect to that port and exfiltrate decrypted v20 cookies — an elevation path relative to reading the SQLite DB directly (which can't decrypt v20 without DPAPI context). Fix direction is `--remote-debugging-pipe` instead of TCP; requires restructuring the CDP client.
|
||||
**Windows native-cookie boundary** (#1136): the exposed debugging TCP fallback has been removed. The native adapter uses Playwright's pipe transport and requires browser/runtime process-ownership and cleanup qualification before enablement; its qualification list is currently empty. DPAPI-compatible database imports remain available, but this does not promise recovery of every App-Bound Encryption cookie. Chrome 136+ protects its default user-data directory, including numbered profiles, against both pipe and TCP debugging. Closing Chrome does not bypass that policy. No TCP downgrade, substitute browser, or real-profile copy is allowed; unsupported cases direct the user to manual sign-in in gstack's browser.
|
||||
|
||||
### Bearer token auth
|
||||
|
||||
Every server session generates a random UUID token, written to the state file with mode 0o600 (owner-only read). Every HTTP request that mutates browser state must include `Authorization: Bearer <token>`. If the token doesn't match, the server returns 401.
|
||||
Every server session generates a random UUID token, written to the state file with mode 0o600 (owner-only read). Requests to `/command` must include `Authorization: Bearer <token>` using an authorized root or scoped token. Invalid authentication is rejected.
|
||||
|
||||
This prevents other processes on the same machine from talking to your browse server. The cookie picker UI (`/cookie-picker`) and health check (`/health`) are exempt on the local listener — they're 127.0.0.1-bound and don't execute commands. On the tunnel listener nothing is exempt except `/connect`.
|
||||
Command authorization requires the token. The local cookie picker instead exchanges a five-minute one-use code for a scoped HttpOnly session cookie; that cookie authorizes only picker routes, never `/command`. Its API also accepts Bearer authorization. The local health check (`/health`) is public and does not execute commands. On the tunnel listener nothing is exempt except `/connect`.
|
||||
|
||||
### Cookie security
|
||||
|
||||
Cookies are the most sensitive data gstack handles. The design:
|
||||
|
||||
1. **Keychain access requires user approval.** First cookie import per browser triggers a macOS Keychain dialog. The user must click "Allow" or "Always Allow." gstack never silently accesses credentials.
|
||||
Cookie databases use a read-only runtime adapter: Bun SQLite in Bun, or built-in SQLite in Node.js 22.13+. Large Chromium timestamps remain exact integers; ordinary domain counts remain JSON numbers. Temporary database snapshots are private and removed on close or failure.
|
||||
|
||||
2. **Decryption happens in-process.** Cookie values are decrypted in memory (PBKDF2 + AES-128-CBC), loaded into the Playwright context, and never written to disk in plaintext. The cookie picker UI never displays cookie values — only domain names and counts.
|
||||
1. **OS key access follows platform permissions.** macOS may prompt for Keychain approval on the first import per browser. Linux supports libsecret-backed `v11` and the Chromium fallback key for `v10`; Windows supports DPAPI-compatible cookies. Permission denial stops the operation rather than automatically repeating prompts.
|
||||
|
||||
3. **Database is read-only.** gstack copies the Chromium cookie DB to a temp file (to avoid SQLite lock conflicts with the running browser) and opens it read-only. It never modifies your real browser's cookie database.
|
||||
2. **Import receipts do not contain cookie values.** Database decryption happens in memory using the platform's supported format; decrypted cookies are applied to the captured Playwright context. The picker shows browser/profile labels, domains, counts, and separate import/reset/authentication statuses, never cookie values. Labels can still identify an account and must not be copied to public logs. Optional session persistence is a separate opt-in disk-storage feature.
|
||||
|
||||
4. **Key caching is per-session.** The Keychain password + derived AES key are cached in memory for the server's lifetime. When the server shuts down (idle timeout or explicit stop), the cache is gone.
|
||||
3. **Database reads do not modify the source.** gstack copies the Chromium cookie DB to a temp file to avoid SQLite lock conflicts and opens it read-only. Only classified transient reads retry, at most three attempts with 150ms and 500ms delays. Native browser extraction has a separate lifecycle boundary and remains disabled pending qualification.
|
||||
|
||||
5. **No cookie values in logs.** Console, network, and dialog logs never contain cookie values. The `cookies` command outputs cookie metadata (domain, name, expiry) but values are truncated.
|
||||
4. **Key caching is per-session.** Derived keys are cached in memory for the server's lifetime. When the server shuts down (idle timeout or explicit stop), the cache is gone.
|
||||
|
||||
5. **Diagnostics use safe categories.** Cookie-import failures expose classified reasons and counts, not raw OS errors or decrypted values. The separate `cookies` inspection command redacts values that match its sensitive-name/value rules; it is not a metadata-only receipt and should not be used for public import summaries.
|
||||
|
||||
`cookie-import-operation.ts` owns profile selection, decryption, application, and receipts for the direct CLI, `--all`, and authenticated picker. Explicit profile selection wins; otherwise only a sole relevant profile is chosen, with unreadable profiles treated as unknown. Current `Local State` labels precede Preferences and directory fallbacks. Browser/context/page ownership is captured before asynchronous work, imports serialize per destination context, and applied domains feed the existing JavaScript-origin guard. Cookies are context-wide, not tab-isolated.
|
||||
|
||||
Storage reset and authentication verification are independent opt-ins. `--clear-storage` clears only the captured origin's localStorage (shared by same-origin tabs in that context) and target-tab sessionStorage, never IndexedDB, service workers, sibling origins, or other tabs' sessionStorage; `--all` plus reset is rejected. Reset is Chromium-only: an isolated world binds native-clock sampling and the destructive operation to one system-unique context, while navigation and the host deadline cancel pending work. Other engines retain import/auth checks but reject reset. A failed reset or later application failure does not imply rollback.
|
||||
|
||||
`cookie-auth-verification.ts` validates daemon-side `GSTACK_COOKIE_AUTH_SELECTOR` and `GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY` before a requested verification can mutate cookies or storage. Set them privately before daemon startup. After importing cookies, `--verify-auth` reloads the captured target and requires a successful same-origin response and exactly one visible identity whose normalized text matches exactly. Cookie count, HTTP 200, and substring matches cannot establish authentication. An import without this check is **not checked**, not verified; zero imports cannot verify sign-in. Results do not echo expected identity text.
|
||||
|
||||
### Shell injection prevention
|
||||
|
||||
The browser registry (Comet, Chrome, Arc, Brave, Edge) is hardcoded. Database paths are constructed from known constants, never from user input. Keychain access uses `Bun.spawn()` with explicit argument arrays, not shell string interpolation.
|
||||
The browser registry (Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, Dia) is hardcoded. Database roots come from known platform locations; profile directory input is validated. Keychain access uses `Bun.spawn()` with explicit argument arrays, not shell string interpolation.
|
||||
|
||||
### Egress receipt ledger (v1.63.0.0)
|
||||
|
||||
@@ -526,5 +534,5 @@ Anything that needs Aside itself — `test/skill-e2e-aside.test.ts`, the Aside c
|
||||
- **No WebSocket streaming.** HTTP request/response is simpler, debuggable with curl, and fast enough. Streaming would add complexity for marginal benefit.
|
||||
- **No MCP protocol.** MCP adds JSON schema overhead per request and requires a persistent connection. Plain HTTP + plain text output is lighter on tokens and easier to debug.
|
||||
- **No multi-user support.** One server per workspace, one user. The token auth is defense-in-depth, not multi-tenancy.
|
||||
- **No Windows/Linux cookie decryption.** macOS Keychain is the only supported credential store. Linux (GNOME Keyring/kwallet) and Windows (DPAPI) are architecturally possible but not implemented.
|
||||
- **No universal session recovery.** OS-backed cookie import supports macOS, Linux, and DPAPI-compatible Windows formats, not every encryption scheme or site's authentication state. Windows native extraction stays disabled pending qualification, and Chrome's protected default directory is not bypassed.
|
||||
- **No iframe auto-discovery.** `$B frame` supports cross-frame interaction (CSS selector, @ref, `--name`, `--url` matching), but the ref system does not auto-crawl iframes during `snapshot`. You must explicitly enter a frame context first.
|
||||
+27
-2
@@ -218,7 +218,7 @@ What changes when the fallback is active:
|
||||
|
||||
| On Aside | On the fallback engine |
|
||||
|---|---|
|
||||
| Your sessions are already there | `/setup-browser-cookies` imports them from Chrome, Arc, Brave, Edge, or Comet — or log in once in headed mode |
|
||||
| Your sessions are already there | `/setup-browser-cookies` copies selected cookies from Chrome, Chromium, Brave, Edge, or macOS-only Comet, Arc, and Dia; verify sign-in separately, or log in once in headed mode |
|
||||
| You watch the tabs the agent opens in Aside | `/open-gstack-browser` (or `$B connect`) shows the headed GStack Browser with the side panel |
|
||||
| Sign-in wall: sign in inside Aside, say "done" | `$B handoff` opens a visible Chrome at the same page; `$B resume` continues |
|
||||
| One `aside repl` script per flow, fresh session each time | Persistent daemon: cookies, tabs, and localStorage carry over between `$B` calls |
|
||||
@@ -562,10 +562,29 @@ from `snapshot`, or `@c` refs from `snapshot -C`. Full table:
|
||||
|---------|-------------|
|
||||
| `cookie <name>=<value>` | Set cookie on current page domain |
|
||||
| `cookie-import <json>` | Import cookies from JSON file |
|
||||
| `cookie-import-browser [browser] [--domain d]` | Import from installed Chromium browsers (interactive picker, or `--domain` for direct import) |
|
||||
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy selected browser cookies; picker by default, explicit scoped or all-domain import, optional storage reset and sign-in assertion |
|
||||
| `header <name>:<value>` | Set custom request header (sensitive values auto-redacted) |
|
||||
| `useragent <string>` | Set user agent (triggers context recreation, invalidates refs) |
|
||||
|
||||
#### Choosing a source and checking sign-in
|
||||
|
||||
Select the source browser and account/profile explicitly. The picker recognizes Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, and Dia. It shows current profile names from `Local State`, falling back to Preferences and then the directory name, with directory labels to distinguish duplicate names. `--profile` takes that directory (`Default`, `Profile 2`), not its display name. Without it, only a sole relevant profile is selected; ambiguity or unreadable profiles require a choice. The omitted-browser default remains `comet` for CLI compatibility, not as a recommendation. The picker opening link is one-use and expires after five minutes.
|
||||
|
||||
For direct import, first navigate to a page matching `--domain`. Example after choosing Chrome's `Profile 2`:
|
||||
|
||||
```bash
|
||||
$B goto https://example.com
|
||||
$B cookie-import-browser chrome --domain example.com --profile "Profile 2"
|
||||
```
|
||||
|
||||
`--all` explicitly selects every non-expired cookie in the chosen source profile; it cannot accompany `--domain` or `--clear-storage`. Cookies are applied to the captured browser context, not isolated to a tab. The receipt distinguishes imported, partial, empty, and failed results, plus separate storage-reset and authentication outcomes. Cookies copied with authentication `not_requested` means **not checked**, not logged in. Zero imports, cookie counts, and HTTP 200 alone never prove sign-in.
|
||||
|
||||
`--verify-auth` (also an explicit picker checkbox) reloads the captured target. Configure `GSTACK_COOKIE_AUTH_SELECTOR` and `GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY` privately in the **daemon environment before startup**; setting them only on a later CLI call does not reconfigure an existing daemon. Missing configuration rejects before mutation. Verification requires a successful same-origin response and exactly one visible element whose whitespace-normalized text equals the expected identity. A wrong account, login redirect, missing assertion, or changed target is not verified. Do not paste cookie values, passwords, profile/account labels, or expected identity into public logs; report only sanitized outcomes.
|
||||
|
||||
Storage stays intact by default. With explicit approval on a Chromium target, `--clear-storage` clears localStorage for the captured origin (exact scheme, host, and port, shared across that origin's tabs in the context) and sessionStorage for the target tab before applying cookies. Reset runs in an isolated world with a native monotonic deadline, so the site's scripts cannot forge its timeout clock. Other target engines reject reset; ordinary imports and authentication checks remain available. It does not clear other origins, other tabs' sessionStorage, IndexedDB, or service workers. Keep the target open and unchanged. A failed reset may have cleared some storage; a later cookie-application failure does not undo it.
|
||||
|
||||
**Platform limits:** macOS imports may request Keychain approval; Linux `v11` cookies may require libsecret, while `v10` uses Chromium's fallback key. The Windows Node server needs Node.js 22.13 or newer with built-in SQLite enabled for cookie database reads. DPAPI-compatible cookies remain supported, but native App-Bound Encryption extraction is disabled until the browser/runtime passes qualification. Chrome 136+ blocks remote debugging of its default user-data directory, including numbered profiles, over both pipe and TCP; closing Chrome does not remove that protection. There is no TCP fallback or real-profile-copy workaround. If import cannot recover the session, sign in manually in gstack's headed browser when a display is available.
|
||||
|
||||
### Tabs + frames
|
||||
|
||||
| Command | Description |
|
||||
@@ -1605,6 +1624,12 @@ browse/
|
||||
│ ├── terminal-agent.ts # Side Panel Claude PTY manager (auth + lifecycle)
|
||||
│ ├── sidebar-utils.ts # Sidebar URL sanitization + helpers
|
||||
│ ├── cookie-import-browser.ts # Decrypt + import cookies from real Chromium browsers
|
||||
│ ├── cookie-database.ts # Read-only Bun/Node SQLite with integer-safe cookie timestamps
|
||||
│ ├── cookie-import-operation.ts # Shared source selection, target policy, import receipts
|
||||
│ ├── cookie-auth-verification.ts # Opt-in target storage reset + exact identity assertion
|
||||
│ ├── cookie-import-native.ts # Qualification-gated Windows pipe extraction adapter
|
||||
│ ├── cookie-import-native-worker.ts # Supervised native launch/read/cleanup
|
||||
│ ├── cookie-import-native-job.ts # Windows owned-process job boundary
|
||||
│ ├── cookie-picker-routes.ts # HTTP routes for /cookie-picker/*
|
||||
│ ├── cookie-picker-ui.ts # Self-contained HTML/CSS/JS for cookie picker
|
||||
│ ├── network-capture.ts # Network request capture for $B network
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## [1.90.0.0] - 2026-09-24
|
||||
|
||||
Cookie imports now keep the chosen browser, profile, and destination explicit, show partial failures, and distinguish copying cookies from proving that you are signed in.
|
||||
|
||||
### Added
|
||||
- macOS Dia discovery and profile selection, using the existing Chromium cookie reader. Live native Dia import remains unqualified; fixture coverage is not a claim of native compatibility.
|
||||
- Optional sign-in verification against an exact, visible identity assertion on the selected destination, with separate copied and verified results.
|
||||
- Explicit current-origin storage recovery. Storage is preserved by default; an opted-in reset clears that origin's localStorage and only the target tab's sessionStorage.
|
||||
|
||||
### Fixed
|
||||
- Prefer renamed profiles from Local State, distinguish duplicate names, and require selection rather than guessing among plausible accounts. Bare and dotted domain selections now match the intended cookie scope without broadening it.
|
||||
- Register picker imports with the browser's imported-domain security guard, reject cross-origin picker mutations, and bind asynchronous operations to their original destination. Opening another picker makes old windows fail closed instead of silently changing their target.
|
||||
- Show complete, partial, zero, and failed imports accurately. Stale discovery responses and duplicate submissions no longer overwrite the current picker state.
|
||||
- Bound credential subprocess output and cleanup, retry only transient database reads, and prevent automatic replay of cookie-import mutations. The Node server uses a real read-only SQLite adapter.
|
||||
|
||||
### Changed
|
||||
- Picker handoff codes last five minutes and remain single-use. Browser guidance explains profile selection, storage-reset consent, and the difference between copied cookies and verified sign-in.
|
||||
- Native Windows extraction uses a sandboxed, owned-process, pipe-only path with bounded cleanup and no TCP fallback. Its qualification allowlist is empty in this release, so encrypted-cookie extraction through this path stays disabled with manual-sign-in guidance.
|
||||
- Added isolated platform qualification, launch diagnostics, and regression coverage. Native Dia and protected default-profile Windows qualification remain incomplete; diagnostic passes do not count as successful imports.
|
||||
|
||||
## [1.89.1.0] - 2026-09-24
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -238,7 +238,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan-
|
||||
| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini). |
|
||||
| `/browse` | **QA Engineer** | Give the agent eyes. Drives your [Aside](https://aside.com) browser first — your real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts. No Aside? It falls back to gstack's own Chromium: real clicks, ~100ms per command, and `/open-gstack-browser` shows it headed with sidebar, anti-bot stealth, and auto model routing. Every other browser skill stands on it. |
|
||||
| `/scrape` | **Data Extractor** | Pull structured data off a web page — tables, lists, prices — in your Aside browser with the page's real logged-in state. On the fallback browser, `/skillify` turns the flow into a permanent browser-skill that runs in ~200ms next time. |
|
||||
| `/setup-browser-cookies` | **Session Manager** | Import cookies from your real browser (Chrome, Arc, Brave, Edge) into gstack's bundled browser so it can test authenticated pages. Only needed on the fallback path — Aside already has your sessions. |
|
||||
| `/setup-browser-cookies` | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, or macOS-only Comet, Arc, and Dia into gstack's bundled browser. Choose your profile and domains; copying and sign-in verification are separate. Only needed on the fallback path — Aside already has your sessions. |
|
||||
| `/autoplan` | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → DX → eng review automatically (eng always last, so the shipping gate reviews the final amended plan) with encoded decision principles. Surfaces only taste decisions for your approval. |
|
||||
| `/spec` | **Spec Author** | Turn vague intent into a precise, executable spec in five phases (why, scope, technical with mandatory code-reading, draft, file). Outside-review quality gate before filing (Claude Code on Codex; Codex on other harnesses; blocks below 7/10), fail-closed secret redaction, dedupe against existing issues, archive to `$GSTACK_STATE_ROOT/projects/$SLUG/specs/` for team-corpus recall. `--execute` spawns `claude -p` in a fresh worktree; `/ship` auto-closes the source issue on merge. Plan-mode aware. |
|
||||
| `/learn` | **Memory** | Manage what gstack learned across sessions. Review, search, prune, and export project-specific patterns, pitfalls, and preferences. Learnings compound across sessions so gstack gets smarter on your codebase over time. |
|
||||
|
||||
@@ -172,10 +172,6 @@ wave"). Each was explicitly deferred with rationale, not dropped:
|
||||
the harness-pinned agent-sdk) carry `ignoreUntil` expiries (~2026-11-30) and
|
||||
re-justify themselves on expiry. When the agent-sdk pin next moves, drop the
|
||||
GHSA-p7fg ignore. Effort S. **Priority:** P3.
|
||||
- **#2701 cookie-import profile pills (Local State info_cache)** — confirmed
|
||||
bug + minimal fix known, but PR #2658 rewrites the same file; land or
|
||||
reject #2658 first, then apply the info_cache read + numeric-aware sort.
|
||||
Effort S. **Priority:** P3. **Blocked by:** #2658 disposition.
|
||||
- **#2750 split absorption** — the record-scanning Codex JSONL parser (real
|
||||
fix; current Codex streams interleave envelopes so sessions vanish from
|
||||
/retro global) should be absorbed once the author splits it from the
|
||||
@@ -3587,6 +3583,14 @@ needs one paid run to validate, so it didn't ride the ship.
|
||||
|
||||
## Completed
|
||||
|
||||
### #2701 cookie-import profile pills (Local State info_cache)
|
||||
|
||||
Current Local State names take precedence, Preferences/directory fallbacks remain,
|
||||
and Default sorts before numbered profiles in numeric order. Directory labels
|
||||
distinguish duplicate names.
|
||||
|
||||
**Completed:** v1.90.0.0 (2026-09-24)
|
||||
|
||||
### Reconcile the registered Opus 4.7 overlay efficacy gates
|
||||
|
||||
**What:** Revisit the two registered fanout experiments against the current overlay
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.89.1.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.90.0.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -432,6 +432,14 @@ goes through the Third-Party Web Actions contract, not through here. Rendering l
|
||||
HTML into a PNG or PDF is the render engine's job: use /make-pdf, /diagram, or
|
||||
/design-html for that.
|
||||
|
||||
On the fallback path, `/setup-browser-cookies` asks the user to choose the source
|
||||
browser and account/profile; Dia is macOS-only. Navigate to the matching target
|
||||
before a direct `--domain` import. Cookies copied is **not checked**, not proof
|
||||
of login. `--verify-auth` requires a daemon-configured exact visible identity
|
||||
assertion; HTTP 200 and cookie counts are not enough. Storage stays intact unless
|
||||
the user explicitly approves `--clear-storage` for the captured origin on a Chromium target. Other engines reject reset, not ordinary import or auth checks. Do not
|
||||
publish cookie values, passwords, profile/account text, or session details.
|
||||
|
||||
## Fallback command reference
|
||||
|
||||
The table in the Browser fallback section covers what the cookbook covers. Everything
|
||||
|
||||
@@ -111,6 +111,14 @@ goes through the Third-Party Web Actions contract, not through here. Rendering l
|
||||
HTML into a PNG or PDF is the render engine's job: use /make-pdf, /diagram, or
|
||||
/design-html for that.
|
||||
|
||||
On the fallback path, `/setup-browser-cookies` asks the user to choose the source
|
||||
browser and account/profile; Dia is macOS-only. Navigate to the matching target
|
||||
before a direct `--domain` import. Cookies copied is **not checked**, not proof
|
||||
of login. `--verify-auth` requires a daemon-configured exact visible identity
|
||||
assertion; HTTP 200 and cookie counts are not enough. Storage stays intact unless
|
||||
the user explicitly approves `--clear-storage` for the captured origin on a Chromium target. Other engines reject reset, not ordinary import or auth checks. Do not
|
||||
publish cookie values, passwords, profile/account text, or session details.
|
||||
|
||||
## Fallback command reference
|
||||
|
||||
The table in the Browser fallback section covers what the cookbook covers. Everything
|
||||
|
||||
@@ -37,8 +37,6 @@ bun build "$SRC_DIR/server.ts" \
|
||||
# Step 2: Post-process
|
||||
# Replace import.meta.dir with a resolvable reference
|
||||
perl -pi -e 's/import\.meta\.dir/__browseNodeSrcDir/g' "$DIST_DIR/server-node.mjs"
|
||||
# Stub out bun:sqlite (macOS-only cookie import, not needed on Windows)
|
||||
perl -pi -e 's|import \{ Database \} from "bun:sqlite";|const Database = null; // bun:sqlite stubbed on Node|g' "$DIST_DIR/server-node.mjs"
|
||||
|
||||
# Step 3: Create the final file with polyfill header injected after the first line
|
||||
{
|
||||
|
||||
@@ -93,7 +93,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
| `click <sel>` | Click element |
|
||||
| `cookie <name>=<value>` | Set cookie on current page domain |
|
||||
| `cookie-import <json>` | Import cookies from JSON file |
|
||||
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all]` | Import cookies from installed Chromium-family browsers. Browser names are the installed browser IDs shown by detection; common values include comet, chrome, chromium, edge, brave, arc. With --domain, imports only that domain after current-page domain validation; without --domain, opens the picker UI. --profile defaults to Default; --all imports every non-expired cookie only when explicitly passed. |
|
||||
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. |
|
||||
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
|
||||
| `dialog-dismiss` | Auto-dismiss next dialog |
|
||||
| `fill <sel> <val>` | Fill input |
|
||||
|
||||
+39
-16
@@ -159,10 +159,17 @@ globalThis.Bun = {
|
||||
parseInt(process.env.GSTACK_SPAWN_MAX_BUFFER || '', 10) || 16 * 1024 * 1024,
|
||||
);
|
||||
const drain = (stream) => {
|
||||
if (!stream) return { done: Promise.resolve(), chunks: [], truncated: false };
|
||||
const state = { chunks: [], bytes: 0, truncated: false };
|
||||
if (!stream) return { done: Promise.resolve(), chunks: [], cancel() {} };
|
||||
const state = { chunks: [], bytes: 0, truncated: false, cancelled: false, finished: false };
|
||||
let finish;
|
||||
const done = new Promise((resolve) => {
|
||||
finish = () => {
|
||||
if (state.finished) return;
|
||||
state.finished = true;
|
||||
resolve();
|
||||
};
|
||||
stream.on('data', (chunk) => {
|
||||
if (state.cancelled) return;
|
||||
if (state.bytes >= MAX_BUFFER) { state.truncated = true; return; }
|
||||
if (state.bytes + chunk.length <= MAX_BUFFER) {
|
||||
state.chunks.push(chunk);
|
||||
@@ -177,11 +184,18 @@ globalThis.Bun = {
|
||||
// Any terminal event resolves: 'end' on normal close, 'error' on a
|
||||
// stream-level error, 'close' as the belt-and-suspenders for spawn
|
||||
// failures where Node fires 'close' but neither 'end' nor 'error'.
|
||||
stream.once('end', resolve);
|
||||
stream.once('error', resolve);
|
||||
stream.once('close', resolve);
|
||||
stream.once('end', finish);
|
||||
stream.once('error', finish);
|
||||
stream.once('close', finish);
|
||||
});
|
||||
return { done, chunks: state.chunks };
|
||||
return { done, chunks: state.chunks, cancel() {
|
||||
if (state.cancelled) return;
|
||||
state.cancelled = true;
|
||||
state.chunks.length = 0;
|
||||
state.bytes = 0;
|
||||
finish();
|
||||
stream.destroy();
|
||||
} };
|
||||
};
|
||||
const stdoutDrain = drain(proc.stdout);
|
||||
const stderrDrain = drain(proc.stderr);
|
||||
@@ -218,20 +232,29 @@ globalThis.Bun = {
|
||||
.then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0));
|
||||
});
|
||||
|
||||
// Replay buffered output as a fresh Web ReadableStream. `start()` awaits
|
||||
// Replay buffered output as a fresh Web ReadableStream. `start()` observes
|
||||
// the drain before enqueueing so `new Response(proc.stdout).text()` yields
|
||||
// the complete output regardless of whether the consumer reads before or
|
||||
// after awaiting `proc.exited`. Stream is single-shot (locked after one
|
||||
// read), matching Bun's behavior.
|
||||
const replay = (d) => new ReadableStream({
|
||||
async start(controller) {
|
||||
await d.done;
|
||||
for (const chunk of d.chunks) {
|
||||
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const replay = (d) => {
|
||||
let cancelled = false;
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
d.done.then(() => {
|
||||
if (cancelled) return;
|
||||
for (const chunk of d.chunks) {
|
||||
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
||||
}
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
d.cancel();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
pid: proc.pid,
|
||||
|
||||
+9
-3
@@ -823,7 +823,7 @@ export function extractTabId(args: string[]): { tabId: number | undefined; args:
|
||||
}
|
||||
|
||||
// ─── Command Dispatch ──────────────────────────────────────────
|
||||
async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise<void> {
|
||||
export async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise<void> {
|
||||
// Precedence: CLI --tab-id flag > BROWSE_TAB env var.
|
||||
// make-pdf always passes --tab-id; human users typically rely on BROWSE_TAB
|
||||
// or the active tab.
|
||||
@@ -832,6 +832,7 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||
const envTab = process.env.BROWSE_TAB;
|
||||
const tabId = extracted.tabId ?? (envTab ? parseInt(envTab, 10) : undefined);
|
||||
const body = JSON.stringify({ command, args, ...(tabId !== undefined && !isNaN(tabId) ? { tabId } : {}) });
|
||||
const timeoutMs = command === 'cookie-import-browser' ? 90_000 : 30_000;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
|
||||
@@ -841,10 +842,11 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||
'Authorization': `Bearer ${state.token}`,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
if (command === 'cookie-import-browser') throw new Error('Cookie import authorization changed. Reopen the session and retry manually.');
|
||||
// Token mismatch — server may have restarted
|
||||
console.error('[browse] Auth failed — server may have restarted. Retrying...');
|
||||
const newState = readState();
|
||||
@@ -871,6 +873,10 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (command === 'cookie-import-browser' && (['AbortError', 'TimeoutError'].includes(err.name)
|
||||
|| ['ECONNREFUSED', 'ECONNRESET'].includes(err.code) || err.message?.includes('fetch failed'))) {
|
||||
throw new Error('Cookie import response was lost or timed out. It may have partially completed; inspect the destination before retrying manually. The command was not replayed.');
|
||||
}
|
||||
if (err.name === 'AbortError') {
|
||||
// #1781: a 30s timeout on a heavy page usually means busy, not dead.
|
||||
// Don't kill a live server (that's what triggered the crash-loop) — report
|
||||
@@ -1535,7 +1541,7 @@ Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
|
||||
scroll [sel] | wait <sel|--networkidle|--load> | viewport <WxH>
|
||||
upload <sel> <file1> [file2...]
|
||||
cookie-import <json-file>
|
||||
cookie-import-browser [browser] [--domain <d>]
|
||||
cookie-import-browser [browser] [--domain <d> | --all] [--profile <p>] [--clear-storage] [--verify-auth]
|
||||
Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
||||
console [--clear|--errors] | network [--clear] | dialog [--clear]
|
||||
cookies | storage [set <k> <v>] | perf
|
||||
|
||||
@@ -130,7 +130,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [<WxH>] [--scale <n>]' },
|
||||
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
|
||||
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
|
||||
'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from installed Chromium-family browsers. Browser names are the installed browser IDs shown by detection; common values include comet, chrome, chromium, edge, brave, arc. With --domain, imports only that domain after current-page domain validation; without --domain, opens the picker UI. --profile defaults to Default; --all imports every non-expired cookie only when explicitly passed.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all]' },
|
||||
'cookie-import-browser': { category: 'Interaction', description: 'Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]' },
|
||||
'header': { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header <name>:<value>' },
|
||||
'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
|
||||
'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' },
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { errors, type Page } from 'playwright';
|
||||
import { CookieImportError } from './cookie-import-browser';
|
||||
import { withCdpSession } from './cdp-bridge';
|
||||
|
||||
export interface CookieAuthVerificationOptions {
|
||||
identitySelector?: string;
|
||||
expectedIdentity?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
type VerificationReason = 'verified' | 'not_configured' | 'invalid_configuration'
|
||||
| 'invalid_target' | 'target_closed' | 'target_changed' | 'login_redirect'
|
||||
| 'http_error' | 'no_response' | 'identity_missing' | 'identity_ambiguous'
|
||||
| 'identity_mismatch' | 'timeout' | 'verification_failed';
|
||||
|
||||
type VerificationResult = { verified: boolean; reason: VerificationReason; status?: number };
|
||||
|
||||
const MAX_TIMEOUT_MS = 15_000;
|
||||
const LOGIN_PATH = /(?:^|\/)(?:log[-_]?in|sign[-_]?in|logon)(?:[\/.;]|$)|(?:^|\/)sessions?\/new(?:[\/.;]|$)/i;
|
||||
|
||||
function validateOrigin(expectedOrigin: string): void {
|
||||
try {
|
||||
const target = new URL(expectedOrigin);
|
||||
if ((target.protocol === 'http:' || target.protocol === 'https:') && target.origin === expectedOrigin) return;
|
||||
} catch {}
|
||||
throw new CookieImportError('A captured HTTP(S) target origin is required.', 'invalid_target');
|
||||
}
|
||||
|
||||
export function validateCookieAuthOptions(options: CookieAuthVerificationOptions): void {
|
||||
if (!options || typeof options.identitySelector !== 'string' || !options.identitySelector.trim()
|
||||
|| typeof options.expectedIdentity !== 'string' || !options.expectedIdentity.replace(/\s+/g, ' ').trim()) {
|
||||
throw new CookieImportError('Authentication verification requires an identity selector and expected identity.', 'verification_not_configured');
|
||||
}
|
||||
if (options.timeoutMs !== undefined && (typeof options.timeoutMs !== 'number'
|
||||
|| !Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
|
||||
throw new CookieImportError('Authentication verification requires a positive finite timeout.', 'invalid_verification_config');
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyCookieAuthentication(
|
||||
page: Page,
|
||||
options: CookieAuthVerificationOptions,
|
||||
expectedOrigin: string,
|
||||
): Promise<VerificationResult> {
|
||||
try {
|
||||
validateCookieAuthOptions(options);
|
||||
validateOrigin(expectedOrigin);
|
||||
} catch (error) {
|
||||
const reason = error instanceof CookieImportError && error.code === 'verification_not_configured'
|
||||
? 'not_configured' : error instanceof CookieImportError && error.code === 'invalid_target'
|
||||
? 'invalid_target' : 'invalid_configuration';
|
||||
return { verified: false, reason };
|
||||
}
|
||||
|
||||
const timeoutMs = Math.min(options.timeoutMs ?? MAX_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
const expectedIdentity = options.expectedIdentity!.replace(/\s+/g, ' ').trim();
|
||||
let finished = false;
|
||||
let status: number | undefined;
|
||||
let lastFailure: VerificationReason = 'timeout';
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<VerificationResult>(resolve => {
|
||||
timer = setTimeout(() => {
|
||||
finished = true;
|
||||
resolve({ verified: false, reason: lastFailure, ...(status === undefined ? {} : { status }) });
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([timeout, (async (): Promise<VerificationResult> => {
|
||||
if (page.isClosed()) return { verified: false, reason: 'target_closed' };
|
||||
if (new URL(page.url()).origin !== expectedOrigin) return { verified: false, reason: 'target_changed' };
|
||||
const response = await page.reload({ waitUntil: 'domcontentloaded', timeout: Math.max(1, deadline - performance.now()) });
|
||||
if (finished || performance.now() >= deadline) return { verified: false, reason: 'timeout' };
|
||||
if (page.isClosed()) return { verified: false, reason: 'target_closed' };
|
||||
const loadedUrl = page.url();
|
||||
if (new URL(loadedUrl).origin !== expectedOrigin) return { verified: false, reason: 'target_changed' };
|
||||
if (LOGIN_PATH.test(decodeURIComponent(new URL(loadedUrl).pathname))) return { verified: false, reason: 'login_redirect' };
|
||||
if (!response) return { verified: false, reason: 'no_response' };
|
||||
status = response.status();
|
||||
if (status < 200 || status >= 300) return { verified: false, reason: 'http_error', status };
|
||||
for (let request = response.request(); request; request = request.redirectedFrom()!) {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== expectedOrigin) return { verified: false, reason: 'target_changed', status };
|
||||
if (LOGIN_PATH.test(decodeURIComponent(url.pathname))) return { verified: false, reason: 'login_redirect', status };
|
||||
}
|
||||
if (new URL(response.url()).origin !== expectedOrigin
|
||||
|| new URL(response.url()).href !== new URL(loadedUrl.split('#')[0]).href) {
|
||||
return { verified: false, reason: 'target_changed', status };
|
||||
}
|
||||
|
||||
while (!finished && performance.now() < deadline) {
|
||||
if (page.isClosed()) return { verified: false, reason: 'target_closed', status };
|
||||
if (page.url() !== loadedUrl) return { verified: false, reason: 'target_changed', status };
|
||||
const reason = await page.locator(options.identitySelector!).filter({ visible: true }).evaluateAll((elements, expected) => {
|
||||
if (location.origin !== expected.origin || location.href !== expected.url) return 'target_changed';
|
||||
if (elements.length === 0) return 'identity_missing';
|
||||
if (elements.length !== 1) return 'identity_ambiguous';
|
||||
const element = elements[0];
|
||||
const text = element instanceof HTMLElement ? element.innerText : element.textContent ?? '';
|
||||
return text.replace(/\s+/g, ' ').trim() === expected.identity ? 'verified' : 'identity_mismatch';
|
||||
}, { origin: expectedOrigin, url: loadedUrl, identity: expectedIdentity });
|
||||
if (finished || performance.now() >= deadline) return { verified: false, reason: lastFailure, status };
|
||||
if (page.isClosed()) return { verified: false, reason: 'target_closed', status };
|
||||
if (page.url() !== loadedUrl) return { verified: false, reason: 'target_changed', status };
|
||||
if (reason === 'target_changed' || reason === 'verified') return { verified: reason === 'verified', reason, status };
|
||||
lastFailure = reason;
|
||||
const remaining = deadline - performance.now();
|
||||
if (remaining <= 0) return { verified: false, reason, status };
|
||||
await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining)));
|
||||
}
|
||||
return { verified: false, reason: lastFailure, ...(status === undefined ? {} : { status }) };
|
||||
})()]);
|
||||
} catch (error) {
|
||||
const reason = error instanceof errors.TimeoutError || finished || performance.now() >= deadline ? 'timeout'
|
||||
: page.isClosed() ? 'target_closed' : 'verification_failed';
|
||||
return { verified: false, reason, ...(status === undefined ? {} : { status }) };
|
||||
} finally {
|
||||
finished = true;
|
||||
clearTimeout(timer!);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCookieStorageSupport(page: Page): void {
|
||||
try {
|
||||
if (page.context().browser()?.browserType().name() === 'chromium') return;
|
||||
} catch {}
|
||||
throw new CookieImportError('Storage reset requires a Chromium target. Import cookies without storage reset on other browsers.', 'storage_reset_unsupported');
|
||||
}
|
||||
|
||||
export async function clearCookieTargetStorage(page: Page, expectedOrigin: string): Promise<void> {
|
||||
validateOrigin(expectedOrigin);
|
||||
validateCookieStorageSupport(page);
|
||||
if (page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed');
|
||||
let targetUrl: string;
|
||||
try {
|
||||
targetUrl = page.url();
|
||||
if (new URL(targetUrl).origin !== expectedOrigin) {
|
||||
throw new CookieImportError('The captured target has changed.', 'target_changed');
|
||||
}
|
||||
} catch {
|
||||
throw new CookieImportError('The captured target has changed.', 'target_changed');
|
||||
}
|
||||
|
||||
const deadline = performance.now() + MAX_TIMEOUT_MS;
|
||||
let expired = false;
|
||||
let navigated = false;
|
||||
const frame = page.mainFrame();
|
||||
const navigation = Promise.withResolvers<string>();
|
||||
const onNavigation = (changed: typeof frame) => {
|
||||
if (changed === frame) { navigated = true; navigation.resolve('target_changed'); }
|
||||
};
|
||||
const onClose = () => { navigated = true; navigation.resolve('target_changed'); };
|
||||
page.on('framenavigated', onNavigation);
|
||||
page.on('close', onClose);
|
||||
const cancelled = () => expired || performance.now() >= deadline ? 'storage_reset_timeout'
|
||||
: navigated || page.isClosed() || page.url() !== targetUrl ? 'target_changed' : undefined;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<string>(resolve => {
|
||||
timer = setTimeout(() => {
|
||||
expired = true;
|
||||
resolve('storage_reset_timeout');
|
||||
}, Math.max(0, deadline - performance.now()));
|
||||
});
|
||||
const cancellation = Promise.race([timeout, navigation.promise]);
|
||||
let result: string;
|
||||
try {
|
||||
result = await Promise.race([cancellation, withCdpSession(page, async session => {
|
||||
let isolated: { id: number; uniqueId: string } | undefined;
|
||||
let frameId: string | undefined;
|
||||
const worldName = 'gstack-cookie-storage-reset';
|
||||
const onContext = ({ context }: any) => {
|
||||
if (context.name === worldName && context.auxData?.frameId === frameId && context.auxData?.isDefault === false) isolated = context;
|
||||
};
|
||||
session.on('Runtime.executionContextCreated', onContext);
|
||||
try {
|
||||
return await Promise.race([cancellation, (async () => {
|
||||
if (cancelled()) return cancelled()!;
|
||||
const tree = await session.send('Page.getFrameTree');
|
||||
if (cancelled()) return cancelled()!;
|
||||
frameId = tree.frameTree.frame.id;
|
||||
await session.send('Runtime.enable');
|
||||
if (cancelled()) return cancelled()!;
|
||||
const world = await session.send('Page.createIsolatedWorld', { frameId, worldName, grantUniveralAccess: false });
|
||||
if (cancelled()) return cancelled()!;
|
||||
if (!isolated?.uniqueId || isolated.id !== world.executionContextId) return 'storage_reset_failed';
|
||||
const uniqueContextId = isolated.uniqueId;
|
||||
const clock = await session.send('Runtime.evaluate', { expression: 'performance.now()', uniqueContextId, returnByValue: true, silent: true });
|
||||
const remaining = deadline - performance.now();
|
||||
if (cancelled() || remaining <= 0) return cancelled() ?? 'storage_reset_timeout';
|
||||
if (clock.exceptionDetails || !Number.isFinite(clock.result?.value)) return 'storage_reset_failed';
|
||||
const cleared = await session.send('Runtime.callFunctionOn', {
|
||||
uniqueContextId,
|
||||
functionDeclaration: String(({ origin, url, deadline }: { origin: string; url: string; deadline: number }) => {
|
||||
if (performance.now() >= deadline) return 'storage_reset_timeout';
|
||||
if (location.origin !== origin || location.href !== url) return 'target_changed';
|
||||
localStorage.clear();
|
||||
if (performance.now() >= deadline) return 'storage_reset_timeout';
|
||||
sessionStorage.clear();
|
||||
return 'cleared';
|
||||
}),
|
||||
arguments: [{ value: { origin: expectedOrigin, url: targetUrl, deadline: clock.result.value + remaining } }],
|
||||
returnByValue: true,
|
||||
silent: true,
|
||||
});
|
||||
if (cancelled()) return cancelled()!;
|
||||
if (cleared.exceptionDetails || !['cleared', 'target_changed', 'storage_reset_timeout'].includes(cleared.result?.value)) return 'storage_reset_failed';
|
||||
return cleared.result.value;
|
||||
})()]);
|
||||
} finally {
|
||||
session.off('Runtime.executionContextCreated', onContext);
|
||||
}
|
||||
})]);
|
||||
} catch {
|
||||
result = cancelled() ?? 'storage_reset_failed';
|
||||
} finally {
|
||||
expired = true;
|
||||
clearTimeout(timer!);
|
||||
page.off('framenavigated', onNavigation);
|
||||
page.off('close', onClose);
|
||||
}
|
||||
if (result === 'storage_reset_timeout') throw new CookieImportError('Target storage reset timed out; storage may be partially cleared.', 'storage_reset_timeout');
|
||||
if (result === 'target_changed') throw new CookieImportError('The captured target has changed.', 'target_changed');
|
||||
if (result !== 'cleared') throw new CookieImportError('Target storage reset failed; storage may be partially cleared.', 'storage_reset_failed');
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function databaseError(error: unknown): Error & { code: string } {
|
||||
const detail = error as { errcode?: number; errno?: number; code?: string } | null;
|
||||
const codes: Record<number, string> = {
|
||||
5: 'SQLITE_BUSY', 6: 'SQLITE_LOCKED', 8: 'SQLITE_READONLY', 10: 'SQLITE_IOERR',
|
||||
11: 'SQLITE_CORRUPT', 14: 'SQLITE_CANTOPEN', 26: 'SQLITE_CORRUPT',
|
||||
};
|
||||
const number = detail?.errcode ?? detail?.errno;
|
||||
const code = typeof number === 'number' ? codes[number & 255] ?? 'SQLITE_ERROR'
|
||||
: Object.values(codes).includes(detail?.code ?? '') ? detail!.code! : 'SQLITE_ERROR';
|
||||
const message = code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED'
|
||||
? 'Cookie database is locked. Close the source browser and retry.'
|
||||
: 'Cookie database operation failed (' + code + ').';
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
export function openCookieDatabase(dbPath: string): {
|
||||
query(sql: string): { all(...bindings: any[]): unknown[] };
|
||||
close(): void;
|
||||
} {
|
||||
const isBun = typeof process.versions.bun === 'string';
|
||||
let Database;
|
||||
try {
|
||||
const sqlite = require(isBun ? 'bun:sqlite' : 'node:sqlite');
|
||||
Database = isBun ? sqlite.Database : sqlite.DatabaseSync;
|
||||
if (typeof Database !== 'function') throw new Error();
|
||||
} catch {
|
||||
throw Object.assign(new Error(isBun
|
||||
? 'Cookie import requires a Bun runtime with SQLite support. Upgrade Bun and retry.'
|
||||
: 'Cookie import requires Node.js 22.13 or newer with built-in SQLite enabled. Upgrade Node.js or enable SQLite and retry.'), { code: 'sqlite_unavailable' });
|
||||
}
|
||||
|
||||
let database;
|
||||
try {
|
||||
database = new Database(dbPath, isBun ? { readonly: true, safeIntegers: true } : { readOnly: true });
|
||||
} catch (error) {
|
||||
throw databaseError(error);
|
||||
}
|
||||
|
||||
return {
|
||||
query(sql) {
|
||||
let statement;
|
||||
try {
|
||||
statement = isBun ? database.query(sql) : database.prepare(sql);
|
||||
if (!isBun) statement.setReadBigInts(true);
|
||||
} catch (error) {
|
||||
throw databaseError(error);
|
||||
}
|
||||
return {
|
||||
all(...bindings) {
|
||||
try {
|
||||
return statement.all(...bindings).map((row: Record<string, unknown>) => Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [key,
|
||||
typeof value === 'bigint' && Number.isSafeInteger(Number(value)) ? Number(value) : value]),
|
||||
));
|
||||
} catch (error) {
|
||||
throw databaseError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
close() {
|
||||
try {
|
||||
database.close();
|
||||
} catch (error) {
|
||||
throw databaseError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+164
-346
@@ -35,12 +35,12 @@
|
||||
* └──────────────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { openCookieDatabase } from './cookie-database';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { TEMP_DIR } from './platform';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface ImportResult {
|
||||
count: number;
|
||||
failed: number;
|
||||
domainCounts: Record<string, number>;
|
||||
failureReasons?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface PlaywrightCookie {
|
||||
@@ -109,6 +110,7 @@ const BROWSER_REGISTRY: BrowserInfo[] = [
|
||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/' },
|
||||
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/' },
|
||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||
{ name: 'Dia', dataDir: 'Dia/User Data/', keychainService: 'Dia Safe Storage', aliases: ['dia'] },
|
||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/' },
|
||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/' },
|
||||
];
|
||||
@@ -168,6 +170,11 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
const browserDir = path.join(getBaseDir(platform), dataDir);
|
||||
if (!fs.existsSync(browserDir)) continue;
|
||||
|
||||
let profileNames: Record<string, { name?: unknown }> = {};
|
||||
try {
|
||||
profileNames = JSON.parse(fs.readFileSync(path.join(browserDir, 'Local State'), 'utf-8'))?.profile?.info_cache ?? {};
|
||||
} catch {}
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
||||
@@ -209,6 +216,9 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
// Ignore — fall back to directory name
|
||||
}
|
||||
|
||||
const currentName = profileNames?.[entry.name]?.name;
|
||||
if (typeof currentName === 'string' && currentName.trim()) displayName = currentName.trim();
|
||||
|
||||
profiles.push({ name: entry.name, displayName });
|
||||
}
|
||||
|
||||
@@ -216,7 +226,48 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
if (profiles.length > 0) break;
|
||||
}
|
||||
|
||||
return profiles;
|
||||
return profiles.sort((a, b) => a.name === b.name ? 0 : a.name === 'Default' ? -1 : b.name === 'Default' ? 1 : a.name.localeCompare(b.name, 'en', { numeric: true }));
|
||||
}
|
||||
|
||||
export function normalizeCookieDomain(domain: string): string {
|
||||
if (typeof domain !== 'string' || !domain || domain.length > 254 || /[\s\/@?#\\*]/.test(domain)) {
|
||||
throw new CookieImportError('Invalid cookie domain', 'bad_request');
|
||||
}
|
||||
let host = domain.replace(/^\./, '').replace(/\.$/, '');
|
||||
if (isIP(host) === 6) host = '[' + host + ']';
|
||||
try {
|
||||
if (host.includes(':') && (!host.startsWith('[') || !host.endsWith(']') || isIP(host.slice(1, -1)) !== 6)) throw new Error();
|
||||
const url = new URL('http://' + host);
|
||||
if (!url.hostname || url.hostname.length > 253 || url.port || url.pathname !== '/' || url.hostname.split('.').some(label => !label)) throw new Error();
|
||||
return url.hostname;
|
||||
} catch {
|
||||
throw new CookieImportError('Invalid cookie domain', 'bad_request');
|
||||
}
|
||||
}
|
||||
|
||||
export function cookieDomainMatches(hostname: string, cookieDomain: string): boolean {
|
||||
const host = normalizeCookieDomain(hostname);
|
||||
const domain = normalizeCookieDomain(cookieDomain);
|
||||
const address = isIP(domain.startsWith('[') ? domain.slice(1, -1) : domain);
|
||||
return host === domain || (!address && cookieDomain.startsWith('.') && host.endsWith('.' + domain));
|
||||
}
|
||||
|
||||
export async function withCookieReadRetry<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err: any) {
|
||||
if (attempt < 2 && ['db_locked', 'SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) {
|
||||
await new Promise(resolve => setTimeout(resolve, [150, 500][attempt]));
|
||||
continue;
|
||||
}
|
||||
if (['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) throw new CookieImportError('Cookie database is busy. Close the source browser and retry.', 'db_locked', 'retry');
|
||||
if (err?.code === 'SQLITE_CORRUPT') throw new CookieImportError('Cookie database is corrupt', 'db_corrupt');
|
||||
if (err?.code === 'SQLITE_READONLY') throw new CookieImportError('Cookie database access was denied', 'db_permission');
|
||||
if (typeof err?.code === 'string' && err.code.startsWith('SQLITE_')) throw new CookieImportError('Cookie database could not be read', 'db_read_error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,15 +302,18 @@ export async function importCookies(
|
||||
): Promise<ImportResult> {
|
||||
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||
|
||||
const selectedDomains = [...new Set(domains.flatMap(domain => {
|
||||
const normalized = normalizeCookieDomain(domain);
|
||||
return [normalized, '.' + normalized];
|
||||
}))];
|
||||
const browser = resolveBrowser(browserName);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const derivedKeys = await getDerivedKeys(match);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
|
||||
try {
|
||||
const now = chromiumNow();
|
||||
// Parameterized query — no SQL injection
|
||||
const placeholders = domains.map(() => '?').join(',');
|
||||
const placeholders = selectedDomains.map(() => '?').join(',');
|
||||
const rows = db.query(
|
||||
`SELECT host_key, name, value, encrypted_value, path, expires_utc,
|
||||
is_secure, is_httponly, has_expires, samesite
|
||||
@@ -267,11 +321,15 @@ export async function importCookies(
|
||||
WHERE host_key IN (${placeholders})
|
||||
AND (has_expires = 0 OR expires_utc > ?)
|
||||
ORDER BY host_key, name`
|
||||
).all(...domains, now) as RawCookie[];
|
||||
).all(...selectedDomains, now) as RawCookie[];
|
||||
|
||||
const needsKey = rows.some(row => !row.value && row.encrypted_value.length > 0 && Buffer.from(row.encrypted_value).subarray(0, 3).toString() !== 'v20');
|
||||
const derivedKeys = needsKey ? await getDerivedKeys(match) : new Map<string, Buffer>();
|
||||
|
||||
const cookies: PlaywrightCookie[] = [];
|
||||
let failed = 0;
|
||||
const domainCounts: Record<string, number> = {};
|
||||
const domainCounts: Record<string, number> = Object.create(null);
|
||||
const failureReasons: Record<string, number> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
@@ -279,12 +337,14 @@ export async function importCookies(
|
||||
const cookie = toPlaywrightCookie(row, value);
|
||||
cookies.push(cookie);
|
||||
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
failed++;
|
||||
const reason = err instanceof CookieImportError && err.code === 'v20_encryption' ? 'unsupported_encryption' : 'decryption_failed';
|
||||
failureReasons[reason] = (failureReasons[reason] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { cookies, count: cookies.length, failed, domainCounts };
|
||||
return { cookies, count: cookies.length, failed, domainCounts, failureReasons };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -384,7 +444,7 @@ function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
|
||||
|
||||
// ─── Internal: SQLite Access ────────────────────────────────────
|
||||
|
||||
function openDb(dbPath: string, browserName: string): Database {
|
||||
function openDb(dbPath: string, browserName: string): ReturnType<typeof openCookieDatabase> {
|
||||
// On Windows, Chrome holds exclusive WAL locks even when we open readonly.
|
||||
// The readonly open may "succeed" but return empty results because the WAL
|
||||
// (where all actual data lives) can't be replayed. Always use the copy
|
||||
@@ -393,45 +453,59 @@ function openDb(dbPath: string, browserName: string): Database {
|
||||
return openDbFromCopy(dbPath, browserName);
|
||||
}
|
||||
try {
|
||||
return new Database(dbPath, { readonly: true });
|
||||
return openCookieDatabase(dbPath);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('SQLITE_BUSY') || err.message?.includes('database is locked')) {
|
||||
if (err?.code === 'sqlite_unavailable') throw new CookieImportError(err.message, 'sqlite_unavailable');
|
||||
if (['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) {
|
||||
return openDbFromCopy(dbPath, browserName);
|
||||
}
|
||||
if (err.message?.includes('SQLITE_CORRUPT') || err.message?.includes('malformed')) {
|
||||
if (err?.code === 'SQLITE_CORRUPT') {
|
||||
throw new CookieImportError(
|
||||
`Cookie database for ${browserName} is corrupt`,
|
||||
'db_corrupt',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
throw new CookieImportError('Cookie database could not be read', 'db_read_error');
|
||||
}
|
||||
}
|
||||
|
||||
function openDbFromCopy(dbPath: string, browserName: string): Database {
|
||||
function openDbFromCopy(dbPath: string, browserName: string): ReturnType<typeof openCookieDatabase> {
|
||||
// Use os.tmpdir() instead of hardcoded /tmp for cross-platform support (#708)
|
||||
const tmpPath = path.join(os.tmpdir(), `browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`);
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-cookies-'));
|
||||
const tmpPath = path.join(tmpDir, 'Cookies');
|
||||
try {
|
||||
fs.chmodSync(tmpDir, 0o700);
|
||||
fs.copyFileSync(dbPath, tmpPath);
|
||||
fs.chmodSync(tmpPath, 0o600);
|
||||
// Also copy WAL and SHM if they exist (for consistent reads)
|
||||
const walPath = dbPath + '-wal';
|
||||
const shmPath = dbPath + '-shm';
|
||||
if (fs.existsSync(walPath)) fs.copyFileSync(walPath, tmpPath + '-wal');
|
||||
if (fs.existsSync(shmPath)) fs.copyFileSync(shmPath, tmpPath + '-shm');
|
||||
if (fs.existsSync(walPath)) {
|
||||
fs.copyFileSync(walPath, tmpPath + '-wal');
|
||||
fs.chmodSync(tmpPath + '-wal', 0o600);
|
||||
}
|
||||
if (fs.existsSync(shmPath)) {
|
||||
fs.copyFileSync(shmPath, tmpPath + '-shm');
|
||||
fs.chmodSync(tmpPath + '-shm', 0o600);
|
||||
}
|
||||
|
||||
const db = new Database(tmpPath, { readonly: true });
|
||||
const db = openCookieDatabase(tmpPath);
|
||||
// Schedule cleanup after the DB is closed
|
||||
const origClose = db.close.bind(db);
|
||||
db.close = () => {
|
||||
origClose();
|
||||
try { fs.unlinkSync(tmpPath); } catch {}
|
||||
try { fs.unlinkSync(tmpPath + '-wal'); } catch {}
|
||||
try { fs.unlinkSync(tmpPath + '-shm'); } catch {}
|
||||
try { origClose(); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); }
|
||||
};
|
||||
return db;
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
// Clean up on failure
|
||||
try { fs.unlinkSync(tmpPath); } catch {}
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
if (err?.code === 'sqlite_unavailable') throw new CookieImportError(err.message, 'sqlite_unavailable');
|
||||
if (err?.code === 'SQLITE_CORRUPT') throw new CookieImportError('Cookie database is corrupt', 'db_corrupt');
|
||||
if (err?.code === 'EACCES' || err?.code === 'EPERM') throw new CookieImportError('Cookie database access denied', 'db_permission');
|
||||
if (err?.code === 'ENOENT') throw new CookieImportError('Cookie database no longer exists', 'db_missing');
|
||||
if (!/SQLITE_BUSY|SQLITE_LOCKED|database is locked|EBUSY/.test(String(err?.code) + String(err?.message))) {
|
||||
throw new CookieImportError('Cookie database could not be read', 'db_read_error');
|
||||
}
|
||||
throw new CookieImportError(
|
||||
`Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`,
|
||||
'db_locked',
|
||||
@@ -494,9 +568,8 @@ async function getWindowsAesKey(browser: BrowserInfo): Promise<Buffer> {
|
||||
try {
|
||||
localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? `: ${err.message}` : '';
|
||||
throw new CookieImportError(
|
||||
`Cannot read Local State for ${browser.name} at ${localStatePath}${reason}`,
|
||||
`Cannot read Local State for ${browser.name}`,
|
||||
'keychain_error',
|
||||
);
|
||||
}
|
||||
@@ -532,33 +605,61 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
proc.stdin.write(encryptedBytes.toString('base64'));
|
||||
proc.stdin.end();
|
||||
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
|
||||
}, 10_000),
|
||||
);
|
||||
|
||||
try {
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
proc.stdin.write(encryptedBytes.toString('base64'));
|
||||
proc.stdin.end();
|
||||
const { exitCode, stdout } = await readCredentialProcess(proc, 10_000, () =>
|
||||
new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
throw new CookieImportError(`DPAPI decryption failed: ${stderr.trim()}`, 'keychain_error');
|
||||
throw new CookieImportError('DPAPI decryption failed', 'keychain_error');
|
||||
}
|
||||
return Buffer.from(stdout.trim(), 'base64');
|
||||
} catch (err) {
|
||||
if (err instanceof CookieImportError) throw err;
|
||||
throw new CookieImportError(
|
||||
`DPAPI decryption failed: ${(err as Error).message}`,
|
||||
'DPAPI decryption failed',
|
||||
'keychain_error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readCredentialProcess(
|
||||
proc: { exited: Promise<number>; stdout: ReadableStream<Uint8Array>; stderr: ReadableStream<Uint8Array>; kill(): void },
|
||||
timeoutMs: number,
|
||||
timeoutError: () => Error,
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const readers = [proc.stdout.getReader(), proc.stderr.getReader()];
|
||||
const read = async (reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return Buffer.concat(chunks, bytes).toString('utf8');
|
||||
bytes += value.byteLength;
|
||||
if (bytes > 64 * 1024) throw new Error('Credential process output exceeded the limit');
|
||||
chunks.push(value);
|
||||
}
|
||||
};
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(timeoutError()), timeoutMs);
|
||||
});
|
||||
try {
|
||||
const [exitCode, stdout, stderr] = await Promise.race([
|
||||
Promise.all([proc.exited, read(readers[0]), read(readers[1])]), timeout,
|
||||
]);
|
||||
return { exitCode, stdout, stderr };
|
||||
} catch (error) {
|
||||
try { proc.kill(); } catch {}
|
||||
for (const reader of readers) {
|
||||
try { void reader.cancel().catch(() => {}); } catch {}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer!);
|
||||
}
|
||||
}
|
||||
|
||||
async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
// Use async Bun.spawn with timeout to avoid blocking the event loop.
|
||||
// macOS may show an Allow/Deny dialog that blocks until the user responds.
|
||||
@@ -567,21 +668,13 @@ async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
{ stdout: 'pipe', stderr: 'pipe', windowsHide: true },
|
||||
);
|
||||
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new CookieImportError(
|
||||
try {
|
||||
const { exitCode, stdout, stderr } = await readCredentialProcess(proc, 10_000, () =>
|
||||
new CookieImportError(
|
||||
`macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`,
|
||||
'keychain_timeout',
|
||||
'retry',
|
||||
));
|
||||
}, 10_000),
|
||||
);
|
||||
|
||||
try {
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
if (exitCode !== 0) {
|
||||
// Distinguish denied vs not found vs other
|
||||
@@ -600,7 +693,7 @@ async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
);
|
||||
}
|
||||
throw new CookieImportError(
|
||||
`Could not read Keychain: ${stderr.trim()}`,
|
||||
'Could not read Keychain',
|
||||
'keychain_error',
|
||||
'retry',
|
||||
);
|
||||
@@ -610,7 +703,7 @@ async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
} catch (err) {
|
||||
if (err instanceof CookieImportError) throw err;
|
||||
throw new CookieImportError(
|
||||
`Could not read Keychain: ${(err as Error).message}`,
|
||||
'Could not read Keychain',
|
||||
'keychain_error',
|
||||
'retry',
|
||||
);
|
||||
@@ -640,15 +733,7 @@ async function getLinuxSecretPassword(browser: BrowserInfo): Promise<string | nu
|
||||
async function runPasswordLookup(cmd: string[], timeoutMs: number): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', windowsHide: true });
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error('timeout'));
|
||||
}, timeoutMs),
|
||||
);
|
||||
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const { exitCode, stdout } = await readCredentialProcess(proc, timeoutMs, () => new Error('timeout'));
|
||||
if (exitCode !== 0) return null;
|
||||
|
||||
const password = stdout.trim();
|
||||
@@ -752,295 +837,28 @@ function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' {
|
||||
}
|
||||
|
||||
|
||||
// ─── CDP-based Cookie Extraction (Windows v20 fallback) ────────
|
||||
// When App-Bound Encryption (v20) is detected, we launch Chrome headless
|
||||
// with remote debugging and extract cookies via the DevTools Protocol.
|
||||
// This only works when Chrome is NOT already running (profile lock).
|
||||
|
||||
const CHROME_PATHS_WIN = [
|
||||
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
];
|
||||
|
||||
const EDGE_PATHS_WIN = [
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
||||
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
||||
];
|
||||
|
||||
function findBrowserExe(browserName: string): string | null {
|
||||
const candidates = browserName.toLowerCase().includes('edge') ? EDGE_PATHS_WIN : CHROME_PATHS_WIN;
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBrowserRunning(browserName: string): Promise<boolean> {
|
||||
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
|
||||
return new Promise((resolve) => {
|
||||
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
|
||||
stdout: 'pipe', stderr: 'pipe', windowsHide: true,
|
||||
});
|
||||
proc.exited.then(async () => {
|
||||
const out = await new Response(proc.stdout).text();
|
||||
resolve(out.toLowerCase().includes(exe));
|
||||
}).catch(() => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract cookies via Chrome DevTools Protocol. Launches Chrome headless with
|
||||
* remote debugging on the user's real profile directory. Requires Chrome to be
|
||||
* closed first (profile lock).
|
||||
*
|
||||
* v20 App-Bound Encryption binds decryption keys to the original user-data-dir
|
||||
* path, so a temp copy of the profile won't work — Chrome silently discards
|
||||
* cookies it can't decrypt. We must use the real profile.
|
||||
*/
|
||||
export async function importCookiesViaCdp(
|
||||
browserName: string,
|
||||
domains: string[],
|
||||
profile = 'Default',
|
||||
): Promise<ImportResult> {
|
||||
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||
if (process.platform !== 'win32') {
|
||||
throw new CookieImportError('CDP extraction is only needed on Windows', 'not_supported');
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') throw new CookieImportError('Native extraction is only supported on Windows', 'not_supported');
|
||||
const browser = resolveBrowser(browserName);
|
||||
const exePath = findBrowserExe(browser.name);
|
||||
if (!exePath) {
|
||||
throw new CookieImportError(
|
||||
`Cannot find ${browser.name} executable. Install it or use /connect-chrome.`,
|
||||
'not_installed',
|
||||
);
|
||||
}
|
||||
|
||||
if (await isBrowserRunning(browser.name)) {
|
||||
throw new CookieImportError(
|
||||
`${browser.name} is running. Close it first so we can launch headless with your profile, or use /connect-chrome to control your real browser directly.`,
|
||||
'browser_running',
|
||||
'retry',
|
||||
);
|
||||
}
|
||||
|
||||
// Must use the real user data dir — v20 ABE keys are path-bound
|
||||
validateProfile(profile);
|
||||
const dataDir = getDataDirForPlatform(browser, 'win32');
|
||||
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
|
||||
const userDataDir = path.join(getBaseDir('win32'), dataDir);
|
||||
|
||||
// Launch Chrome headless with remote debugging on the real profile.
|
||||
//
|
||||
// Security posture of the debug port:
|
||||
// - Chrome binds --remote-debugging-port to 127.0.0.1 by default. The
|
||||
// port is NOT exposed to the network. Baseline threat: a local
|
||||
// process running as the same user can connect.
|
||||
// - Port is randomized in [9222, 9321] to avoid collisions with other
|
||||
// Chrome-based tools. Not cryptographic — security relies on
|
||||
// same-user-access baseline, not port secrecy.
|
||||
// - Chrome is always killed in the finally block below (even on crash).
|
||||
//
|
||||
// KNOWN NON-GOAL (tracked as a separate hardening task for the next
|
||||
// security wave):
|
||||
// On Windows 10.15+ with App-Bound Encryption (v20) enabled, a
|
||||
// same-user process that opens the cookie DB directly cannot decrypt
|
||||
// v20 values — the DPAPI context is bound to the browser process.
|
||||
// The CDP port bypasses that: `Network.getAllCookies` runs inside the
|
||||
// browser, so any same-user process that connects to the debug port
|
||||
// before we kill Chrome could exfiltrate decrypted v20 cookies.
|
||||
// Fix direction: switch to `--remote-debugging-pipe` so the CDP
|
||||
// transport is a parent/child stdio pipe, not TCP. Requires
|
||||
// restructuring the extractCookiesViaCdp WebSocket client; deferred
|
||||
// to a follow-up because the transport swap is non-trivial and the
|
||||
// baseline threat is still "attacker already has same-user access."
|
||||
//
|
||||
// Debugging note: if this path starts failing after a Chrome update,
|
||||
// check the Chrome version logged below — Chrome's ABE key format (v20)
|
||||
// or /json/list shape can change between major versions.
|
||||
const debugPort = 9222 + Math.floor(Math.random() * 100);
|
||||
const chromeProc = Bun.spawn([
|
||||
exePath,
|
||||
`--remote-debugging-port=${debugPort}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
`--profile-directory=${profile}`,
|
||||
'--headless=new',
|
||||
'--no-first-run',
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--no-default-browser-check',
|
||||
], { stdout: 'pipe', stderr: 'pipe', windowsHide: true });
|
||||
|
||||
// Wait for Chrome to start, then find a page target's WebSocket URL.
|
||||
// Network.getAllCookies is only available on page targets, not browser.
|
||||
let wsUrl: string | null = null;
|
||||
const startTime = Date.now();
|
||||
let loggedVersion = false;
|
||||
while (Date.now() - startTime < 15_000) {
|
||||
try {
|
||||
// One-time version log for future diagnostics when Chrome changes v20 format.
|
||||
if (!loggedVersion) {
|
||||
try {
|
||||
const versionResp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);
|
||||
if (versionResp.ok) {
|
||||
const v = await versionResp.json() as { Browser?: string };
|
||||
console.log(`[cookie-import] CDP fallback: ${browser.name} ${v.Browser || 'unknown version'}`);
|
||||
loggedVersion = true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const resp = await fetch(`http://127.0.0.1:${debugPort}/json/list`);
|
||||
if (resp.ok) {
|
||||
const targets = await resp.json() as Array<{ type: string; webSocketDebuggerUrl?: string }>;
|
||||
const page = targets.find(t => t.type === 'page');
|
||||
if (page?.webSocketDebuggerUrl) {
|
||||
wsUrl = page.webSocketDebuggerUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not ready yet
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
|
||||
if (!wsUrl) {
|
||||
chromeProc.kill();
|
||||
throw new CookieImportError(
|
||||
`${browser.name} headless did not start within 15s`,
|
||||
'cdp_timeout',
|
||||
'retry',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect via CDP WebSocket
|
||||
const cookies = await extractCookiesViaCdp(wsUrl, domains);
|
||||
|
||||
const domainCounts: Record<string, number> = {};
|
||||
for (const c of cookies) {
|
||||
domainCounts[c.domain] = (domainCounts[c.domain] || 0) + 1;
|
||||
}
|
||||
|
||||
return { cookies, count: cookies.length, failed: 0, domainCounts };
|
||||
} finally {
|
||||
chromeProc.kill();
|
||||
}
|
||||
}
|
||||
|
||||
async function extractCookiesViaCdp(wsUrl: string, domains: string[]): Promise<PlaywrightCookie[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let msgId = 1;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new CookieImportError('CDP cookie extraction timed out', 'cdp_timeout'));
|
||||
}, 10_000);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Enable Network domain first, then request all cookies
|
||||
ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(String(event.data));
|
||||
|
||||
// After Network.enable succeeds, request all cookies
|
||||
if (data.id === 1 && !data.error) {
|
||||
ws.send(JSON.stringify({ id: msgId, method: 'Network.getAllCookies' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.id === msgId && data.result?.cookies) {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
|
||||
// Normalize domain matching: domains like ".example.com" match "example.com" and vice versa
|
||||
const domainSet = new Set<string>();
|
||||
for (const d of domains) {
|
||||
domainSet.add(d);
|
||||
domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d);
|
||||
}
|
||||
|
||||
const matched: PlaywrightCookie[] = [];
|
||||
for (const c of data.result.cookies as CdpCookie[]) {
|
||||
if (!domainSet.has(c.domain)) continue;
|
||||
matched.push({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path || '/',
|
||||
expires: c.expires === -1 ? -1 : c.expires,
|
||||
secure: c.secure,
|
||||
httpOnly: c.httpOnly,
|
||||
sameSite: cdpSameSite(c.sameSite),
|
||||
});
|
||||
}
|
||||
resolve(matched);
|
||||
} else if (data.id === msgId && data.error) {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
reject(new CookieImportError(
|
||||
`CDP error: ${data.error.message}`,
|
||||
'cdp_error',
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new CookieImportError(
|
||||
`CDP WebSocket error: ${(err as any).message || 'unknown'}`,
|
||||
'cdp_error',
|
||||
));
|
||||
};
|
||||
if (!dataDir) throw new CookieImportError('This browser is not supported on Windows', 'not_supported');
|
||||
const { importNativeCookies } = await import('./cookie-import-native');
|
||||
const cookies = await importNativeCookies({
|
||||
browserName: browser.name,
|
||||
userDataDir: path.join(getBaseDir('win32'), dataDir),
|
||||
profile,
|
||||
domains: [...new Set(domains.flatMap(domain => {
|
||||
const normalized = normalizeCookieDomain(domain);
|
||||
return [normalized, '.' + normalized];
|
||||
}))],
|
||||
});
|
||||
}
|
||||
|
||||
interface CdpCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
size: number;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
session: boolean;
|
||||
sameSite: string;
|
||||
}
|
||||
|
||||
function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' {
|
||||
switch (value) {
|
||||
case 'Strict': return 'Strict';
|
||||
case 'Lax': return 'Lax';
|
||||
case 'None': return 'None';
|
||||
default: return 'Lax';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies.
|
||||
* Quick check — reads a small sample, no decryption attempted.
|
||||
*/
|
||||
export function hasV20Cookies(browserName: string, profile = 'Default'): boolean {
|
||||
if (process.platform !== 'win32') return false;
|
||||
try {
|
||||
const browser = resolveBrowser(browserName);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
try {
|
||||
const rows = db.query('SELECT encrypted_value FROM cookies LIMIT 10').all() as Array<{ encrypted_value: Buffer | Uint8Array }>;
|
||||
return rows.some(row => {
|
||||
const ev = Buffer.from(row.encrypted_value);
|
||||
return ev.length >= 3 && ev.slice(0, 3).toString('utf-8') === 'v20';
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const domainCounts: Record<string, number> = Object.create(null);
|
||||
for (const cookie of cookies) domainCounts[cookie.domain] = (domainCounts[cookie.domain] || 0) + 1;
|
||||
return { cookies, count: cookies.length, failed: 0, domainCounts };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const NATIVE_QUALIFICATION_DATA = 'browse/src/cookie-import-native-qualification.json';
|
||||
export const NATIVE_BROWSER_VERSION_COMMAND = '$ErrorActionPreference = "Stop"; [Diagnostics.FileVersionInfo]::GetVersionInfo($env:GSTACK_QUALIFY_BROWSER_EXE).ProductVersion';
|
||||
|
||||
export const NATIVE_CODE_INPUTS = Object.freeze([
|
||||
'browse/src/cookie-import-browser.ts',
|
||||
'browse/src/cookie-database.ts',
|
||||
'browse/src/cookie-import-native.ts',
|
||||
'browse/src/cookie-import-native-integrity.ts',
|
||||
'browse/src/cookie-import-native-job.ts',
|
||||
'browse/src/cookie-import-native-worker.ts',
|
||||
'browse/src/bun-polyfill.cjs',
|
||||
'browse/scripts/build-node-server.sh',
|
||||
'.github/scripts/run-cookie-native-qualification.ps1',
|
||||
'browse/dist/server-node.mjs',
|
||||
'browse/dist/bun-polyfill.cjs',
|
||||
'browse/test/cookie-import-native.test.ts',
|
||||
'browse/test/cookie-import-native-job.test.ts',
|
||||
'browse/test/cookie-import-native-qualification.ts',
|
||||
'browse/test/fixtures/native-cookie-process.cjs',
|
||||
'browse/test/fixtures/native-cookie-launch.cjs',
|
||||
'browse/test/fixtures/native-cookie-process-observer.ts',
|
||||
'browse/test/fixtures/native-cookie-file-owners.ts',
|
||||
'browse/test/fixtures/native-cookie-remove-fixture.cjs',
|
||||
'node_modules/playwright/package.json',
|
||||
'node_modules/playwright/index.js',
|
||||
'node_modules/playwright-core/package.json',
|
||||
'node_modules/playwright-core/index.js',
|
||||
'node_modules/playwright-core/lib/bootstrap.js',
|
||||
'node_modules/playwright-core/lib/coreBundle.js',
|
||||
'node_modules/playwright-core/lib/utilsBundle.js',
|
||||
]);
|
||||
|
||||
export interface NativeQualifiedBuild {
|
||||
browserName: 'Chrome' | 'Chromium' | 'Brave' | 'Edge';
|
||||
architecture: 'x64' | 'arm64';
|
||||
windowsRelease: string;
|
||||
executableSha256: string;
|
||||
nodeVersion: string;
|
||||
bunVersion: string;
|
||||
playwrightVersion: string;
|
||||
sourceHashes: Record<string, string>;
|
||||
}
|
||||
|
||||
async function readBoundedFile(file: string, deadline: number, maximumBytes: number): Promise<Buffer> {
|
||||
if (!Number.isFinite(deadline) || Date.now() >= deadline) throw new Error('native_timeout');
|
||||
const cancellation = new AbortController();
|
||||
const timer = setTimeout(() => cancellation.abort(), Math.max(0, deadline - Date.now()));
|
||||
const stream = createReadStream(file, { signal: cancellation.signal });
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
size += chunk.length;
|
||||
if (size > maximumBytes) throw new Error('native_unqualified');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (Date.now() >= deadline) throw new Error('native_timeout');
|
||||
return Buffer.concat(chunks);
|
||||
} catch (error) {
|
||||
if (cancellation.signal.aborted) throw new Error('native_timeout');
|
||||
throw error;
|
||||
} finally {
|
||||
stream.destroy();
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readNativeQualifications(root: string, deadline: number): Promise<NativeQualifiedBuild[]> {
|
||||
const builds = JSON.parse((await readBoundedFile(path.join(root, NATIVE_QUALIFICATION_DATA), deadline, 1024 * 1024)).toString('utf8'));
|
||||
if (!Array.isArray(builds) || builds.some(build => !build || typeof build !== 'object')) throw new Error('native_unqualified');
|
||||
return builds;
|
||||
}
|
||||
|
||||
export async function hashNativeFile(file: string, deadline: number): Promise<string> {
|
||||
return createHash('sha256').update(await readBoundedFile(file, deadline, 64 * 1024 * 1024)).digest('hex');
|
||||
}
|
||||
|
||||
export async function nativeCodeHashes(root: string, deadline: number): Promise<Record<string, string>> {
|
||||
const hashes: Record<string, string> = {};
|
||||
for (const file of NATIVE_CODE_INPUTS) hashes[file] = await hashNativeFile(path.join(root, file), deadline);
|
||||
return hashes;
|
||||
}
|
||||
|
||||
export function nativeCodeMatches(expected: unknown, actual: Record<string, string>): boolean {
|
||||
if (!expected || typeof expected !== 'object' || Array.isArray(expected) || Object.keys(expected).length !== NATIVE_CODE_INPUTS.length) return false;
|
||||
return NATIVE_CODE_INPUTS.every(file => {
|
||||
const hash = (expected as Record<string, unknown>)[file];
|
||||
return typeof hash === 'string' && /^[0-9a-f]{64}$/.test(hash) && hash === actual[file];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const NATIVE_COOKIE_STAGES = [
|
||||
'platform', 'ffi_import', 'ffi_open', 'job_create', 'job_limits', 'job_open',
|
||||
'process_open', 'job_assign', 'job_assigned', 'job_joined', 'process_close', 'job_query', 'job_terminate', 'job_close',
|
||||
'worker_boot', 'supervisor_input', 'runtime_check', 'member_start', 'member_input', 'member_decoded', 'member_exit',
|
||||
'node_start', 'node_spawned', 'node_exit', 'node_input', 'node_load', 'browser_launch', 'cookie_read', 'browser_close',
|
||||
] as const;
|
||||
|
||||
export interface NativeCookieDiagnostic {
|
||||
stage: typeof NATIVE_COOKIE_STAGES[number];
|
||||
win32Error?: number;
|
||||
lastStage?: typeof NATIVE_COOKIE_STAGES[number];
|
||||
exitCode?: number;
|
||||
nodeExitCode?: number;
|
||||
signal?: string;
|
||||
memberMode?: boolean;
|
||||
stderrBytes?: number;
|
||||
}
|
||||
|
||||
export class NativeCookieJobError extends Error {
|
||||
readonly diagnostic: NativeCookieDiagnostic;
|
||||
|
||||
constructor(stage: NativeCookieDiagnostic['stage'], win32Error?: number) {
|
||||
super(stage === 'platform' ? 'native_supervision_unavailable' : 'native_supervision_failed');
|
||||
this.name = 'NativeCookieJobError';
|
||||
this.diagnostic = { stage, ...(Number.isInteger(win32Error) && win32Error! >= 0 && win32Error! <= 0xffffffff ? { win32Error } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
export function nativeCookieDiagnostic(error: unknown, fallback: NativeCookieDiagnostic['stage']): NativeCookieDiagnostic {
|
||||
return error instanceof NativeCookieJobError ? error.diagnostic : { stage: fallback };
|
||||
}
|
||||
|
||||
export function parseNativeCookieDiagnostic(value: unknown): NativeCookieDiagnostic | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const candidate = value as NativeCookieDiagnostic;
|
||||
if (!NATIVE_COOKIE_STAGES.includes(candidate.stage)) return undefined;
|
||||
const diagnostic = new NativeCookieJobError(candidate.stage, candidate.win32Error).diagnostic;
|
||||
if (NATIVE_COOKIE_STAGES.includes(candidate.lastStage!)) diagnostic.lastStage = candidate.lastStage;
|
||||
for (const field of ['exitCode', 'nodeExitCode', 'stderrBytes'] as const) {
|
||||
const value = candidate[field];
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value >= (field === 'stderrBytes' ? 0 : -0x80000000) && value <= 0xffffffff) diagnostic[field] = value;
|
||||
}
|
||||
if (['SIGTERM', 'SIGKILL', 'SIGINT', 'SIGSEGV', 'SIGABRT', 'SIGBREAK', 'SIGHUP'].includes(candidate.signal!)) diagnostic.signal = candidate.signal;
|
||||
if (typeof candidate.memberMode === 'boolean') diagnostic.memberMode = candidate.memberMode;
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
export interface NativeCookieJob {
|
||||
name: string;
|
||||
terminate(): void;
|
||||
activeProcesses(): number;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export async function createNativeCookieJob(): Promise<NativeCookieJob> {
|
||||
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) {
|
||||
throw new NativeCookieJobError('platform');
|
||||
}
|
||||
const { api, ptr, closeLibrary } = await openKernel();
|
||||
const name = `Local\\gstack-cookie-${randomUUID()}`;
|
||||
const wideName = Buffer.from(`${name}\0`, 'utf16le');
|
||||
let stage: NativeCookieDiagnostic['stage'] = 'job_create';
|
||||
let handle: number | bigint = 0;
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
if (handle && !api.CloseHandle(handle)) throw new NativeCookieJobError('job_close', api.GetLastError());
|
||||
} finally {
|
||||
closeLibrary();
|
||||
}
|
||||
};
|
||||
try {
|
||||
handle = api.CreateJobObjectW(null, ptr(wideName));
|
||||
const createError = api.GetLastError();
|
||||
if (!handle || createError === 183) throw new NativeCookieJobError('job_create', createError);
|
||||
const limits = Buffer.alloc(144);
|
||||
limits.writeUInt32LE(0x2000, 16);
|
||||
stage = 'job_limits';
|
||||
if (!api.SetInformationJobObject(handle, 9, ptr(limits), limits.byteLength)) {
|
||||
throw new NativeCookieJobError(stage, api.GetLastError());
|
||||
}
|
||||
return {
|
||||
name,
|
||||
terminate() {
|
||||
if (closed) throw new NativeCookieJobError('job_terminate');
|
||||
if (!api.TerminateJobObject(handle, 1)) throw new NativeCookieJobError('job_terminate', api.GetLastError());
|
||||
},
|
||||
activeProcesses() {
|
||||
const accounting = Buffer.alloc(48);
|
||||
if (closed) throw new NativeCookieJobError('job_query');
|
||||
if (!api.QueryInformationJobObject(handle, 1, ptr(accounting), accounting.byteLength, null)) throw new NativeCookieJobError('job_query', api.GetLastError());
|
||||
return accounting.readUInt32LE(40);
|
||||
},
|
||||
close,
|
||||
};
|
||||
} catch (error) {
|
||||
try { close(); } catch {}
|
||||
throw error instanceof NativeCookieJobError ? error : new NativeCookieJobError(stage);
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinNativeCookieJob(name: string, observe?: (stage: NativeCookieDiagnostic['stage']) => void): Promise<void> {
|
||||
if (process.platform !== 'win32' || !/^Local\\gstack-cookie-[0-9a-f-]{36}$/.test(name)) {
|
||||
throw new NativeCookieJobError('job_open');
|
||||
}
|
||||
observe?.('ffi_import');
|
||||
const { api, ptr, closeLibrary } = await openKernel();
|
||||
const wideName = Buffer.from(`${name}\0`, 'utf16le');
|
||||
let stage: NativeCookieDiagnostic['stage'] = 'job_open';
|
||||
let handle: number | bigint = 0;
|
||||
let currentProcess: number | bigint = 0;
|
||||
try {
|
||||
observe?.(stage);
|
||||
handle = api.OpenJobObjectW(1, 0, ptr(wideName));
|
||||
if (!handle) throw new NativeCookieJobError(stage, api.GetLastError());
|
||||
stage = 'process_open';
|
||||
observe?.(stage);
|
||||
currentProcess = api.OpenProcess(0x0101, 0, process.pid);
|
||||
if (!currentProcess) throw new NativeCookieJobError(stage, api.GetLastError());
|
||||
stage = 'job_assign';
|
||||
observe?.(stage);
|
||||
if (!api.AssignProcessToJobObject(handle, currentProcess)) throw new NativeCookieJobError(stage, api.GetLastError());
|
||||
observe?.('job_assigned');
|
||||
} catch (error) {
|
||||
throw error instanceof NativeCookieJobError ? error : new NativeCookieJobError(stage);
|
||||
} finally {
|
||||
let closeError: NativeCookieJobError | undefined;
|
||||
observe?.('process_close');
|
||||
if (currentProcess && !api.CloseHandle(currentProcess)) closeError = new NativeCookieJobError('process_close', api.GetLastError());
|
||||
observe?.('job_close');
|
||||
if (handle && !api.CloseHandle(handle)) closeError ??= new NativeCookieJobError('job_close', api.GetLastError());
|
||||
closeLibrary();
|
||||
if (closeError) throw closeError;
|
||||
}
|
||||
}
|
||||
|
||||
async function openKernel() {
|
||||
let stage: NativeCookieDiagnostic['stage'] = 'ffi_import';
|
||||
try {
|
||||
const { dlopen, FFIType, ptr } = await import('bun:ffi');
|
||||
stage = 'ffi_open';
|
||||
const library = dlopen('kernel32.dll', {
|
||||
CreateJobObjectW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.u64 },
|
||||
OpenJobObjectW: { args: [FFIType.u32, FFIType.i32, FFIType.ptr], returns: FFIType.u64 },
|
||||
SetInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
||||
QueryInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
AssignProcessToJobObject: { args: [FFIType.u64, FFIType.u64], returns: FFIType.i32 },
|
||||
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
|
||||
TerminateJobObject: { args: [FFIType.u64, FFIType.u32], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
GetLastError: { args: [], returns: FFIType.u32 },
|
||||
});
|
||||
return { api: library.symbols, ptr, closeLibrary: () => library.close() };
|
||||
} catch {
|
||||
throw new NativeCookieJobError(stage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,267 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { writeSync } from 'node:fs';
|
||||
import { createNativeCookieJob, joinNativeCookieJob, nativeCookieDiagnostic, parseNativeCookieDiagnostic, type NativeCookieDiagnostic, type NativeCookieJob } from './cookie-import-native-job';
|
||||
import type { PlaywrightCookie } from './cookie-import-browser';
|
||||
|
||||
export interface NativeCookieRequest {
|
||||
nodeExecutable: string;
|
||||
nodeArchitecture: string;
|
||||
playwrightEntry: string;
|
||||
executablePath: string;
|
||||
userDataDir: string;
|
||||
profile: string;
|
||||
domains: string[];
|
||||
deadline: number;
|
||||
qualifiedBunVersions: string[];
|
||||
}
|
||||
|
||||
export type NativeCookieReply =
|
||||
| { cookies: PlaywrightCookie[] }
|
||||
| { error: 'native_timeout' | 'native_failed' | 'native_cleanup_failed' | 'native_supervision_failed' | 'browser_running' | 'native_profile_unsupported'; diagnostic?: NativeCookieDiagnostic };
|
||||
|
||||
export interface NativeCookieMember {
|
||||
result: Promise<NativeCookieReply>;
|
||||
closed: Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
const MAX_REPLY_BYTES = 8 * 1024 * 1024;
|
||||
export const NATIVE_PROGRESS_PREFIX = 'GSTACK_NATIVE_PROGRESS ';
|
||||
|
||||
function nativeProgress(diagnostic: NativeCookieDiagnostic): void {
|
||||
try { writeSync(2, NATIVE_PROGRESS_PREFIX + JSON.stringify(diagnostic) + '\n'); } catch {}
|
||||
}
|
||||
|
||||
export function nativeCookieEnvironment(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const allowed = new Set(['systemroot', 'windir', 'temp', 'tmp', 'userprofile', 'localappdata', 'appdata', 'programfiles', 'programfiles(x86)', 'programdata', 'path', 'pathext']);
|
||||
return Object.fromEntries(Object.entries(env).filter(([key, value]) => allowed.has(key.toLowerCase()) && typeof value === 'string')) as Record<string, string>;
|
||||
}
|
||||
|
||||
export const NATIVE_COOKIE_NODE_SCRIPT = String.raw`
|
||||
const fs = require('node:fs');
|
||||
let stage = 'node_input';
|
||||
const progress = () => { try { fs.writeSync(2, 'GSTACK_NATIVE_PROGRESS ' + JSON.stringify({ stage }) + '\n'); } catch {} };
|
||||
progress();
|
||||
(async () => {
|
||||
const request = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
stage = 'node_load';
|
||||
progress();
|
||||
const { chromium } = require(request.playwrightEntry);
|
||||
let context;
|
||||
try {
|
||||
const remaining = request.deadline - Date.now();
|
||||
if (remaining <= 0) throw new Error('native_timeout');
|
||||
stage = 'browser_launch';
|
||||
progress();
|
||||
context = await chromium.launchPersistentContext(request.userDataDir, {
|
||||
executablePath: request.executablePath,
|
||||
args: ['--profile-directory=' + request.profile],
|
||||
headless: true,
|
||||
chromiumSandbox: true,
|
||||
timeout: remaining,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
handleSIGHUP: false,
|
||||
env: process.env,
|
||||
});
|
||||
stage = 'cookie_read';
|
||||
progress();
|
||||
const selected = new Set(request.domains.map(domain => domain.toLowerCase().replace(/^\./, '').replace(/\.$/, '')));
|
||||
const cookies = (await context.cookies()).filter(cookie => selected.has(cookie.domain.toLowerCase().replace(/^\./, '').replace(/\.$/, '')));
|
||||
await new Promise(resolve => process.stdout.write(JSON.stringify({ cookies }) + '\n', resolve));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
const exited = message.match(/<process did exit: exitCode=(-?\d+), signal=(?:null|SIG[A-Z]+)>/);
|
||||
const exitCode = exited ? Number(exited[1]) : undefined;
|
||||
const code = (stage === 'browser_launch' && exitCode === 21) || /ProcessSingleton|profile.*in use|user data directory is already in use|opening in existing browser session/i.test(message)
|
||||
? 'browser_running'
|
||||
: /remote debugging requires a non-default data directory/i.test(message)
|
||||
? 'native_profile_unsupported'
|
||||
: /Timeout|native_timeout/.test(message) ? 'native_timeout' : 'native_failed';
|
||||
await new Promise(resolve => process.stdout.write(JSON.stringify({ error: code, diagnostic: { stage, ...(Number.isInteger(exitCode) ? { exitCode } : {}) } }) + '\n', resolve));
|
||||
} finally {
|
||||
stage = 'browser_close';
|
||||
progress();
|
||||
await context?.close().catch(() => {});
|
||||
}
|
||||
})().catch(() => { process.stdout.write(JSON.stringify({ error: 'native_failed', diagnostic: { stage } }) + '\n'); process.exitCode = 1; });
|
||||
`;
|
||||
|
||||
export async function superviseNativeCookieImport(
|
||||
request: NativeCookieRequest,
|
||||
dependencies: {
|
||||
createJob?: () => Promise<NativeCookieJob>;
|
||||
startMember?: (request: NativeCookieRequest, jobName: string) => NativeCookieMember;
|
||||
now?: () => number;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<NativeCookieReply> {
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const sleep = dependencies.sleep ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)));
|
||||
const deadline = Math.min(request.deadline, now() + 25_000);
|
||||
let job: NativeCookieJob | undefined;
|
||||
let member: NativeCookieMember | undefined;
|
||||
let reply: NativeCookieReply | undefined;
|
||||
let closed = false;
|
||||
try {
|
||||
job = await (dependencies.createJob ?? createNativeCookieJob)();
|
||||
if (now() >= deadline || dependencies.signal?.aborted) return { error: 'native_timeout' };
|
||||
member = (dependencies.startMember ?? startMember)({ ...request, deadline }, job.name);
|
||||
void member.result.then(value => { reply ??= value; }, () => { reply ??= { error: 'native_failed' }; });
|
||||
void member.closed.then(() => { closed = true; }, () => { closed = true; });
|
||||
while (!reply && !closed && now() < deadline && !dependencies.signal?.aborted) await sleep(Math.min(20, deadline - now()));
|
||||
reply ??= { error: now() >= deadline ? 'native_timeout' : 'native_failed' };
|
||||
const cleanupDeadline = now() + 5_000;
|
||||
const graceDeadline = now() + ('cookies' in reply ? 2_000 : 0);
|
||||
while ((!closed || job.activeProcesses() !== 0) && now() < graceDeadline) await sleep(20);
|
||||
if (job.activeProcesses() !== 0) job.terminate();
|
||||
while ((!closed || job.activeProcesses() !== 0) && now() < cleanupDeadline) await sleep(20);
|
||||
if (!closed || job.activeProcesses() !== 0) return { error: 'native_cleanup_failed' };
|
||||
return reply;
|
||||
} catch (error) {
|
||||
return { error: job ? 'native_cleanup_failed' : 'native_supervision_failed', diagnostic: nativeCookieDiagnostic(error, job ? 'job_query' : 'job_create') };
|
||||
} finally {
|
||||
let diagnostic: NativeCookieDiagnostic | undefined;
|
||||
try { job?.close(); } catch (error) { diagnostic = nativeCookieDiagnostic(error, 'job_close'); }
|
||||
try { member?.stop(); } catch (error) { diagnostic ??= nativeCookieDiagnostic(error, 'member_exit'); }
|
||||
if (diagnostic) return { error: 'native_cleanup_failed', diagnostic };
|
||||
}
|
||||
}
|
||||
|
||||
function startMember(request: NativeCookieRequest, jobName: string, mode = '--member'): NativeCookieMember {
|
||||
const child = spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', import.meta.path, mode], {
|
||||
env: nativeCookieEnvironment(process.env),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const closed = new Promise<void>(resolve => child.once('close', () => resolve()));
|
||||
let progressInput = '';
|
||||
let lastStage: NativeCookieDiagnostic['stage'] | undefined;
|
||||
let memberMode: boolean | undefined;
|
||||
let nodeExitCode: number | undefined;
|
||||
let stderrBytes = 0;
|
||||
child.stderr.on('data', chunk => {
|
||||
stderrBytes = Math.min(0xffffffff, stderrBytes + chunk.length);
|
||||
progressInput += chunk.toString('utf8');
|
||||
for (let end = progressInput.indexOf('\n'); end >= 0; end = progressInput.indexOf('\n')) {
|
||||
const line = progressInput.slice(0, end);
|
||||
progressInput = progressInput.slice(end + 1);
|
||||
if (!line.startsWith(NATIVE_PROGRESS_PREFIX)) continue;
|
||||
try {
|
||||
const diagnostic = parseNativeCookieDiagnostic(JSON.parse(line.slice(NATIVE_PROGRESS_PREFIX.length)));
|
||||
if (!diagnostic) continue;
|
||||
lastStage = diagnostic.stage;
|
||||
memberMode ??= diagnostic.memberMode;
|
||||
if (diagnostic.stage === 'node_exit') nodeExitCode = diagnostic.exitCode;
|
||||
} catch {}
|
||||
}
|
||||
if (progressInput.length > 4096) progressInput = '';
|
||||
});
|
||||
const result = new Promise<NativeCookieReply>(resolve => {
|
||||
let output = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stdout.on('data', chunk => {
|
||||
output += chunk;
|
||||
if (Buffer.byteLength(output) > MAX_REPLY_BYTES) {
|
||||
resolve({ error: 'native_failed' });
|
||||
child.stdout.destroy();
|
||||
} else if (output.includes('\n')) {
|
||||
try {
|
||||
const parsed = JSON.parse(output.slice(0, output.indexOf('\n')));
|
||||
const errors = ['native_timeout', 'native_failed', 'native_cleanup_failed', 'native_supervision_failed', 'browser_running', 'native_profile_unsupported'];
|
||||
const diagnostic = parseNativeCookieDiagnostic(parsed.diagnostic);
|
||||
resolve(Array.isArray(parsed.cookies) ? { cookies: parsed.cookies } : { error: errors.includes(parsed.error) ? parsed.error : 'native_failed', ...(diagnostic ? { diagnostic } : {}) });
|
||||
} catch {
|
||||
resolve({ error: 'native_failed' });
|
||||
}
|
||||
}
|
||||
});
|
||||
child.once('error', () => resolve({ error: 'native_failed', diagnostic: { stage: 'member_start' } }));
|
||||
child.once('close', (code, signal) => resolve({ error: 'native_failed', diagnostic: parseNativeCookieDiagnostic({ stage: 'member_exit', exitCode: code, signal, lastStage, memberMode, nodeExitCode, stderrBytes }) }));
|
||||
child.stdin.on('error', () => resolve({ error: 'native_failed', diagnostic: { stage: 'member_input' } }));
|
||||
child.stdin.end(JSON.stringify({ request, jobName }));
|
||||
});
|
||||
return { result, closed, stop: () => { if (child.exitCode === null && child.signalCode === null) child.kill(); } };
|
||||
}
|
||||
|
||||
export async function probeNativeCookieMember(): Promise<NativeCookieReply> {
|
||||
return superviseNativeCookieImport({
|
||||
nodeExecutable: '', nodeArchitecture: process.arch, playwrightEntry: '', executablePath: '',
|
||||
userDataDir: '', profile: '', domains: [], deadline: Date.now() + 5_000, qualifiedBunVersions: [Bun.version],
|
||||
}, { startMember: (request, jobName) => startMember(request, jobName, '--member-smoke') });
|
||||
}
|
||||
|
||||
let mainStage: NativeCookieDiagnostic['stage'] = 'supervisor_input';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.argv[2] === '--member' || process.argv[2] === '--member-smoke') {
|
||||
mainStage = 'member_input';
|
||||
nativeProgress({ stage: mainStage });
|
||||
const serialized = await new Promise<string>((resolve, reject) => {
|
||||
let payload = '';
|
||||
let bytes = 0;
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
bytes += Buffer.byteLength(chunk);
|
||||
if (bytes > 1024 * 1024) {
|
||||
reject(new Error('native_supervision_failed'));
|
||||
process.stdin.destroy();
|
||||
return;
|
||||
}
|
||||
payload += chunk;
|
||||
});
|
||||
process.stdin.once('end', () => resolve(payload));
|
||||
process.stdin.once('error', () => reject(new Error('native_supervision_failed')));
|
||||
process.stdin.once('close', () => reject(new Error('native_supervision_failed')));
|
||||
process.stdin.resume();
|
||||
});
|
||||
const input = JSON.parse(serialized);
|
||||
nativeProgress({ stage: 'member_decoded' });
|
||||
await joinNativeCookieJob(input.jobName, stage => nativeProgress({ stage }));
|
||||
nativeProgress({ stage: 'job_joined' });
|
||||
if (process.argv[2] === '--member-smoke') {
|
||||
process.stdout.write(JSON.stringify({ cookies: [] }) + '\n', () => process.exit(0));
|
||||
return;
|
||||
}
|
||||
mainStage = 'node_start';
|
||||
nativeProgress({ stage: mainStage });
|
||||
const child = spawn(input.request.nodeExecutable, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
|
||||
env: nativeCookieEnvironment(process.env),
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
windowsHide: true,
|
||||
});
|
||||
nativeProgress({ stage: 'node_spawned' });
|
||||
child.stdin.on('error', () => {});
|
||||
child.stdin.end(JSON.stringify(input.request));
|
||||
child.once('error', () => process.stdout.write(JSON.stringify({ error: 'native_failed', diagnostic: { stage: 'node_start' } }) + '\n', () => process.exit(1)));
|
||||
child.once('close', (code, signal) => {
|
||||
nativeProgress(parseNativeCookieDiagnostic({ stage: 'node_exit', exitCode: code, signal })!);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cancellation = new AbortController();
|
||||
const lines = createInterface({ input: process.stdin });
|
||||
lines.once('close', () => cancellation.abort());
|
||||
const input = await new Promise<NativeCookieRequest>((resolve, reject) => {
|
||||
lines.once('line', line => {
|
||||
try { resolve(JSON.parse(line)); } catch { reject(new Error('native_supervision_failed')); }
|
||||
});
|
||||
lines.once('close', () => reject(new Error('native_supervision_failed')));
|
||||
});
|
||||
mainStage = 'runtime_check';
|
||||
if (input.nodeArchitecture !== process.arch || !Array.isArray(input.qualifiedBunVersions) || !input.qualifiedBunVersions.includes(Bun.version)) {
|
||||
throw new Error('native_supervision_failed');
|
||||
}
|
||||
const result = await superviseNativeCookieImport(input, { signal: cancellation.signal });
|
||||
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(0));
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
nativeProgress({ stage: 'worker_boot', memberMode: process.argv[2] === '--member' || process.argv[2] === '--member-smoke' });
|
||||
void main().catch(error => {
|
||||
process.stdout.write(JSON.stringify({ error: 'native_supervision_failed', diagnostic: nativeCookieDiagnostic(error, mainStage) }) + '\n', () => process.exit(1));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { realpathSync, statSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { release } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { CookieImportError, normalizeCookieDomain, type PlaywrightCookie } from './cookie-import-browser';
|
||||
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, readNativeQualifications, type NativeQualifiedBuild } from './cookie-import-native-integrity';
|
||||
|
||||
type BrowserName = 'Chrome' | 'Chromium' | 'Brave' | 'Edge';
|
||||
|
||||
export function nativeBrowserPaths(browserName: string, env: NodeJS.ProcessEnv): {
|
||||
name: BrowserName;
|
||||
userDataDir: string;
|
||||
executables: string[];
|
||||
} {
|
||||
const get = (key: string) => Object.entries(env).find(([name]) => name.toLowerCase() === key.toLowerCase())?.[1];
|
||||
const local = get('LOCALAPPDATA');
|
||||
if (!local || !path.win32.isAbsolute(local)) {
|
||||
throw new CookieImportError('The Windows browser data location is unavailable. Sign in manually in the gstack browser.', 'native_profile_unsupported');
|
||||
}
|
||||
const pf = get('PROGRAMFILES') || 'C:\\Program Files';
|
||||
const pf86 = get('PROGRAMFILES(X86)') || 'C:\\Program Files (x86)';
|
||||
const mappings = {
|
||||
chrome: { name: 'Chrome', root: ['Google', 'Chrome'], exe: 'chrome.exe', installs: [pf, pf86, local] },
|
||||
chromium: { name: 'Chromium', root: ['Chromium'], exe: 'chrome.exe', installs: [local] },
|
||||
brave: { name: 'Brave', root: ['BraveSoftware', 'Brave-Browser'], exe: 'brave.exe', installs: [pf, pf86, local] },
|
||||
edge: { name: 'Edge', root: ['Microsoft', 'Edge'], exe: 'msedge.exe', installs: [pf86, pf, local] },
|
||||
} as const;
|
||||
const key = browserName.toLowerCase();
|
||||
if (!Object.hasOwn(mappings, key)) {
|
||||
throw new CookieImportError('This browser has no supported Windows native-cookie mapping. Sign in manually in the gstack browser.', 'not_supported');
|
||||
}
|
||||
const browser = mappings[key as keyof typeof mappings];
|
||||
return {
|
||||
name: browser.name,
|
||||
userDataDir: path.win32.join(local, ...browser.root, 'User Data'),
|
||||
executables: [...new Set(browser.installs.map(root => path.win32.join(root, ...browser.root, 'Application', browser.exe)))],
|
||||
};
|
||||
}
|
||||
|
||||
export async function importNativeCookies(options: {
|
||||
browserName: string;
|
||||
userDataDir: string;
|
||||
profile: string;
|
||||
domains: string[];
|
||||
}): Promise<PlaywrightCookie[]> {
|
||||
let domains: string[];
|
||||
try {
|
||||
if (!Array.isArray(options.domains)) throw new Error();
|
||||
domains = [...new Set(options.domains.map(normalizeCookieDomain))];
|
||||
} catch {
|
||||
throw new CookieImportError('Native cookie import needs explicit valid domain selections.', 'invalid_domain');
|
||||
}
|
||||
if (!domains.length) return [];
|
||||
if (process.platform !== 'win32' || process.versions.bun) {
|
||||
throw new CookieImportError('Native cookie import requires the Windows Node server. Sign in manually in the gstack browser.', 'not_supported');
|
||||
}
|
||||
if (!/^(Default|Profile [0-9]+)$/.test(options.profile)) {
|
||||
throw new CookieImportError('Select an existing browser profile before importing cookies.', 'invalid_profile');
|
||||
}
|
||||
const browser = nativeBrowserPaths(options.browserName, process.env);
|
||||
if (path.win32.resolve(options.userDataDir).toLowerCase() !== path.win32.resolve(browser.userDataDir).toLowerCase()) {
|
||||
throw new CookieImportError('Native cookie import cannot substitute or copy the selected browser profile. Sign in manually in the gstack browser.', 'native_profile_unsupported');
|
||||
}
|
||||
if (browser.name === 'Chrome') {
|
||||
throw new CookieImportError('Chrome 136 and later block remote debugging of the default user-data directory, including every profile inside it, over both pipe and TCP. Default-directory extraction is unavailable; sign in manually in the gstack browser.', 'native_profile_unsupported');
|
||||
}
|
||||
const deadline = Date.now() + 25_000;
|
||||
let qualified: NativeQualifiedBuild[];
|
||||
try {
|
||||
const root = path.resolve(import.meta.dir, '../..');
|
||||
const builds = await readNativeQualifications(root, deadline);
|
||||
qualified = builds.filter(build => build.browserName === browser.name && build.nodeVersion === process.version && build.architecture === process.arch && build.windowsRelease === release());
|
||||
if (qualified.length) {
|
||||
const hashes = await nativeCodeHashes(root, deadline);
|
||||
qualified = qualified.filter(build => nativeCodeMatches(build.sourceHashes, hashes));
|
||||
}
|
||||
} catch (error) {
|
||||
throw new CookieImportError('The native-cookie qualification inputs could not be verified. Sign in manually in the gstack browser.', error instanceof Error && error.message === 'native_timeout' ? 'native_timeout' : 'native_unqualified');
|
||||
}
|
||||
if (!qualified.length) {
|
||||
throw new CookieImportError('Windows native cookie extraction is disabled until this browser and runtime pass native process-ownership and forced-cleanup qualification. Sign in manually in the gstack browser.', 'native_unqualified');
|
||||
}
|
||||
const executablePath = browser.executables.find(candidate => {
|
||||
try { return statSync(candidate).isFile(); } catch { return false; }
|
||||
});
|
||||
if (!executablePath) throw new CookieImportError('The selected browser executable is not installed.', 'not_installed');
|
||||
try {
|
||||
const profilePath = path.join(options.userDataDir, options.profile);
|
||||
if (realpathSync(options.userDataDir).toLowerCase() !== path.win32.resolve(options.userDataDir).toLowerCase()
|
||||
|| realpathSync(profilePath).toLowerCase() !== path.win32.resolve(profilePath).toLowerCase()
|
||||
|| !statSync(profilePath).isDirectory()) {
|
||||
throw new Error('invalid_profile');
|
||||
}
|
||||
} catch {
|
||||
throw new CookieImportError('The selected source profile is unavailable or redirects to another location.', 'native_profile_unsupported');
|
||||
}
|
||||
const require = createRequire(import.meta.url);
|
||||
let playwrightVersion: string;
|
||||
let playwrightEntry: string;
|
||||
let executableSha256: string;
|
||||
try {
|
||||
playwrightVersion = require('playwright/package.json').version;
|
||||
playwrightEntry = require.resolve('playwright');
|
||||
executableSha256 = await hashNativeFile(executablePath, deadline);
|
||||
} catch (error) {
|
||||
throw new CookieImportError('The native browser build could not be checked safely. Sign in manually in the gstack browser.', error instanceof Error && error.message === 'native_timeout' ? 'native_timeout' : 'native_unqualified');
|
||||
}
|
||||
const bunCandidates = [
|
||||
...(process.env.BUN_INSTALL ? [path.join(process.env.BUN_INSTALL, 'bin', 'bun.exe')] : []),
|
||||
...(process.env.PATH || process.env.Path || '').split(path.delimiter).filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, 'bun.exe')),
|
||||
...(process.env.USERPROFILE ? [path.join(process.env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []),
|
||||
];
|
||||
const bunExecutable = bunCandidates.find(candidate => {
|
||||
try { return statSync(candidate).isFile(); } catch { return false; }
|
||||
});
|
||||
if (!bunExecutable || !qualified.some(build => build.executableSha256 === executableSha256 && build.playwrightVersion === playwrightVersion)) {
|
||||
throw new CookieImportError('This browser build or supervisor runtime has not passed native qualification. Sign in manually in the gstack browser.', 'native_unqualified');
|
||||
}
|
||||
if (Date.now() >= deadline) throw new CookieImportError('Native cookie import exceeded its operation deadline.', 'native_timeout');
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => /^(systemroot|windir|temp|tmp|userprofile|localappdata|appdata|programfiles|programfiles\(x86\)|programdata|path|pathext)$/i.test(key) && typeof value === 'string'));
|
||||
const worker = spawn(bunExecutable, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.join(import.meta.dir, 'cookie-import-native-worker.ts')], {
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
const timer = setTimeout(() => {
|
||||
worker.kill();
|
||||
reject(new CookieImportError('Native cookie cleanup could not be confirmed within its deadline.', 'native_cleanup_failed'));
|
||||
}, Math.max(0, deadline + 5_000 - Date.now()));
|
||||
worker.stdout.setEncoding('utf8');
|
||||
worker.stdout.on('data', chunk => {
|
||||
output += chunk;
|
||||
if (Buffer.byteLength(output) > 8 * 1024 * 1024) worker.kill();
|
||||
});
|
||||
worker.once('error', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new CookieImportError('Native cookie supervision could not start.', 'native_supervision_failed'));
|
||||
});
|
||||
worker.once('close', () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const result = JSON.parse(output);
|
||||
if (Array.isArray(result.cookies)) resolve(result.cookies);
|
||||
else reject(new CookieImportError('Native cookie import did not complete. Sign in manually in the gstack browser.', ['native_timeout', 'native_failed', 'native_cleanup_failed', 'native_supervision_failed', 'browser_running', 'native_profile_unsupported'].includes(result.error) ? result.error : 'native_failed'));
|
||||
} catch {
|
||||
reject(new CookieImportError('Native cookie supervision did not return a complete result.', 'native_failed'));
|
||||
}
|
||||
});
|
||||
worker.stdin.on('error', () => {});
|
||||
worker.stdin.write(JSON.stringify({
|
||||
nodeExecutable: process.execPath,
|
||||
nodeArchitecture: process.arch,
|
||||
playwrightEntry,
|
||||
executablePath,
|
||||
userDataDir: options.userDataDir,
|
||||
profile: options.profile,
|
||||
domains,
|
||||
deadline,
|
||||
qualifiedBunVersions: qualified.filter(build => build.executableSha256 === executableSha256 && build.playwrightVersion === playwrightVersion).map(build => build.bunVersion),
|
||||
}) + '\n');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { Page } from 'playwright';
|
||||
import {
|
||||
CookieImportError, cookieDomainMatches, importCookies, importCookiesViaCdp,
|
||||
listDomains, listProfiles, normalizeCookieDomain, withCookieReadRetry,
|
||||
type ProfileEntry,
|
||||
} from './cookie-import-browser';
|
||||
import { clearCookieTargetStorage, validateCookieAuthOptions, validateCookieStorageSupport, verifyCookieAuthentication, type CookieAuthVerificationOptions } from './cookie-auth-verification';
|
||||
|
||||
export interface CookieImportTarget {
|
||||
page: Page;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CookieImportOptions {
|
||||
browser: string;
|
||||
domains?: string[];
|
||||
profile?: string;
|
||||
all?: boolean;
|
||||
clearStorage?: boolean;
|
||||
verifyAuth?: boolean;
|
||||
}
|
||||
|
||||
const activeImports = new WeakSet<object>();
|
||||
|
||||
export function parseCookieImportArgs(args: string[]): CookieImportOptions {
|
||||
const options: CookieImportOptions = { browser: 'comet' };
|
||||
let browserSet = false;
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (!arg.startsWith('--')) {
|
||||
if (browserSet) throw new CookieImportError('Specify only one source browser.', 'bad_request');
|
||||
options.browser = arg;
|
||||
browserSet = true;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(arg)) throw new CookieImportError('Duplicate cookie-import option.', 'bad_request');
|
||||
seen.add(arg);
|
||||
if (arg === '--domain' || arg === '--profile') {
|
||||
const value = args[++i];
|
||||
if (!value || value.startsWith('--')) throw new CookieImportError('Cookie-import option requires a value.', 'bad_request');
|
||||
if (arg === '--domain') options.domains = [normalizeCookieDomain(value)];
|
||||
else options.profile = value;
|
||||
} else if (arg === '--all') options.all = true;
|
||||
else if (arg === '--clear-storage') options.clearStorage = true;
|
||||
else if (arg === '--verify-auth') options.verifyAuth = true;
|
||||
else throw new CookieImportError('Unknown cookie-import option.', 'bad_request');
|
||||
}
|
||||
if (options.all && options.domains) throw new CookieImportError('Choose --domain or --all, not both.', 'bad_request');
|
||||
if (options.all && options.clearStorage) throw new CookieImportError('Storage reset requires a single target origin; --all is not supported.', 'bad_request');
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function getCookieProfiles(browser: string, domains: string[] = [], hostname?: string) {
|
||||
const profiles: Array<ProfileEntry & { matches?: boolean; unavailable?: boolean }> = listProfiles(browser);
|
||||
if (domains.length || hostname) {
|
||||
const selected = domains.map(normalizeCookieDomain);
|
||||
let index = 0;
|
||||
const check = async () => {
|
||||
while (index < profiles.length) {
|
||||
const profile = profiles[index++];
|
||||
try {
|
||||
const result = await withCookieReadRetry(() => listDomains(browser, profile.name));
|
||||
profile.matches = result.domains.some(entry => selected.length
|
||||
? selected.includes(normalizeCookieDomain(entry.domain))
|
||||
: cookieDomainMatches(hostname!, entry.domain));
|
||||
} catch (err) {
|
||||
if (err instanceof CookieImportError && err.code === 'sqlite_unavailable') throw err;
|
||||
profile.unavailable = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all([check(), check()]);
|
||||
}
|
||||
const matching = profiles.filter(profile => profile.matches === true);
|
||||
const recommendedProfile = !profiles.some(profile => profile.unavailable) && matching.length === 1
|
||||
? matching[0].name : profiles.length === 1 && profiles[0].matches !== false && !profiles[0].unavailable ? profiles[0].name : undefined;
|
||||
return { profiles, recommendedProfile };
|
||||
}
|
||||
|
||||
export function validateCookieTarget(target: CookieImportTarget): URL {
|
||||
try {
|
||||
const url = new URL(target.url);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
|
||||
if (target.page.isClosed() || target.page.url() !== target.url) throw new Error();
|
||||
return url;
|
||||
} catch {
|
||||
throw new CookieImportError('The captured HTTP(S) target has changed or is unavailable. Reopen the picker on the intended page.', 'target_changed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCookieImport(
|
||||
options: CookieImportOptions,
|
||||
target: CookieImportTarget,
|
||||
trackDomains: (domains: string[]) => void,
|
||||
authOptions: CookieAuthVerificationOptions = {},
|
||||
) {
|
||||
for (const value of [options.all, options.clearStorage, options.verifyAuth]) {
|
||||
if (value !== undefined && typeof value !== 'boolean') throw new CookieImportError('Cookie import options must be booleans.', 'bad_request');
|
||||
}
|
||||
if (typeof options.browser !== 'string' || !options.browser.trim()) throw new CookieImportError('Select a source browser.', 'bad_request');
|
||||
if (options.profile !== undefined && (typeof options.profile !== 'string' || !options.profile)) throw new CookieImportError('Invalid source profile.', 'bad_request');
|
||||
if (options.all && options.domains || options.all && options.clearStorage) throw new CookieImportError('All-domain import cannot be combined with a scoped domain or storage reset.', 'bad_request');
|
||||
if (!options.all && (!Array.isArray(options.domains) || !options.domains.length)) throw new CookieImportError('Select at least one cookie domain.', 'bad_request');
|
||||
const selected = options.domains?.map(normalizeCookieDomain) ?? [];
|
||||
const needsTarget = options.clearStorage || options.verifyAuth;
|
||||
const targetUrl = needsTarget ? validateCookieTarget(target) : undefined;
|
||||
if (options.clearStorage) validateCookieStorageSupport(target.page);
|
||||
if (options.verifyAuth) validateCookieAuthOptions(authOptions);
|
||||
if (targetUrl && selected.length && !selected.some(domain => cookieDomainMatches(targetUrl.hostname, '.' + domain))) {
|
||||
throw new CookieImportError('The selected cookies do not match the captured target origin.', 'target_mismatch');
|
||||
}
|
||||
const context = target.page.context();
|
||||
if (target.page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed');
|
||||
if (activeImports.has(context)) throw new CookieImportError('Another cookie import is still running. Wait before retrying.', 'import_busy', 'retry');
|
||||
activeImports.add(context);
|
||||
try {
|
||||
let profile = options.profile;
|
||||
if (!profile) {
|
||||
const suggestion = await getCookieProfiles(options.browser, selected);
|
||||
profile = suggestion.recommendedProfile;
|
||||
if (!profile) throw new CookieImportError('Choose a source profile explicitly; matching profiles are ambiguous, unavailable, or empty.', 'profile_required');
|
||||
}
|
||||
const domains = options.all
|
||||
? (await withCookieReadRetry(() => listDomains(options.browser, profile!))).domains.map(entry => entry.domain)
|
||||
: selected;
|
||||
let result = await withCookieReadRetry(() => importCookies(options.browser, domains, profile));
|
||||
if (result.count === 0 && result.failureReasons?.unsupported_encryption && process.platform === 'win32') {
|
||||
const failed = result.failed;
|
||||
result = await importCookiesViaCdp(options.browser, domains, profile);
|
||||
result.failed = Math.max(result.failed, failed - result.count);
|
||||
if (result.failed) result.failureReasons = { native_unrecovered: result.failed };
|
||||
}
|
||||
const receipt = {
|
||||
browser: options.browser,
|
||||
profile,
|
||||
imported: 0,
|
||||
failed: result.failed,
|
||||
domainCounts: {} as Record<string, number>,
|
||||
failureReasons: result.failureReasons ?? {},
|
||||
outcome: (result.failed ? 'failed' : 'empty') as 'empty' | 'imported' | 'partial' | 'failed',
|
||||
reset: 'not_requested' as 'not_requested' | 'cleared' | 'failed',
|
||||
verification: { verified: false, reason: 'not_requested' } as { verified: boolean; reason: string; status?: number },
|
||||
message: result.failed ? 'No cookies imported; cookies could not be decrypted.' : 'No matching cookies found.',
|
||||
};
|
||||
if (!result.count) {
|
||||
if (options.verifyAuth) receipt.verification.reason = 'no_cookies_imported';
|
||||
return receipt;
|
||||
}
|
||||
if (targetUrl && !result.cookies.some(cookie => cookieDomainMatches(targetUrl.hostname, cookie.domain))) {
|
||||
throw new CookieImportError('No imported cookies apply to the captured target origin.', 'target_mismatch');
|
||||
}
|
||||
if (target.page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed');
|
||||
if (needsTarget) validateCookieTarget(target);
|
||||
if (options.clearStorage) {
|
||||
try {
|
||||
await clearCookieTargetStorage(target.page, targetUrl!.origin);
|
||||
receipt.reset = 'cleared';
|
||||
} catch {
|
||||
receipt.outcome = 'failed';
|
||||
receipt.reset = 'failed';
|
||||
receipt.message = 'Storage reset did not complete; storage may be partially cleared. No new cookies were applied.';
|
||||
receipt.verification.reason = 'reset_failed';
|
||||
return receipt;
|
||||
}
|
||||
}
|
||||
const appliedDomains = [...new Set(result.cookies.map(cookie => cookie.domain))];
|
||||
try {
|
||||
await context.addCookies(result.cookies);
|
||||
} catch {
|
||||
trackDomains(appliedDomains);
|
||||
receipt.outcome = 'failed';
|
||||
receipt.message = 'Cookie application failed; the browser may contain a partial import. Authentication was not verified.';
|
||||
receipt.verification.reason = 'application_failed';
|
||||
return receipt;
|
||||
}
|
||||
trackDomains(appliedDomains);
|
||||
receipt.imported = result.count;
|
||||
receipt.domainCounts = result.domainCounts;
|
||||
receipt.outcome = result.failed ? 'partial' : 'imported';
|
||||
receipt.message = result.failed ? 'Some cookies could not be decrypted.' : 'Cookie copy complete.';
|
||||
if (options.verifyAuth) {
|
||||
try {
|
||||
validateCookieTarget(target);
|
||||
receipt.verification = await verifyCookieAuthentication(target.page, authOptions, targetUrl!.origin);
|
||||
} catch {
|
||||
receipt.verification = { verified: false, reason: 'target_changed' };
|
||||
}
|
||||
}
|
||||
return receipt;
|
||||
} finally {
|
||||
activeImports.delete(context);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCookieImportResult(result: Awaited<ReturnType<typeof runCookieImport>>): string {
|
||||
return `Imported ${result.imported} cookies from ${result.browser} (profile: ${result.profile}); ${result.failed} failed to decrypt. ${result.message} Storage reset: ${result.reset}. Authentication: ${result.verification.reason}.`;
|
||||
}
|
||||
@@ -19,24 +19,34 @@
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, importCookiesViaCdp, hasV20Cookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
|
||||
import { findInstalledBrowsers, listDomains, withCookieReadRetry, CookieImportError } from './cookie-import-browser';
|
||||
import { getCookiePickerHTML } from './cookie-picker-ui';
|
||||
import { getCookieProfiles, runCookieImport, type CookieImportTarget } from './cookie-import-operation';
|
||||
import { validateCookieStorageSupport } from './cookie-auth-verification';
|
||||
|
||||
// ─── Auth State ─────────────────────────────────────────────────
|
||||
// One-time codes for the cookie picker UI (code → expiry timestamp).
|
||||
// Codes are generated by generatePickerCode() and consumed on first use.
|
||||
const pendingCodes = new Map<string, number>();
|
||||
const CODE_TTL_MS = 30_000; // 30 seconds
|
||||
interface PickerContext {
|
||||
target?: CookieImportTarget;
|
||||
browser?: string;
|
||||
profile?: string;
|
||||
clearStorage?: boolean;
|
||||
verifyAuth?: boolean;
|
||||
}
|
||||
|
||||
const pendingCodes = new Map<string, PickerContext & { expiry: number }>();
|
||||
const CODE_TTL_MS = 5 * 60_000;
|
||||
|
||||
// Session cookies for authenticated picker access (session → expiry timestamp).
|
||||
// Sessions are created after a valid code exchange and last 1 hour.
|
||||
const validSessions = new Map<string, number>();
|
||||
const validSessions = new Map<string, PickerContext & { expiry: number; pickerInstance: string }>();
|
||||
const SESSION_TTL_MS = 3_600_000; // 1 hour
|
||||
|
||||
/** Generate a one-time code for opening the cookie picker UI. */
|
||||
export function generatePickerCode(): string {
|
||||
export function generatePickerCode(context: PickerContext = {}): string {
|
||||
const code = crypto.randomUUID();
|
||||
pendingCodes.set(code, Date.now() + CODE_TTL_MS);
|
||||
pendingCodes.set(code, { ...context, expiry: Date.now() + CODE_TTL_MS });
|
||||
return code;
|
||||
}
|
||||
|
||||
@@ -44,13 +54,13 @@ export function generatePickerCode(): string {
|
||||
export function hasActivePicker(): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [code, expiry] of pendingCodes) {
|
||||
if (expiry > now) return true;
|
||||
for (const [code, context] of pendingCodes) {
|
||||
if (context.expiry > now) return true;
|
||||
pendingCodes.delete(code);
|
||||
}
|
||||
|
||||
for (const [session, expiry] of validSessions) {
|
||||
if (expiry > now) return true;
|
||||
for (const [session, context] of validSessions) {
|
||||
if (context.expiry > now) return true;
|
||||
validSessions.delete(session);
|
||||
}
|
||||
|
||||
@@ -67,9 +77,9 @@ function getSessionFromCookie(req: Request): string | null {
|
||||
|
||||
/** Check if a session cookie value is valid and not expired. */
|
||||
function isValidSession(session: string): boolean {
|
||||
const expiry = validSessions.get(session);
|
||||
if (!expiry) return false;
|
||||
if (Date.now() > expiry) { validSessions.delete(session); return false; }
|
||||
const context = validSessions.get(session);
|
||||
if (!context) return false;
|
||||
if (Date.now() >= context.expiry) { validSessions.delete(session); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -121,7 +131,7 @@ export async function handleCookiePickerRoute(
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': corsOrigin(port),
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Gstack-Picker-Instance',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -133,8 +143,8 @@ export async function handleCookiePickerRoute(
|
||||
|
||||
// Code exchange: validate one-time code, set session cookie, redirect
|
||||
if (code) {
|
||||
const expiry = pendingCodes.get(code);
|
||||
if (!expiry || Date.now() > expiry) {
|
||||
const context = pendingCodes.get(code);
|
||||
if (!context || Date.now() >= context.expiry) {
|
||||
pendingCodes.delete(code);
|
||||
return new Response('Invalid or expired code. Re-run cookie-import-browser.', {
|
||||
status: 403,
|
||||
@@ -143,7 +153,7 @@ export async function handleCookiePickerRoute(
|
||||
}
|
||||
pendingCodes.delete(code); // one-time use
|
||||
const session = crypto.randomUUID();
|
||||
validSessions.set(session, Date.now() + SESSION_TTL_MS);
|
||||
validSessions.set(session, { ...context, expiry: Date.now() + SESSION_TTL_MS, pickerInstance: crypto.randomUUID() });
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
@@ -157,7 +167,26 @@ export async function handleCookiePickerRoute(
|
||||
// Session cookie: serve HTML (no auth token inlined)
|
||||
const session = getSessionFromCookie(req);
|
||||
if (session && isValidSession(session)) {
|
||||
const html = getCookiePickerHTML(port);
|
||||
const context = validSessions.get(session)!;
|
||||
let targetOrigin: string | undefined;
|
||||
let storageResetAvailable = false;
|
||||
try {
|
||||
const url = new URL(context.target?.url ?? '');
|
||||
if (['http:', 'https:'].includes(url.protocol)) targetOrigin = url.origin;
|
||||
} catch {}
|
||||
if (context.target) {
|
||||
try { validateCookieStorageSupport(context.target.page); storageResetAvailable = true; } catch {}
|
||||
}
|
||||
const html = getCookiePickerHTML(port, {
|
||||
pickerInstance: context.pickerInstance,
|
||||
browser: context.browser,
|
||||
profile: context.profile,
|
||||
clearStorage: context.clearStorage,
|
||||
verifyAuth: context.verifyAuth,
|
||||
targetOrigin,
|
||||
storageResetAvailable,
|
||||
verificationAvailable: !!process.env.GSTACK_COOKIE_AUTH_SELECTOR?.trim() && !!process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY?.trim(),
|
||||
});
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
@@ -182,6 +211,13 @@ export async function handleCookiePickerRoute(
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (req.method === 'POST' && !hasBearer && req.headers.get('origin') !== url.origin) {
|
||||
return errorResponse('Cookie picker mutations require a same-origin request.', 'invalid_origin', { port, status: 403 });
|
||||
}
|
||||
const pickerContext = !hasBearer && hasSession ? validSessions.get(sessionId!)! : undefined;
|
||||
if (pickerContext && req.headers.get('X-Gstack-Picker-Instance') !== pickerContext.pickerInstance) {
|
||||
return errorResponse('This picker no longer matches the active picker session. Reopen the picker from the intended page before continuing.', 'picker_changed', { port, status: 403 });
|
||||
}
|
||||
|
||||
// GET /cookie-picker/browsers — list installed browsers
|
||||
if (pathname === '/cookie-picker/browsers' && req.method === 'GET') {
|
||||
@@ -200,8 +236,9 @@ export async function handleCookiePickerRoute(
|
||||
if (!browserName) {
|
||||
return errorResponse("Missing 'browser' parameter", 'missing_param', { port });
|
||||
}
|
||||
const profiles = listProfiles(browserName);
|
||||
return jsonResponse({ profiles }, { port });
|
||||
let hostname: string | undefined;
|
||||
try { hostname = new URL(pickerContext?.target?.url ?? '').hostname || undefined; } catch {}
|
||||
return jsonResponse(await getCookieProfiles(browserName, [], hostname), { port });
|
||||
}
|
||||
|
||||
// GET /cookie-picker/domains?browser=<name>&profile=<profile> — list domains + counts
|
||||
@@ -211,7 +248,7 @@ export async function handleCookiePickerRoute(
|
||||
return errorResponse("Missing 'browser' parameter", 'missing_param', { port });
|
||||
}
|
||||
const profile = url.searchParams.get('profile') || 'Default';
|
||||
const result = listDomains(browserName, profile);
|
||||
const result = await withCookieReadRetry(() => listDomains(browserName, profile));
|
||||
return jsonResponse({
|
||||
browser: result.browser,
|
||||
domains: result.domains,
|
||||
@@ -227,61 +264,27 @@ export async function handleCookiePickerRoute(
|
||||
return errorResponse('Invalid JSON body', 'bad_request', { port });
|
||||
}
|
||||
|
||||
const { browser, domains, profile } = body;
|
||||
const { browser, domains, profile, clearStorage, verifyAuth } = body ?? {};
|
||||
if (!browser) return errorResponse("Missing 'browser' field", 'missing_param', { port });
|
||||
if (!domains || !Array.isArray(domains) || domains.length === 0) {
|
||||
return errorResponse("Missing or empty 'domains' array", 'missing_param', { port });
|
||||
}
|
||||
|
||||
// Decrypt cookies from the browser DB
|
||||
const selectedProfile = profile || 'Default';
|
||||
let result = await importCookies(browser, domains, selectedProfile);
|
||||
|
||||
// If all cookies failed and v20 encryption is detected, try CDP extraction
|
||||
if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, selectedProfile)) {
|
||||
console.log(`[cookie-picker] v20 App-Bound Encryption detected, trying CDP extraction...`);
|
||||
try {
|
||||
result = await importCookiesViaCdp(browser, domains, selectedProfile);
|
||||
} catch (cdpErr: any) {
|
||||
console.log(`[cookie-picker] CDP fallback failed: ${cdpErr.message}`);
|
||||
return jsonResponse({
|
||||
imported: 0,
|
||||
failed: result.failed,
|
||||
domainCounts: {},
|
||||
message: `Cookies use App-Bound Encryption (v20). Close ${browser}, retry, or use /connect-chrome to browse with your real browser directly.`,
|
||||
code: 'v20_encryption',
|
||||
}, { port });
|
||||
}
|
||||
}
|
||||
|
||||
if (result.cookies.length === 0) {
|
||||
return jsonResponse({
|
||||
imported: 0,
|
||||
failed: result.failed,
|
||||
domainCounts: {},
|
||||
message: result.failed > 0
|
||||
? `All ${result.failed} cookies failed to decrypt`
|
||||
: 'No cookies found for the specified domains',
|
||||
}, { port });
|
||||
}
|
||||
|
||||
// Add to Playwright context
|
||||
const page = bm.getActiveSession().getPage();
|
||||
await page.context().addCookies(result.cookies);
|
||||
|
||||
// Track what was imported
|
||||
const page = pickerContext?.target?.page ?? bm.getActiveSession().getPage();
|
||||
const target = pickerContext?.target ?? { page, url: page.url() };
|
||||
const result = await runCookieImport({
|
||||
browser, domains, profile,
|
||||
clearStorage: clearStorage ?? pickerContext?.clearStorage ?? false,
|
||||
verifyAuth: verifyAuth ?? pickerContext?.verifyAuth ?? false,
|
||||
}, target, domains => bm.trackCookieImportDomains(domains), {
|
||||
identitySelector: process.env.GSTACK_COOKIE_AUTH_SELECTOR,
|
||||
expectedIdentity: process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY,
|
||||
});
|
||||
for (const domain of Object.keys(result.domainCounts)) {
|
||||
importedDomains.add(domain);
|
||||
importedCounts.set(domain, (importedCounts.get(domain) || 0) + result.domainCounts[domain]);
|
||||
importedCounts.set(domain, result.domainCounts[domain]);
|
||||
}
|
||||
|
||||
console.log(`[cookie-picker] Imported ${result.count} cookies for ${Object.keys(result.domainCounts).length} domains`);
|
||||
|
||||
return jsonResponse({
|
||||
imported: result.count,
|
||||
failed: result.failed,
|
||||
domainCounts: result.domainCounts,
|
||||
}, { port });
|
||||
return jsonResponse(result, { port });
|
||||
}
|
||||
|
||||
// POST /cookie-picker/remove — clear cookies for domains
|
||||
@@ -334,7 +337,7 @@ export async function handleCookiePickerRoute(
|
||||
if (err instanceof CookieImportError) {
|
||||
return errorResponse(err.message, err.code, { port, status: 400, action: err.action });
|
||||
}
|
||||
console.error(`[cookie-picker] Error: ${err.message}`);
|
||||
return errorResponse(err.message || 'Internal error', 'internal_error', { port, status: 500 });
|
||||
console.error('[cookie-picker] Operation failed');
|
||||
return errorResponse('Cookie picker operation failed. Retry or reopen the picker.', 'internal_error', { port, status: 500 });
|
||||
}
|
||||
}
|
||||
+297
-276
@@ -7,8 +7,32 @@
|
||||
* No cookie values exposed anywhere.
|
||||
*/
|
||||
|
||||
export function getCookiePickerHTML(serverPort: number): string {
|
||||
export function getCookiePickerHTML(serverPort: number, options: {
|
||||
pickerInstance?: string;
|
||||
browser?: string;
|
||||
profile?: string;
|
||||
targetOrigin?: string;
|
||||
verifyAuth?: boolean;
|
||||
clearStorage?: boolean;
|
||||
verificationAvailable?: boolean;
|
||||
storageResetAvailable?: boolean;
|
||||
} = {}): string {
|
||||
const baseUrl = `http://127.0.0.1:${serverPort}`;
|
||||
let targetOrigin: string | undefined;
|
||||
try {
|
||||
const target = new URL(options.targetOrigin ?? '');
|
||||
if (['http:', 'https:'].includes(target.protocol)) targetOrigin = target.origin;
|
||||
} catch {}
|
||||
const config = JSON.stringify({
|
||||
pickerInstance: options.pickerInstance,
|
||||
browser: options.browser,
|
||||
profile: options.profile,
|
||||
targetOrigin,
|
||||
verifyAuth: options.verifyAuth === true,
|
||||
clearStorage: options.clearStorage === true,
|
||||
verificationAvailable: options.verificationAvailable === true,
|
||||
storageResetAvailable: options.storageResetAvailable !== false,
|
||||
}).replace(/[<>&\u2028\u2029]/g, character => '\\u' + character.charCodeAt(0).toString(16).padStart(4, '0'));
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -24,6 +48,8 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
color: #e0e0e0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Header ──────────────────────────── */
|
||||
@@ -58,7 +84,8 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
/* ─── Layout ──────────────────────────── */
|
||||
.container {
|
||||
display: flex;
|
||||
height: calc(100vh - 53px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.panel {
|
||||
flex: 1;
|
||||
@@ -255,7 +282,7 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
.banner {
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -269,6 +296,26 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
border-bottom: 1px solid #112233;
|
||||
color: #60a5fa;
|
||||
}
|
||||
.banner.warning {
|
||||
background: #241b0a;
|
||||
border-bottom: 1px solid #49330d;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.target-options {
|
||||
border: 0;
|
||||
border-bottom: 1px solid #222;
|
||||
padding: 10px 24px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.target-options legend { padding-top: 10px; color: #ccc; }
|
||||
.target-options label { display: flex; align-items: flex-start; gap: 8px; line-height: 1.5; }
|
||||
.target-options input { margin-top: 3px; accent-color: #60a5fa; }
|
||||
.target-options small { color: #888; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid #60a5fa; outline-offset: 3px; }
|
||||
.banner .banner-text { flex: 1; }
|
||||
.banner .banner-close, .banner .banner-retry {
|
||||
background: none;
|
||||
@@ -309,9 +356,14 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
<span class="port">localhost:${serverPort}</span>
|
||||
</div>
|
||||
|
||||
<p class="subtitle">Select the domains of cookies you want to import to GStack Browser. You'll be able to browse those sites with the same login as your other browser.</p>
|
||||
<p class="subtitle">Copy cookies from the browser and profile you choose to this GStack Browser session. Copying cookies does not prove that you are signed in. Cookies are shared by tabs in this session.</p>
|
||||
|
||||
<div id="banner" class="banner"></div>
|
||||
<fieldset class="target-options">
|
||||
<legend>Captured target: <span id="target-origin">No HTTP(S) target bound</span></legend>
|
||||
<label><input id="clear-storage" type="checkbox"><span>Clear storage for this target origin before importing.<br><small>Chromium targets only. Clears origin localStorage (shared across tabs) and this target tab's sessionStorage only. Other origins and other tabs' sessionStorage are preserved.</small></span></label>
|
||||
<label><input id="verify-auth" type="checkbox"><span>Reload the captured target and verify the configured account identity.<br><small id="verification-help">Requires an explicitly configured identity assertion and an HTTP(S) target.</small></span></label>
|
||||
</fieldset>
|
||||
<div id="banner" class="banner info" role="status" aria-live="polite" aria-atomic="true">Choose a browser and profile to inspect cookie domains.</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Left Panel: Source Browser -->
|
||||
@@ -320,7 +372,7 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
<div id="browser-pills" class="browser-pills"></div>
|
||||
<div id="profile-pills" class="profile-pills" style="display:none"></div>
|
||||
<div class="search-wrap">
|
||||
<input type="text" class="search-input" id="search" placeholder="Search domains..." />
|
||||
<input type="text" class="search-input" id="search" placeholder="Search domains..." aria-label="Search cookie domains" />
|
||||
</div>
|
||||
<div class="domain-list" id="source-domains">
|
||||
<div class="loading-row"><span class="spinner"></span> Detecting browsers...</div>
|
||||
@@ -338,15 +390,42 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="picker-config" type="application/json">${config}</script>
|
||||
<script>
|
||||
(function() {
|
||||
const BASE = '${baseUrl}';
|
||||
const config = JSON.parse(document.getElementById('picker-config').textContent);
|
||||
let activeBrowser = null;
|
||||
let activeProfile = 'Default';
|
||||
let configuredBrowser = null;
|
||||
let activeProfile = null;
|
||||
let allProfiles = [];
|
||||
let allDomains = [];
|
||||
let importedSet = {}; // domain → count
|
||||
let inflight = {}; // domain → true (prevents double-click)
|
||||
let importedSet = Object.create(null);
|
||||
let generation = 0;
|
||||
let mutation = false;
|
||||
const errorMessages = {
|
||||
picker_changed: 'This picker is stale because another picker was opened. Reopen the picker from the intended page before continuing.',
|
||||
keychain_denied: 'Keychain access was denied. Allow access in the OS permission prompt or settings, then retry manually.',
|
||||
keychain_timeout: 'Credential lookup timed out. Check for a pending OS permission prompt, then retry manually.',
|
||||
keychain_error: 'Credential lookup failed. Check the OS credential store or sign in manually in GStack Browser.',
|
||||
db_locked: 'The source cookie database is busy. Close the source browser, then retry manually.',
|
||||
db_corrupt: 'The source cookie database is invalid or corrupt. Choose another profile or sign in manually in GStack Browser.',
|
||||
db_permission: 'Cookie database access was denied. Check source-profile permissions, then retry manually.',
|
||||
db_read_error: 'Cookie data could not be read from this profile. Choose another profile or sign in manually in GStack Browser.',
|
||||
sqlite_unavailable: 'Cookie import needs Node.js 22.13 or newer with built-in SQLite enabled. Upgrade the runtime or sign in manually in GStack Browser.',
|
||||
storage_reset_unsupported: 'Storage reset requires a Chromium target. Import cookies without storage reset on other browsers.',
|
||||
profile_required: 'Choose a source profile explicitly; multiple or unavailable profiles cannot be selected automatically.',
|
||||
target_changed: 'The captured target changed or is unavailable. Reopen the picker from the intended HTTP(S) page.',
|
||||
target_closed: 'The captured target is closed. Reopen the picker from the intended HTTP(S) page.',
|
||||
target_mismatch: 'The selected cookies do not match the captured target. Select its cookie domain or reopen the picker from the intended page.',
|
||||
not_supported: 'Native cookie import is unsupported for this browser or runtime. Sign in manually in GStack Browser.',
|
||||
native_profile_unsupported: 'This browser profile does not support native cookie extraction. Sign in manually in GStack Browser.',
|
||||
native_unqualified: 'Native extraction is disabled because process ownership and cleanup are not qualified for this browser and runtime. Sign in manually in GStack Browser.',
|
||||
native_cleanup_failed: 'Native browser cleanup could not be confirmed. Inspect the source browser before any manual retry, or sign in manually in GStack Browser.',
|
||||
native_supervision_failed: 'Native browser supervision could not start. Sign in manually in GStack Browser.',
|
||||
native_timeout: 'Native cookie extraction timed out. Sign in manually in GStack Browser.',
|
||||
browser_running: 'The source browser is already running. Close it yourself before retrying, or sign in manually in GStack Browser.',
|
||||
};
|
||||
|
||||
const $pills = document.getElementById('browser-pills');
|
||||
const $profilePills = document.getElementById('profile-pills');
|
||||
@@ -357,337 +436,279 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
const $btnImportAll = document.getElementById('btn-import-all');
|
||||
const $importedFooter = document.getElementById('imported-footer');
|
||||
const $banner = document.getElementById('banner');
|
||||
|
||||
// ─── Banner ────────────────────────────
|
||||
function showBanner(msg, type, retryFn) {
|
||||
$banner.className = 'banner ' + type;
|
||||
$banner.style.display = 'flex';
|
||||
let html = '<span class="banner-text">' + escHtml(msg) + '</span>';
|
||||
if (retryFn) {
|
||||
html += '<button class="banner-retry" id="banner-retry">Retry</button>';
|
||||
}
|
||||
html += '<button class="banner-close" id="banner-close">×</button>';
|
||||
$banner.innerHTML = html;
|
||||
document.getElementById('banner-close').onclick = () => { $banner.style.display = 'none'; };
|
||||
if (retryFn) {
|
||||
document.getElementById('banner-retry').onclick = () => {
|
||||
$banner.style.display = 'none';
|
||||
retryFn();
|
||||
};
|
||||
}
|
||||
}
|
||||
const $clearStorage = document.getElementById('clear-storage');
|
||||
const $verifyAuth = document.getElementById('verify-auth');
|
||||
const canVerify = !!config.targetOrigin && config.verificationAvailable;
|
||||
const canReset = !!config.targetOrigin && config.storageResetAvailable;
|
||||
document.getElementById('target-origin').textContent = config.targetOrigin || 'No HTTP(S) target bound';
|
||||
$clearStorage.checked = canReset && config.clearStorage;
|
||||
$clearStorage.disabled = !canReset;
|
||||
$verifyAuth.checked = canVerify && config.verifyAuth;
|
||||
$verifyAuth.disabled = !canVerify;
|
||||
if (canVerify) document.getElementById('verification-help').textContent = 'Optional. Uses the server-configured exact identity assertion; the identity is never displayed here.';
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function errorMessage(error) {
|
||||
return error && Object.hasOwn(errorMessages, error.code) ? errorMessages[error.code]
|
||||
: 'Inspect the source and destination before retrying, or reopen the picker.';
|
||||
}
|
||||
|
||||
function showBanner(message, type, retry) {
|
||||
$banner.className = 'banner ' + type;
|
||||
$banner.innerHTML = '<span class="banner-text">' + escHtml(message) + '</span>';
|
||||
if (retry) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'banner-retry';
|
||||
button.textContent = 'Retry';
|
||||
button.disabled = mutation;
|
||||
button.onclick = () => { if (!mutation) retry(); };
|
||||
$banner.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API ────────────────────────────────
|
||||
async function api(path, opts) {
|
||||
const res = await fetch(BASE + '/cookie-picker' + path, { ...opts, credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || 'Request failed');
|
||||
err.code = data.code;
|
||||
err.action = data.action;
|
||||
throw err;
|
||||
const headers = new Headers(opts && opts.headers);
|
||||
headers.set('X-Gstack-Picker-Instance', config.pickerInstance || '');
|
||||
const response = await fetch(BASE + '/cookie-picker' + path, { ...opts, headers, credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = new Error('Cookie picker request failed.');
|
||||
if (data && typeof data.code === 'string' && Object.hasOwn(errorMessages, data.code)) error.code = data.code;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Init ───────────────────────────────
|
||||
async function init() {
|
||||
try {
|
||||
const [browserData, importedData] = await Promise.all([
|
||||
api('/browsers'),
|
||||
api('/imported'),
|
||||
]);
|
||||
|
||||
// Populate imported state
|
||||
for (const entry of importedData.domains) {
|
||||
importedSet[entry.domain] = entry.count;
|
||||
const [browserData, importedData] = await Promise.all([api('/browsers'), api('/imported')]);
|
||||
for (const entry of importedData.domains || []) {
|
||||
if (Number.isFinite(entry.count) && entry.count > 0) importedSet[entry.domain] = entry.count;
|
||||
}
|
||||
renderImported();
|
||||
|
||||
// Render browser pills
|
||||
const browsers = browserData.browsers;
|
||||
if (browsers.length === 0) {
|
||||
const browsers = browserData.browsers || [];
|
||||
$pills.innerHTML = '';
|
||||
for (const browser of browsers) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'pill';
|
||||
button.dataset.browser = browser.name;
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
button.innerHTML = '<span class="dot"></span>' + escHtml(browser.name);
|
||||
button.onclick = () => selectBrowser(browser.name);
|
||||
$pills.appendChild(button);
|
||||
}
|
||||
if (!browsers.length) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">No Chromium browsers detected</div>';
|
||||
showBanner('No supported source browsers were detected.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
$pills.innerHTML = '';
|
||||
browsers.forEach(b => {
|
||||
const pill = document.createElement('button');
|
||||
pill.className = 'pill';
|
||||
pill.innerHTML = '<span class="dot"></span>' + escHtml(b.name);
|
||||
pill.onclick = () => selectBrowser(b.name);
|
||||
$pills.appendChild(pill);
|
||||
});
|
||||
|
||||
// Auto-select first browser
|
||||
selectBrowser(browsers[0].name);
|
||||
} catch (err) {
|
||||
showBanner(err.message, 'error', init);
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Select Browser ────────────────────
|
||||
async function selectBrowser(name) {
|
||||
activeBrowser = name;
|
||||
activeProfile = 'Default';
|
||||
|
||||
// Update pills
|
||||
$pills.querySelectorAll('.pill').forEach(p => {
|
||||
p.classList.toggle('active', p.textContent === name);
|
||||
});
|
||||
|
||||
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading...</div>';
|
||||
$sourceFooter.textContent = '';
|
||||
$search.value = '';
|
||||
|
||||
try {
|
||||
// Fetch profiles for this browser
|
||||
const profileData = await api('/profiles?browser=' + encodeURIComponent(name));
|
||||
allProfiles = profileData.profiles || [];
|
||||
|
||||
if (allProfiles.length > 1) {
|
||||
// Show profile pills when multiple profiles exist
|
||||
$profilePills.style.display = 'flex';
|
||||
renderProfilePills();
|
||||
// Auto-select profile with the most recent/largest cookie DB, or Default
|
||||
activeProfile = allProfiles[0].name;
|
||||
} else {
|
||||
$profilePills.style.display = 'none';
|
||||
activeProfile = allProfiles.length === 1 ? allProfiles[0].name : 'Default';
|
||||
const selected = config.browser
|
||||
? browsers.find(browser => [browser.name, ...(Array.isArray(browser.aliases) ? browser.aliases : [])]
|
||||
.some(name => typeof name === 'string' && name.toLowerCase() === config.browser.trim().toLowerCase()))
|
||||
: browsers[0];
|
||||
configuredBrowser = config.browser && selected ? selected.name : null;
|
||||
if (selected) await selectBrowser(selected.name);
|
||||
else {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Choose an available source browser</div>';
|
||||
showBanner('The requested browser is unavailable. Choose an available browser explicitly.', 'warning');
|
||||
}
|
||||
|
||||
await loadDomains();
|
||||
} catch (err) {
|
||||
showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null);
|
||||
} catch (error) {
|
||||
showBanner('Could not load the cookie picker. ' + errorMessage(error), 'error', init);
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
|
||||
$profilePills.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render Profile Pills ─────────────
|
||||
function renderProfilePills() {
|
||||
let html = '';
|
||||
for (const p of allProfiles) {
|
||||
const isActive = p.name === activeProfile;
|
||||
const label = p.displayName || p.name;
|
||||
html += '<button class="profile-pill' + (isActive ? ' active' : '') + '" data-profile="' + escHtml(p.name) + '">' + escHtml(label) + '</button>';
|
||||
async function selectBrowser(name) {
|
||||
if (mutation) return;
|
||||
const selection = ++generation;
|
||||
activeBrowser = name;
|
||||
activeProfile = null;
|
||||
allProfiles = [];
|
||||
allDomains = [];
|
||||
$search.value = '';
|
||||
$profilePills.innerHTML = '';
|
||||
$profilePills.style.display = 'none';
|
||||
$btnImportAll.style.display = 'none';
|
||||
$sourceFooter.textContent = '';
|
||||
$pills.querySelectorAll('button').forEach(button => {
|
||||
const active = button.dataset.browser === name;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading profiles...</div>';
|
||||
showBanner('Loading profiles from ' + name + '.', 'info');
|
||||
try {
|
||||
const data = await api('/profiles?browser=' + encodeURIComponent(name));
|
||||
if (selection !== generation) return;
|
||||
allProfiles = data.profiles || [];
|
||||
const explicit = config.profile && configuredBrowser === name;
|
||||
const requested = explicit ? config.profile : data.recommendedProfile;
|
||||
activeProfile = allProfiles.some(profile => profile.name === requested) ? requested : null;
|
||||
renderProfilePills();
|
||||
if (!activeProfile) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">' + (allProfiles.length ? 'Choose a profile to inspect its domains' : 'No profiles found') + '</div>';
|
||||
showBanner(explicit ? 'The requested profile is unavailable. Choose a profile explicitly.'
|
||||
: allProfiles.length ? 'Choose a profile. No unambiguous profile was recommended.' : 'No source profiles were found.', 'warning');
|
||||
return;
|
||||
}
|
||||
await loadDomains(selection, name, activeProfile);
|
||||
} catch (error) {
|
||||
if (selection !== generation) return;
|
||||
showBanner('Could not load profiles for ' + name + '. ' + errorMessage(error), 'error', () => selectBrowser(name));
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load profiles</div>';
|
||||
}
|
||||
$profilePills.innerHTML = html;
|
||||
}
|
||||
|
||||
$profilePills.querySelectorAll('.profile-pill').forEach(btn => {
|
||||
btn.addEventListener('click', () => selectProfile(btn.dataset.profile));
|
||||
function renderProfilePills() {
|
||||
$profilePills.style.display = allProfiles.length ? 'flex' : 'none';
|
||||
$profilePills.innerHTML = allProfiles.map(profile => {
|
||||
const active = profile.name === activeProfile;
|
||||
const label = (profile.displayName || profile.name) + ' (' + profile.name + ')' + (profile.unavailable ? ' — could not inspect' : '');
|
||||
return '<button class="profile-pill' + (active ? ' active' : '') + '" aria-pressed="' + active + '" data-profile="' + escHtml(profile.name) + '"' + (mutation ? ' disabled' : '') + '>' + escHtml(label) + '</button>';
|
||||
}).join('');
|
||||
$profilePills.querySelectorAll('button').forEach(button => {
|
||||
button.onclick = () => selectProfile(button.dataset.profile);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Select Profile ───────────────────
|
||||
async function selectProfile(profileName) {
|
||||
activeProfile = profileName;
|
||||
async function selectProfile(name) {
|
||||
if (mutation || !allProfiles.some(profile => profile.name === name)) return;
|
||||
const selection = ++generation;
|
||||
activeProfile = name;
|
||||
allDomains = [];
|
||||
$search.value = '';
|
||||
renderProfilePills();
|
||||
$profilePills.querySelectorAll('button').forEach(button => { if (button.dataset.profile === name) button.focus(); });
|
||||
await loadDomains(selection, activeBrowser, name);
|
||||
}
|
||||
|
||||
async function loadDomains(selection, browser, profile) {
|
||||
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
|
||||
$sourceFooter.textContent = '';
|
||||
$search.value = '';
|
||||
|
||||
await loadDomains();
|
||||
}
|
||||
|
||||
// ─── Load Domains ─────────────────────
|
||||
async function loadDomains() {
|
||||
$btnImportAll.style.display = 'none';
|
||||
showBanner('Loading domains from ' + browser + ' (' + profile + ').', 'info');
|
||||
try {
|
||||
const data = await api('/domains?browser=' + encodeURIComponent(activeBrowser) + '&profile=' + encodeURIComponent(activeProfile));
|
||||
allDomains = data.domains;
|
||||
const data = await api('/domains?browser=' + encodeURIComponent(browser) + '&profile=' + encodeURIComponent(profile));
|
||||
if (selection !== generation) return;
|
||||
allDomains = data.domains || [];
|
||||
renderSourceDomains();
|
||||
} catch (err) {
|
||||
showBanner(err.message, 'error', err.action === 'retry' ? () => loadDomains() : null);
|
||||
showBanner(allDomains.length ? 'Ready to import from ' + browser + ' (' + profile + '). Authentication has not been checked.'
|
||||
: 'No cookie domains found in ' + browser + ' (' + profile + ').', allDomains.length ? 'info' : 'warning');
|
||||
} catch (error) {
|
||||
if (selection !== generation) return;
|
||||
showBanner('Could not read domains from ' + browser + ' (' + profile + '). ' + errorMessage(error), 'error', () => selectProfile(profile));
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load domains</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render Source Domains ─────────────
|
||||
function renderSourceDomains() {
|
||||
const query = $search.value.toLowerCase();
|
||||
const filtered = query
|
||||
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
|
||||
: allDomains;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">' +
|
||||
(query ? 'No matching domains' : 'No cookie domains found') + '</div>';
|
||||
$sourceFooter.textContent = '';
|
||||
const filtered = allDomains.filter(domain => domain.domain.toLowerCase().includes(query));
|
||||
$btnImportAll.style.display = filtered.length && activeProfile ? '' : 'none';
|
||||
$btnImportAll.disabled = mutation || !activeProfile;
|
||||
$btnImportAll.textContent = 'Import All (' + filtered.length + ')';
|
||||
$sourceFooter.textContent = allDomains.length ? allDomains.length + ' domains · ' + allDomains.reduce((sum, domain) => sum + domain.count, 0).toLocaleString() + ' cookies' : '';
|
||||
if (!filtered.length) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">' + (!activeProfile ? 'Choose a profile to inspect its domains' : query ? 'No matching domains' : 'No cookie domains found') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const d of filtered) {
|
||||
const isImported = d.domain in importedSet;
|
||||
const isInflight = inflight[d.domain];
|
||||
html += '<div class="domain-row">';
|
||||
html += '<span class="domain-name">' + escHtml(d.domain) + '</span>';
|
||||
html += '<span class="domain-count">' + d.count + '</span>';
|
||||
if (isInflight) {
|
||||
html += '<span class="btn-add" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;"></span></span>';
|
||||
} else if (isImported) {
|
||||
html += '<span class="btn-add imported">✓</span>';
|
||||
} else {
|
||||
html += '<button class="btn-add" data-domain="' + escHtml(d.domain) + '" title="Import">+</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$sourceDomains.innerHTML = html;
|
||||
|
||||
// Total counts
|
||||
const totalDomains = allDomains.length;
|
||||
const totalCookies = allDomains.reduce((s, d) => s + d.count, 0);
|
||||
$sourceFooter.textContent = totalDomains + ' domains · ' + totalCookies.toLocaleString() + ' cookies';
|
||||
|
||||
// Show/hide Import All button
|
||||
const unimported = filtered.filter(d => !(d.domain in importedSet) && !inflight[d.domain]);
|
||||
if (unimported.length > 0) {
|
||||
$btnImportAll.style.display = '';
|
||||
$btnImportAll.disabled = false;
|
||||
$btnImportAll.textContent = 'Import All (' + unimported.length + ')';
|
||||
} else {
|
||||
$btnImportAll.style.display = 'none';
|
||||
}
|
||||
|
||||
// Click handlers
|
||||
$sourceDomains.querySelectorAll('.btn-add[data-domain]').forEach(btn => {
|
||||
btn.addEventListener('click', () => importDomain(btn.dataset.domain));
|
||||
$sourceDomains.innerHTML = filtered.map(domain => {
|
||||
const label = (domain.domain in importedSet ? 'Reimport ' : 'Import ') + domain.domain;
|
||||
return '<div class="domain-row"><span class="domain-name">' + escHtml(domain.domain) + '</span><span class="domain-count">' + escHtml(domain.count) + '</span><button class="btn-add" data-domain="' + escHtml(domain.domain) + '" title="' + escHtml(label) + '" aria-label="' + escHtml(label) + '"' + (mutation ? ' disabled' : '') + '>' + (domain.domain in importedSet ? '↻' : '+') + '</button></div>';
|
||||
}).join('');
|
||||
$sourceDomains.querySelectorAll('button').forEach(button => {
|
||||
button.onclick = () => importDomains([button.dataset.domain]);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Import Domain ─────────────────────
|
||||
async function importDomain(domain) {
|
||||
if (inflight[domain] || domain in importedSet) return;
|
||||
inflight[domain] = true;
|
||||
function setMutationBusy(busy) {
|
||||
mutation = busy;
|
||||
$pills.querySelectorAll('button').forEach(button => { button.disabled = busy; });
|
||||
$profilePills.querySelectorAll('button').forEach(button => { button.disabled = busy; });
|
||||
$search.disabled = busy;
|
||||
$clearStorage.disabled = busy || !canReset;
|
||||
$verifyAuth.disabled = busy || !canVerify;
|
||||
renderSourceDomains();
|
||||
renderImported();
|
||||
}
|
||||
|
||||
async function importDomains(domains) {
|
||||
if (mutation || !activeBrowser || !activeProfile || !domains.length) return;
|
||||
const request = { browser: activeBrowser, profile: activeProfile, domains: domains.slice(),
|
||||
clearStorage: canReset && $clearStorage.checked, verifyAuth: canVerify && $verifyAuth.checked };
|
||||
setMutationBusy(true);
|
||||
showBanner('Importing from ' + request.browser + ' (' + request.profile + '). Keep this picker open.', 'info');
|
||||
try {
|
||||
const data = await api('/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ browser: activeBrowser, domains: [domain], profile: activeProfile }),
|
||||
});
|
||||
|
||||
if (data.domainCounts) {
|
||||
for (const [d, count] of Object.entries(data.domainCounts)) {
|
||||
importedSet[d] = (importedSet[d] || 0) + count;
|
||||
}
|
||||
const data = await api('/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) });
|
||||
const imported = Number.isFinite(data.imported) && data.imported > 0 ? data.imported : 0;
|
||||
const failed = Number.isFinite(data.failed) && data.failed > 0 ? data.failed : 0;
|
||||
for (const [domain, count] of Object.entries(data.domainCounts || {})) {
|
||||
if (imported > 0 && Number.isFinite(count) && count > 0) importedSet[domain] = count;
|
||||
}
|
||||
renderImported();
|
||||
} catch (err) {
|
||||
showBanner('Import failed for ' + domain + ': ' + err.message, 'error',
|
||||
err.action === 'retry' ? () => importDomain(domain) : null);
|
||||
const partial = data.outcome === 'partial' || (imported > 0 && failed > 0);
|
||||
let type = data.outcome === 'failed' || data.reset === 'failed' ? 'error' : partial || !imported ? 'warning' : 'info';
|
||||
let message = (data.outcome === 'failed' ? 'Import failed. ' : partial ? 'Partial import. ' : !imported ? 'No cookies imported. ' : 'Cookies imported. ')
|
||||
+ imported + ' imported; ' + failed + ' failed. Source: ' + request.browser + ' (' + request.profile + ').';
|
||||
if (typeof data.message === 'string' && data.message) message += ' ' + data.message;
|
||||
const failureLabels = { unsupported_encryption: 'unsupported encryption', decryption_failed: 'decryption failed', native_unrecovered: 'not recovered by native import' };
|
||||
for (const [reason, count] of Object.entries(data.failureReasons || {})) {
|
||||
if (Object.hasOwn(failureLabels, reason) && Number.isFinite(count) && count > 0) message += ' ' + failureLabels[reason] + ': ' + count + '.';
|
||||
}
|
||||
message += data.reset === 'cleared' ? ' Storage cleared for ' + config.targetOrigin + '.'
|
||||
: data.reset === 'failed' ? ' Storage reset failed; storage may be partially cleared.' : ' Storage preserved.';
|
||||
if (!request.verifyAuth) message += ' Authentication not checked.';
|
||||
else if (imported > 0 && data.verification && data.verification.verified === true) message += ' Authentication verified on the captured target.';
|
||||
else {
|
||||
const reasons = { not_configured: 'identity assertion not configured', no_cookies_imported: 'no cookies imported',
|
||||
identity_missing: 'visible identity missing', identity_ambiguous: 'multiple visible identities', identity_mismatch: 'identity did not match',
|
||||
login_redirect: 'login page detected', target_changed: 'target changed', target_closed: 'target closed',
|
||||
timeout: 'check timed out', http_error: 'unsuccessful HTTP response', reset_failed: 'storage reset failed', application_failed: 'cookie application failed' };
|
||||
const reason = data.verification && data.verification.reason;
|
||||
message += ' Authentication not verified' + (Object.hasOwn(reasons, reason) ? ': ' + reasons[reason] : '') + '.';
|
||||
if (type === 'info') type = 'warning';
|
||||
}
|
||||
showBanner(message, type);
|
||||
} catch (error) {
|
||||
showBanner('Import did not complete for ' + request.browser + ' (' + request.profile + '). ' + errorMessage(error) + ' Authentication was not verified.', 'error');
|
||||
} finally {
|
||||
delete inflight[domain];
|
||||
renderSourceDomains();
|
||||
setMutationBusy(false);
|
||||
if (domains.length === 1) {
|
||||
$sourceDomains.querySelectorAll('button').forEach(button => { if (button.dataset.domain === domains[0]) button.focus(); });
|
||||
} else $btnImportAll.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Import All ───────────────────────
|
||||
async function importAll() {
|
||||
const query = $search.value.toLowerCase();
|
||||
const filtered = query
|
||||
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
|
||||
: allDomains;
|
||||
const toImport = filtered.filter(d => !(d.domain in importedSet) && !inflight[d.domain]);
|
||||
if (toImport.length === 0) return;
|
||||
$btnImportAll.onclick = () => importDomains(allDomains.filter(domain => domain.domain.toLowerCase().includes($search.value.toLowerCase())).map(domain => domain.domain));
|
||||
|
||||
$btnImportAll.disabled = true;
|
||||
$btnImportAll.textContent = 'Importing...';
|
||||
|
||||
const domains = toImport.map(d => d.domain);
|
||||
try {
|
||||
const data = await api('/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ browser: activeBrowser, domains: domains, profile: activeProfile }),
|
||||
});
|
||||
|
||||
if (data.domainCounts) {
|
||||
for (const [d, count] of Object.entries(data.domainCounts)) {
|
||||
importedSet[d] = (importedSet[d] || 0) + count;
|
||||
}
|
||||
}
|
||||
renderImported();
|
||||
} catch (err) {
|
||||
showBanner('Import all failed: ' + err.message, 'error',
|
||||
err.action === 'retry' ? () => importAll() : null);
|
||||
} finally {
|
||||
renderSourceDomains();
|
||||
}
|
||||
}
|
||||
|
||||
$btnImportAll.addEventListener('click', importAll);
|
||||
|
||||
// ─── Render Imported ───────────────────
|
||||
function renderImported() {
|
||||
const entries = Object.entries(importedSet).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
$importedDomains.innerHTML = '<div class="imported-empty">No cookies imported yet</div>';
|
||||
$importedFooter.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const [domain, count] of entries) {
|
||||
const isInflight = inflight['remove:' + domain];
|
||||
html += '<div class="domain-row">';
|
||||
html += '<span class="domain-name">' + escHtml(domain) + '</span>';
|
||||
html += '<span class="domain-count">' + count + '</span>';
|
||||
if (isInflight) {
|
||||
html += '<span class="btn-trash" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;border-top-color:#f87171;"></span></span>';
|
||||
} else {
|
||||
html += '<button class="btn-trash" data-domain="' + escHtml(domain) + '" title="Remove">🗑</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$importedDomains.innerHTML = html;
|
||||
|
||||
const totalCookies = entries.reduce((s, e) => s + e[1], 0);
|
||||
$importedFooter.textContent = entries.length + ' domains · ' + totalCookies.toLocaleString() + ' cookies imported';
|
||||
|
||||
// Click handlers
|
||||
$importedDomains.querySelectorAll('.btn-trash[data-domain]').forEach(btn => {
|
||||
btn.addEventListener('click', () => removeDomain(btn.dataset.domain));
|
||||
});
|
||||
$importedFooter.textContent = entries.length ? entries.length + ' domains · ' + entries.reduce((sum, entry) => sum + entry[1], 0).toLocaleString() + ' cookies imported' : '';
|
||||
$importedDomains.innerHTML = entries.length ? entries.map(([domain, count]) => '<div class="domain-row"><span class="domain-name">' + escHtml(domain) + '</span><span class="domain-count">' + escHtml(count) + '</span><button class="btn-trash" data-domain="' + escHtml(domain) + '" aria-label="' + escHtml('Remove ' + domain) + '" title="Remove"' + (mutation ? ' disabled' : '') + '>🗑</button></div>').join('')
|
||||
: '<div class="imported-empty">No cookies imported yet</div>';
|
||||
$importedDomains.querySelectorAll('button').forEach(button => { button.onclick = () => removeDomain(button.dataset.domain); });
|
||||
}
|
||||
|
||||
// ─── Remove Domain ─────────────────────
|
||||
async function removeDomain(domain) {
|
||||
if (inflight['remove:' + domain]) return;
|
||||
inflight['remove:' + domain] = true;
|
||||
renderImported();
|
||||
|
||||
if (mutation) return;
|
||||
setMutationBusy(true);
|
||||
showBanner('Removing imported cookies for ' + domain + '.', 'info');
|
||||
try {
|
||||
await api('/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domains: [domain] }),
|
||||
});
|
||||
await api('/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domains: [domain] }) });
|
||||
delete importedSet[domain];
|
||||
renderImported();
|
||||
renderSourceDomains(); // update checkmarks
|
||||
} catch (err) {
|
||||
showBanner('Remove failed for ' + domain + ': ' + err.message, 'error',
|
||||
err.action === 'retry' ? () => removeDomain(domain) : null);
|
||||
showBanner('Removed imported cookies for ' + domain + '. Storage was not cleared.', 'info');
|
||||
} catch (error) {
|
||||
showBanner('Cookie removal did not complete. ' + errorMessage(error), 'error');
|
||||
} finally {
|
||||
delete inflight['remove:' + domain];
|
||||
renderImported();
|
||||
setMutationBusy(false);
|
||||
const first = $importedDomains.querySelector('button') || $sourceDomains.querySelector('button');
|
||||
if (first) first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Search ────────────────────────────
|
||||
$search.addEventListener('input', renderSourceDomains);
|
||||
|
||||
// ─── Start ─────────────────────────────
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
|
||||
import type { TabSession } from './tab-session';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, importCookies, importCookiesViaCdp, hasV20Cookies, listSupportedBrowserNames } from './cookie-import-browser';
|
||||
import { CookieImportError, cookieDomainMatches, findInstalledBrowsers, listSupportedBrowserNames } from './cookie-import-browser';
|
||||
import { generatePickerCode } from './cookie-picker-routes';
|
||||
import { formatCookieImportResult, parseCookieImportArgs, runCookieImport, validateCookieTarget } from './cookie-import-operation';
|
||||
import { validateCookieAuthOptions, validateCookieStorageSupport } from './cookie-auth-verification';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import { validateOutputPath, validateReadPath } from './path-security';
|
||||
import { guardScreenshotPath } from './screenshot-size-guard';
|
||||
@@ -687,80 +689,50 @@ export async function handleWriteCommand(
|
||||
}
|
||||
|
||||
case 'cookie-import-browser': {
|
||||
// Two modes:
|
||||
// 1. Direct CLI import: cookie-import-browser <browser> --domain <domain> [--profile <profile>]
|
||||
// Requires --domain (or --all to explicitly import everything).
|
||||
// 2. Open picker UI: cookie-import-browser [browser] (interactive domain selection)
|
||||
const browserArg = args[0];
|
||||
const domainIdx = args.indexOf('--domain');
|
||||
const profileIdx = args.indexOf('--profile');
|
||||
const hasAll = args.includes('--all');
|
||||
const profile = (profileIdx !== -1 && profileIdx + 1 < args.length) ? args[profileIdx + 1] : 'Default';
|
||||
|
||||
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
|
||||
// Direct import mode — scoped to specific domain
|
||||
const domain = args[domainIdx + 1];
|
||||
// Validate --domain against current page hostname to prevent cross-site cookie injection
|
||||
const pageHostname = new URL(page.url()).hostname;
|
||||
const normalizedDomain = domain.startsWith('.') ? domain.slice(1) : domain;
|
||||
if (normalizedDomain !== pageHostname && !pageHostname.endsWith('.' + normalizedDomain)) {
|
||||
throw new Error(`--domain "${domain}" does not match current page domain "${pageHostname}". Navigate to the target site first.`);
|
||||
const options = parseCookieImportArgs(args);
|
||||
const target = { page, url: page.url() };
|
||||
const authOptions = {
|
||||
identitySelector: process.env.GSTACK_COOKIE_AUTH_SELECTOR,
|
||||
expectedIdentity: process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY,
|
||||
};
|
||||
if (options.domains || options.all) {
|
||||
if (options.domains) {
|
||||
const targetUrl = validateCookieTarget(target);
|
||||
if (!options.domains.every(domain => cookieDomainMatches(targetUrl.hostname, '.' + domain))) {
|
||||
throw new CookieImportError('The requested cookie domain does not match the current page. Navigate to the target site first.', 'target_mismatch');
|
||||
}
|
||||
}
|
||||
const browser = browserArg || 'comet';
|
||||
let result = await importCookies(browser, [domain], profile);
|
||||
// If all cookies failed and v20 is detected, try CDP extraction
|
||||
if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, profile)) {
|
||||
result = await importCookiesViaCdp(browser, [domain], profile);
|
||||
const result = await runCookieImport(options, target, domains => bm.trackCookieImportDomains(domains), authOptions);
|
||||
const message = formatCookieImportResult(result);
|
||||
if (result.outcome === 'failed' || options.verifyAuth && !result.verification.verified) {
|
||||
throw new CookieImportError(message, 'cookie_import_incomplete');
|
||||
}
|
||||
if (result.cookies.length > 0) {
|
||||
await page.context().addCookies(result.cookies);
|
||||
bm.trackCookieImportDomains([domain]);
|
||||
}
|
||||
const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`];
|
||||
if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);
|
||||
return msg.join(' ');
|
||||
return message + (options.all ? ' Used --all: all source-profile cookie domains were selected.' : '');
|
||||
}
|
||||
|
||||
if (hasAll) {
|
||||
// Explicit all-cookies import — requires --all flag as a deliberate opt-in.
|
||||
// Imports every non-expired cookie domain from the browser.
|
||||
const browser = browserArg || 'comet';
|
||||
const { listDomains } = await import('./cookie-import-browser');
|
||||
const { domains } = listDomains(browser, profile);
|
||||
const allDomainNames = domains.map((d: any) => d.domain);
|
||||
if (allDomainNames.length === 0) {
|
||||
return `No cookies found in ${browser} (profile: ${profile})`;
|
||||
}
|
||||
const result = await importCookies(browser, allDomainNames, profile);
|
||||
if (result.cookies.length > 0) {
|
||||
await page.context().addCookies(result.cookies);
|
||||
bm.trackCookieImportDomains(allDomainNames);
|
||||
}
|
||||
const msg = [`Imported ${result.count} cookies across ${Object.keys(result.domainCounts).length} domains from ${browser}`];
|
||||
msg.push('(used --all: all browser cookies imported, consider --domain for tighter scoping)');
|
||||
if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);
|
||||
return msg.join(' ');
|
||||
}
|
||||
|
||||
// Picker UI mode — open in user's browser for interactive domain selection
|
||||
const port = bm.serverPort;
|
||||
if (!port) throw new Error('Server port not available');
|
||||
|
||||
if (!port) throw new CookieImportError('Server port not available', 'unavailable');
|
||||
const browsers = findInstalledBrowsers();
|
||||
if (browsers.length === 0) {
|
||||
throw new Error(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`);
|
||||
}
|
||||
|
||||
const code = generatePickerCode();
|
||||
if (browsers.length === 0) throw new CookieImportError(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`, 'not_installed');
|
||||
if (options.clearStorage || options.verifyAuth) validateCookieTarget(target);
|
||||
if (options.clearStorage) validateCookieStorageSupport(page);
|
||||
if (options.verifyAuth) validateCookieAuthOptions(authOptions);
|
||||
const code = generatePickerCode({
|
||||
target,
|
||||
browser: options.browser,
|
||||
profile: options.profile,
|
||||
clearStorage: options.clearStorage,
|
||||
verifyAuth: options.verifyAuth,
|
||||
});
|
||||
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker?code=${code}`;
|
||||
const openCommand = process.platform === 'darwin' ? ['open', pickerUrl]
|
||||
: process.platform === 'win32' ? ['cmd.exe', '/d', '/c', 'start', '', pickerUrl] : ['xdg-open', pickerUrl];
|
||||
try {
|
||||
Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore', windowsHide: true });
|
||||
} catch (err: any) {
|
||||
// open may fail on non-macOS or if 'open' binary is missing — URL is in the message below
|
||||
if (err?.code !== 'ENOENT' && !err?.message?.includes('spawn')) throw err;
|
||||
Bun.spawn(openCommand, { stdout: 'ignore', stderr: 'ignore', windowsHide: true });
|
||||
} catch {
|
||||
throw new CookieImportError('Could not open the cookie picker in a local browser. Retry from a desktop session.', 'picker_open_failed');
|
||||
}
|
||||
|
||||
return `Cookie picker opened at http://127.0.0.1:${port}/cookie-picker\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.\n\nTip: For scripted imports, use --domain <domain> to scope cookies to a single domain.`;
|
||||
return `Cookie picker opened at http://127.0.0.1:${port}/cookie-picker\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect the source profile and domains. Cookies copied does not mean sign-in verified.`;
|
||||
}
|
||||
|
||||
case 'style': {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, test, expect, afterAll, setDefaultTimeout } from 'bun:test';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
|
||||
// Every test here spawnSync's a `node` child; Windows CI cold-start (AV scan,
|
||||
// first-touch of node.exe) alone can blow bun's 5s default — observed 5,007ms
|
||||
@@ -204,6 +206,118 @@ describe('bun-polyfill', () => {
|
||||
expect(result.stdout.toString().trim()).toBe('1048576:0');
|
||||
}, 15000);
|
||||
|
||||
test('cancelled replay readers release inherited pipes after the direct child exits', () => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'polyfill-cancel-')));
|
||||
const marker = path.join(root, 'descendant.pid');
|
||||
const pidPath = path.join(root, 'spawned.pid');
|
||||
expect(fs.realpathSync(root)).toBe(root);
|
||||
try {
|
||||
const descendantScript = `
|
||||
const fs = require('node:fs');
|
||||
fs.writeSync(1, 'fixture-stdout');
|
||||
fs.writeSync(2, 'fixture-stderr');
|
||||
fs.writeFileSync(${JSON.stringify(marker + '.tmp')}, JSON.stringify({ pid: process.pid, stdout: true, stderr: true }));
|
||||
fs.renameSync(${JSON.stringify(marker + '.tmp')}, ${JSON.stringify(marker)});
|
||||
setInterval(() => {}, 1000);
|
||||
`;
|
||||
const childScript = `
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const descendant = spawn(process.execPath, ['-e', ${JSON.stringify(descendantScript)}],
|
||||
{ stdio: ['ignore', 'inherit', 'inherit'], windowsHide: true, detached: process.platform === 'win32' });
|
||||
fs.writeFileSync(${JSON.stringify(pidPath)}, String(descendant.pid));
|
||||
const deadline = Date.now() + 5000;
|
||||
const ready = () => {
|
||||
if (fs.existsSync(${JSON.stringify(marker)})) process.exit(0);
|
||||
if (descendant.exitCode !== null || Date.now() >= deadline) process.exit(1);
|
||||
setTimeout(ready, 10);
|
||||
};
|
||||
ready();
|
||||
`;
|
||||
const script = `
|
||||
const childProcess = require('node:child_process');
|
||||
const originalSpawn = childProcess.spawn;
|
||||
let direct;
|
||||
childProcess.spawn = (...args) => { direct = originalSpawn(...args); return direct; };
|
||||
require(${JSON.stringify(polyfillPath)});
|
||||
let stage = 'spawn';
|
||||
let directExitCode;
|
||||
let markerValid = false;
|
||||
let stdoutAck = false;
|
||||
let stderrAck = false;
|
||||
let descendantAlive = false;
|
||||
let checkErrorCode = null;
|
||||
(async () => {
|
||||
const proc = Bun.spawn([process.execPath, '-e', ${JSON.stringify(childScript)}],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
if (!direct) throw new Error('capture_missing');
|
||||
const stdout = proc.stdout.getReader();
|
||||
const stderr = proc.stderr.getReader();
|
||||
const stdoutRead = stdout.read();
|
||||
const stderrRead = stderr.read();
|
||||
stage = 'direct_exit';
|
||||
directExitCode = await new Promise((resolve, reject) => { direct.once('exit', resolve); direct.once('error', reject); });
|
||||
let readyPid;
|
||||
try {
|
||||
const fs = require('node:fs');
|
||||
const marker = JSON.parse(fs.readFileSync(${JSON.stringify(marker)}, 'utf8'));
|
||||
readyPid = marker.pid;
|
||||
markerValid = Number.isSafeInteger(readyPid) && readyPid > 0
|
||||
&& String(readyPid) === fs.readFileSync(${JSON.stringify(pidPath)}, 'utf8');
|
||||
stdoutAck = marker.stdout === true;
|
||||
stderrAck = marker.stderr === true;
|
||||
} catch (error) { checkErrorCode = typeof error.code === 'string' ? error.code : 'invalid_marker'; }
|
||||
if (markerValid) {
|
||||
try { process.kill(readyPid, 0); descendantAlive = true; }
|
||||
catch (error) { checkErrorCode = typeof error.code === 'string' ? error.code : 'liveness_error'; }
|
||||
}
|
||||
if (!markerValid || !stdoutAck || !stderrAck || !descendantAlive) throw new Error('descendant_not_ready');
|
||||
stage = 'pending_check';
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
let settled = false;
|
||||
proc.exited.then(() => { settled = true; });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (settled) throw new Error('Inherited pipes unexpectedly closed before cancellation');
|
||||
stage = 'cancel';
|
||||
await Promise.all([stdout.cancel(), stderr.cancel()]);
|
||||
const reads = await Promise.all([stdoutRead, stderrRead]);
|
||||
stage = 'await_exited';
|
||||
let timer;
|
||||
const code = await Promise.race([proc.exited, new Promise((_, reject) =>
|
||||
{ timer = setTimeout(() => reject(new Error('cancel did not settle exited')), 5000); })])
|
||||
.finally(() => clearTimeout(timer));
|
||||
console.log(JSON.stringify({ code, directExitCode, reads: reads.map(read => read.done), descendantAlive: (() => {
|
||||
try { process.kill(JSON.parse(require('node:fs').readFileSync(${JSON.stringify(marker)}, 'utf8')).pid, 0); return true; }
|
||||
catch { return false; }
|
||||
})() }));
|
||||
})().catch(error => {
|
||||
const reason = error.message === 'Inherited pipes unexpectedly closed before cancellation' ? 'early_pipes'
|
||||
: error.message === 'cancel did not settle exited' ? 'cancel_stalled'
|
||||
: error.message === 'capture_missing' ? 'capture_missing'
|
||||
: error.message === 'descendant_not_ready' ? 'descendant_not_ready' : 'unexpected';
|
||||
console.error(JSON.stringify({ stage, reason, directExitCode, markerValid, stdoutAck, stderrAck,
|
||||
descendantAlive, checkErrorCode, errorCode: typeof error.code === 'string' ? error.code : null }));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
`;
|
||||
const result = Bun.spawnSync(['node', '-e', script], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
||||
const errorOutput = result.stderr.toString().trim();
|
||||
let diagnostic: object | null = null;
|
||||
try { if (errorOutput) diagnostic = JSON.parse(errorOutput); }
|
||||
catch { diagnostic = { stage: 'unframed', stderrBytes: Buffer.byteLength(errorOutput) }; }
|
||||
expect({ exitCode: result.exitCode, diagnostic }).toEqual({ exitCode: 0, diagnostic: null });
|
||||
expect(JSON.parse(result.stdout.toString())).toEqual({ code: 0, directExitCode: 0, reads: [true, true], descendantAlive: true });
|
||||
} finally {
|
||||
if (fs.existsSync(pidPath)) {
|
||||
const pidText = fs.readFileSync(pidPath, 'utf8');
|
||||
if (/^[1-9]\d*$/.test(pidText)) {
|
||||
try { process.kill(Number(pidText)); } catch (error: any) { if (error.code !== 'ESRCH') throw error; }
|
||||
}
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Bun.serve creates an HTTP server that responds', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require(${JSON.stringify(polyfillPath)});
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { chromium, errors, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { CookieImportError } from '../src/cookie-import-browser';
|
||||
import { clearCookieTargetStorage, validateCookieAuthOptions, validateCookieStorageSupport, verifyCookieAuthentication } from '../src/cookie-auth-verification';
|
||||
|
||||
const identity = 'Synthetic Account 472';
|
||||
const options = { identitySelector: '.identity', expectedIdentity: identity, timeoutMs: 400 };
|
||||
const unitOrigin = 'https://fixture.test';
|
||||
const credentialUrl = new URL(unitOrigin);
|
||||
credentialUrl.username = 'fixture-user';
|
||||
credentialUrl.password = 'fixture';
|
||||
credentialUrl.searchParams.set('token', 'synthetic-token');
|
||||
|
||||
function mockPage() {
|
||||
const evaluateAll = mock(async () => 'verified');
|
||||
const request = { url: () => `${unitOrigin}/protected`, redirectedFrom: () => null };
|
||||
const events = new EventEmitter();
|
||||
const frame = {};
|
||||
const storage = { engine: 'chromium', now: 100, deadline: 0, origin: unitOrigin, url: request.url(),
|
||||
localClear: mock(() => {}), sessionClear: mock(() => {}), nativeNow: mock(() => storage.now) };
|
||||
const cdpEvents = new EventEmitter();
|
||||
const cdp = {
|
||||
on: cdpEvents.on.bind(cdpEvents),
|
||||
off: cdpEvents.off.bind(cdpEvents),
|
||||
detach: mock(async () => {}),
|
||||
send: mock(async (method: string, params?: any): Promise<any> => {
|
||||
if (method === 'Page.getFrameTree') return { frameTree: { frame: { id: 'fixture-frame' } } };
|
||||
if (method === 'Page.createIsolatedWorld') {
|
||||
cdpEvents.emit('Runtime.executionContextCreated', { context: { name: params.worldName, id: 7,
|
||||
uniqueId: 'fixture-unique-world', auxData: { frameId: 'fixture-frame', isDefault: false } } });
|
||||
return { executionContextId: 7 };
|
||||
}
|
||||
if (method === 'Runtime.evaluate') return { result: { value: storage.nativeNow() } };
|
||||
if (method === 'Runtime.callFunctionOn') {
|
||||
const arg = params.arguments[0].value;
|
||||
storage.deadline = arg.deadline;
|
||||
const value = runInNewContext(`(${params.functionDeclaration})(arg)`, { arg,
|
||||
performance: { now: storage.nativeNow }, Date: { now: () => 0 },
|
||||
location: { origin: storage.origin, href: storage.url },
|
||||
localStorage: { clear: storage.localClear }, sessionStorage: { clear: storage.sessionClear },
|
||||
});
|
||||
return { result: { value } };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
const context = {
|
||||
browser: () => ({ browserType: () => ({ name: () => storage.engine }) }),
|
||||
newCDPSession: mock(async () => cdp),
|
||||
};
|
||||
const page = {
|
||||
isClosed: mock(() => false),
|
||||
url: mock(() => `${unitOrigin}/protected`),
|
||||
reload: mock(async () => ({ status: () => 200, url: request.url, request: () => request })),
|
||||
locator: mock(() => ({ filter: () => ({ evaluateAll }) })),
|
||||
evaluate: mock(async () => 'cleared'),
|
||||
context: () => context,
|
||||
mainFrame: () => frame,
|
||||
on: events.on.bind(events),
|
||||
off: events.off.bind(events),
|
||||
};
|
||||
return { page: page as unknown as Page, calls: page, evaluateAll, storage, cdp, context, events, frame, cdpEvents };
|
||||
}
|
||||
|
||||
describe('cookie auth configuration and bounded operations', () => {
|
||||
for (const [index, invalid] of [undefined, {}, { identitySelector: '.identity' }, { expectedIdentity: identity },
|
||||
{ identitySelector: ' ', expectedIdentity: identity }, { identitySelector: '.identity', expectedIdentity: '\n\t' },
|
||||
{ identitySelector: 7, expectedIdentity: identity }, { identitySelector: '.identity', expectedIdentity: [] }].entries()) {
|
||||
test(`rejects incomplete configuration case ${index + 1}`, () => {
|
||||
try {
|
||||
validateCookieAuthOptions(invalid as any);
|
||||
throw new Error('Expected configuration rejection');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect((error as CookieImportError).code).toBe('verification_not_configured');
|
||||
expect(String(error)).not.toContain(identity);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const timeoutMs of [0, -1, NaN, Infinity, '200']) {
|
||||
test(`rejects invalid timeout ${String(timeoutMs)}`, async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect(() => validateCookieAuthOptions({ ...options, timeoutMs } as any)).toThrow(CookieImportError);
|
||||
expect(await verifyCookieAuthentication(page, { ...options, timeoutMs } as any, unitOrigin))
|
||||
.toEqual({ verified: false, reason: 'invalid_configuration' });
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test('requires explicit configuration without reloading or touching storage', async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect(await verifyCookieAuthentication(page, {}, unitOrigin)).toEqual({ verified: false, reason: 'not_configured' });
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect(calls.locator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('caps an oversized requested timeout at fifteen seconds', async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect((await verifyCookieAuthentication(page, { ...options, timeoutMs: 60_000 }, unitOrigin)).verified).toBe(true);
|
||||
expect(calls.reload.mock.calls[0][0].timeout).toBeGreaterThan(0);
|
||||
expect(calls.reload.mock.calls[0][0].timeout).toBeLessThanOrEqual(15_000);
|
||||
});
|
||||
|
||||
test('does not run assertions after a timed-out reload eventually resolves', async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
let release!: (response: any) => void;
|
||||
calls.reload.mockImplementation(() => new Promise(resolve => { release = resolve; }));
|
||||
const result = await verifyCookieAuthentication(page, { ...options, timeoutMs: 30 }, unitOrigin);
|
||||
expect(result).toEqual({ verified: false, reason: 'timeout' });
|
||||
release({ status: () => 200 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(evaluateAll).not.toHaveBeenCalled();
|
||||
expect(calls.locator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const stage of ['reload', 'selector']) {
|
||||
test(`classifies Playwright's ${stage} timeout before the outer timer expires`, async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
const fail = async () => { throw new errors.TimeoutError(`${credentialUrl.href} ${identity}`); };
|
||||
if (stage === 'reload') calls.reload.mockImplementation(fail);
|
||||
else evaluateAll.mockImplementation(fail);
|
||||
const clock = spyOn(performance, 'now').mockReturnValue(0);
|
||||
try {
|
||||
const result = await verifyCookieAuthentication(page, options, unitOrigin);
|
||||
expect(result).toEqual({ verified: false, reason: 'timeout', ...(stage === 'selector' ? { status: 200 } : {}) });
|
||||
expect(JSON.stringify(result)).not.toContain(identity);
|
||||
expect(JSON.stringify(result)).not.toContain(credentialUrl.href);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('does not classify generic error text or a copied name as a Playwright timeout', async () => {
|
||||
const { page, calls } = mockPage();
|
||||
calls.reload.mockImplementation(async () => { throw Object.assign(new Error('synthetic timeout exceeded'), { name: 'TimeoutError' }); });
|
||||
expect(await verifyCookieAuthentication(page, options, unitOrigin)).toEqual({ verified: false, reason: 'verification_failed' });
|
||||
});
|
||||
|
||||
test('shares one deadline between reload and the identity assertion', async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
const response = await calls.reload();
|
||||
let now = 0;
|
||||
let expire!: () => void;
|
||||
const clock = spyOn(performance, 'now').mockImplementation(() => now);
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void, timeout: number) => {
|
||||
expect(timeout).toBe(100);
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
try {
|
||||
calls.reload.mockImplementation(async () => {
|
||||
now = 70;
|
||||
return response;
|
||||
});
|
||||
evaluateAll.mockImplementation(() => new Promise(() => {}));
|
||||
const result = verifyCookieAuthentication(page, { ...options, timeoutMs: 100 }, unitOrigin);
|
||||
await Promise.resolve();
|
||||
expect(evaluateAll).toHaveBeenCalledTimes(1);
|
||||
now = 100;
|
||||
expire();
|
||||
expect(await result).toEqual({ verified: false, reason: 'timeout', status: 200 });
|
||||
expect(timer).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns only safe reasons for secret-bearing reload and selector failures', async () => {
|
||||
for (const stage of ['reload', 'selector']) {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
const fail = async () => { throw new Error(`${credentialUrl.href} ${identity}`); };
|
||||
if (stage === 'reload') calls.reload.mockImplementation(fail);
|
||||
else evaluateAll.mockImplementation(fail);
|
||||
const result = await verifyCookieAuthentication(page, options, unitOrigin);
|
||||
expect(result.reason).toBe('verification_failed');
|
||||
expect(JSON.stringify(result)).not.toMatch(/synthetic-token|fixture|Synthetic Account/);
|
||||
}
|
||||
});
|
||||
|
||||
test('requires a successful reload response even with a positive assertion', async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
calls.reload.mockImplementation(async () => null as any);
|
||||
expect(await verifyCookieAuthentication(page, options, unitOrigin)).toEqual({ verified: false, reason: 'no_response' });
|
||||
expect(evaluateAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects a closed or changed target before reload', async () => {
|
||||
for (const state of ['closed', 'changed']) {
|
||||
const { page, calls } = mockPage();
|
||||
if (state === 'closed') calls.isClosed.mockReturnValue(true);
|
||||
else calls.url.mockReturnValue('https://other.test/protected');
|
||||
expect((await verifyCookieAuthentication(page, options, unitOrigin)).reason).toBe(`target_${state}`);
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
for (const expectedOrigin of ['about:blank', 'file:///tmp/fixture', 'data:text/html,fixture', 'invalid',
|
||||
'https://fixture.test/path', credentialUrl.href, 'https://fixture.test/?token=synthetic-token']) {
|
||||
test(`rejects a non-origin target ${expectedOrigin.split(':')[0]}`, async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect(await verifyCookieAuthentication(page, options, expectedOrigin)).toEqual({ verified: false, reason: 'invalid_target' });
|
||||
expect(await clearCookieTargetStorage(page, expectedOrigin).then(() => null, error => error)).toMatchObject({ code: 'invalid_target' });
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test('storage reset sanitizes even a typed exception with a secret-bearing message', async () => {
|
||||
const { page, cdp } = mockPage();
|
||||
cdp.send.mockImplementation(async () => {
|
||||
throw new CookieImportError(`synthetic-token ${identity}`, 'target_changed');
|
||||
});
|
||||
try {
|
||||
await clearCookieTargetStorage(page, unitOrigin);
|
||||
throw new Error('Expected reset failure');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect((error as CookieImportError).code).toBe('storage_reset_failed');
|
||||
expect(String(error)).not.toMatch(/synthetic-token|Synthetic Account/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a reset dispatched after its timeout does no destructive work', async () => {
|
||||
const { page, cdp, storage } = mockPage();
|
||||
let runLate!: () => void;
|
||||
let expire!: () => void;
|
||||
const dispatched = Promise.withResolvers<void>();
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method !== 'Runtime.callFunctionOn') return send(method, params);
|
||||
return new Promise(resolve => {
|
||||
runLate = () => {
|
||||
storage.now = params.arguments[0].value.deadline;
|
||||
resolve(send(method, params));
|
||||
};
|
||||
dispatched.resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await dispatched.promise;
|
||||
expire();
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
runLate();
|
||||
await Promise.resolve();
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not clear session storage if the local-storage operation consumed the deadline', async () => {
|
||||
const { page, storage } = mockPage();
|
||||
storage.nativeNow.mockImplementation(() => storage.localClear.mock.calls.length ? storage.deadline : storage.now);
|
||||
expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
expect(storage.localClear).toHaveBeenCalledTimes(1);
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const engine of ['firefox', 'webkit']) {
|
||||
test(`rejects reset support before protocol work on ${engine} without disabling authentication checks`, async () => {
|
||||
const { page, storage, context, calls } = mockPage();
|
||||
storage.engine = engine;
|
||||
expect(() => validateCookieStorageSupport(page)).toThrow(CookieImportError);
|
||||
expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'storage_reset_unsupported' });
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect((await verifyCookieAuthentication(page, options, unitOrigin)).verified).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('Chromium support preflight is side-effect free', () => {
|
||||
const { page, context, calls, cdp } = mockPage();
|
||||
validateCookieStorageSupport(page);
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
expect(cdp.send).not.toHaveBeenCalled();
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const phase of ['attachment', 'Page.getFrameTree', 'Runtime.enable', 'Page.createIsolatedWorld', 'Runtime.evaluate']) {
|
||||
test(`does not dispatch destructive work when timeout wins during ${phase}`, async () => {
|
||||
const { page, cdp, context, storage } = mockPage();
|
||||
const reached = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
let expire!: () => void;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
if (phase === 'attachment') context.newCDPSession.mockImplementation(async () => {
|
||||
reached.resolve();
|
||||
await release.promise;
|
||||
return cdp;
|
||||
});
|
||||
else cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method === phase) { reached.resolve(); await release.promise; }
|
||||
return send(method, params);
|
||||
});
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await reached.promise;
|
||||
expire();
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
release.resolve();
|
||||
for (let tick = 0; tick < 10; tick++) await Promise.resolve();
|
||||
expect(cdp.send.mock.calls.some(([method]) => method === 'Runtime.callFunctionOn')).toBe(false);
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
expect(cdp.detach).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release.resolve();
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('calibrates a conservative isolated deadline using host time after the clock response', async () => {
|
||||
const { page, cdp, storage } = mockPage();
|
||||
let now = 0;
|
||||
const clock = spyOn(performance, 'now').mockImplementation(() => now);
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
const result = await send(method, params);
|
||||
if (method === 'Runtime.evaluate') { now = 4_000; storage.now = 4_100; }
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
await clearCookieTargetStorage(page, unitOrigin);
|
||||
const sample = cdp.send.mock.calls.find(([method]) => method === 'Runtime.evaluate')![1];
|
||||
const mutation = cdp.send.mock.calls.find(([method]) => method === 'Runtime.callFunctionOn')![1];
|
||||
expect(mutation.arguments[0].value.deadline).toBe(11_100);
|
||||
expect(sample.uniqueContextId).toBe('fixture-unique-world');
|
||||
expect(mutation.uniqueContextId).toBe(sample.uniqueContextId);
|
||||
expect(mutation.executionContextId).toBeUndefined();
|
||||
expect(storage.localClear).toHaveBeenCalledTimes(1);
|
||||
expect(storage.sessionClear).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not let pending CDP detach prolong the reported timeout', async () => {
|
||||
const { page, cdp, storage, events, cdpEvents } = mockPage();
|
||||
const detaching = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
let expire!: () => void;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
cdp.detach.mockImplementation(async () => { detaching.resolve(); await release.promise; });
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await detaching.promise;
|
||||
expire();
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
expect(storage.localClear).toHaveBeenCalledTimes(1);
|
||||
expect(storage.sessionClear).toHaveBeenCalledTimes(1);
|
||||
expect(events.listenerCount('framenavigated')).toBe(0);
|
||||
expect(cdpEvents.listenerCount('Runtime.executionContextCreated')).toBe(0);
|
||||
} finally {
|
||||
release.resolve();
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('aborts same-URL target navigation before destructive dispatch', async () => {
|
||||
const { page, cdp, storage, events, frame } = mockPage();
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
events.emit('framenavigated', frame);
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(cdp.send.mock.calls.some(([method]) => method === 'Runtime.callFunctionOn')).toBe(false);
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const event of ['framenavigated', 'close']) {
|
||||
test(`settles ${event} cancellation without waiting for a stalled CDP operation`, async () => {
|
||||
const { page, cdp, events, frame, storage } = mockPage();
|
||||
const reached = Promise.withResolvers<void>();
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method === 'Runtime.callFunctionOn') { reached.resolve(); return new Promise(() => {}); }
|
||||
return send(method, params);
|
||||
});
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await reached.promise;
|
||||
events.emit(event, frame);
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
expect(cdp.detach).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const changed of ['origin', 'url']) {
|
||||
test(`checks the captured ${changed} inside the isolated destructive operation`, async () => {
|
||||
const { page, cdp, storage } = mockPage();
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method === 'Runtime.callFunctionOn') storage[changed] = changed === 'origin' ? 'https://other.test' : `${unitOrigin}/other-path`;
|
||||
return send(method, params);
|
||||
});
|
||||
expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('cookie authentication and storage with isolated Chromium profiles', () => {
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let otherServer: ReturnType<typeof Bun.serve>;
|
||||
let origin: string;
|
||||
let otherOrigin: string;
|
||||
let releaseSlowResponse: (() => void) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const html = (body: string, status = 200) => new Response(body, { status, headers: { 'Content-Type': 'text/html' } });
|
||||
const positive = `<div class="identity">${identity}</div>`;
|
||||
otherServer = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
|
||||
if (new URL(request.url).pathname === '/back') return Response.redirect(`${origin}/identity`, 302);
|
||||
return html(positive);
|
||||
} });
|
||||
otherOrigin = `http://127.0.0.1:${otherServer.port}`;
|
||||
server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/slow') return new Promise<Response>(resolve => {
|
||||
releaseSlowResponse = () => resolve(html(positive));
|
||||
});
|
||||
if (url.pathname === '/protected') {
|
||||
if (!request.headers.get('cookie')?.includes('fixture_session=approved')) return Response.redirect(`${origin}/login`, 302);
|
||||
return html(positive);
|
||||
}
|
||||
if (url.pathname === '/public-login') return html('<form><input name="email"><button>Sign in</button></form>');
|
||||
if (url.pathname === '/wrong') return html(`<div class="identity">${identity} extra account</div>`);
|
||||
if (url.pathname === '/normalized') return html('<div class="identity">\n Synthetic Account\n 472 </div>');
|
||||
if (url.pathname === '/hidden') return html(`<div class="identity" hidden>${identity}</div>`);
|
||||
if (url.pathname === '/duplicate') return html(positive + positive);
|
||||
if (url.pathname === '/one-visible') return html(positive + `<div class="identity" hidden>${identity}</div>`);
|
||||
if (url.pathname === '/hidden-child') return html(`<div class="identity">${identity}<span hidden>not visible</span></div>`);
|
||||
if (url.pathname === '/delayed') return html(`<script>setTimeout(() => document.body.innerHTML = ${JSON.stringify(positive)}, 100)</script>`);
|
||||
if (url.pathname === '/login' && url.searchParams.has('continue')) return Response.redirect(`${origin}/identity`, 302);
|
||||
if (url.pathname === '/login-hop') return Response.redirect(`${origin}/login?continue=1`, 302);
|
||||
if (url.pathname === '/redirect-cross') return Response.redirect(`${otherOrigin}/identity`, 302);
|
||||
if (url.pathname === '/roundtrip') return Response.redirect(`${otherOrigin}/back`, 302);
|
||||
if (url.pathname.startsWith('/status/')) return html(positive, Number(url.pathname.split('/')[2]));
|
||||
return html(positive);
|
||||
} });
|
||||
origin = `http://127.0.0.1:${server.port}`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.close();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
server?.stop(true);
|
||||
otherServer?.stop(true);
|
||||
});
|
||||
|
||||
test('verifies a reloaded protected page only with an exact visible identity and synthetic session', async () => {
|
||||
await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]);
|
||||
await page.goto(`${origin}/protected`);
|
||||
expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: true, reason: 'verified', status: 200 });
|
||||
});
|
||||
|
||||
for (const path of ['/normalized', '/one-visible', '/hidden-child', '/delayed']) {
|
||||
test(`accepts one normalized visible identity at ${path}`, async () => {
|
||||
await page.goto(`${origin}${path}`);
|
||||
const result = await verifyCookieAuthentication(page, { ...options, expectedIdentity: ` \n${identity} `, timeoutMs: 1000 }, origin);
|
||||
expect(result).toEqual({ verified: true, reason: 'verified', status: 200 });
|
||||
expect(JSON.stringify(result)).not.toContain(identity);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [path, reason] of [
|
||||
['/wrong', 'identity_mismatch'], ['/hidden', 'identity_missing'], ['/duplicate', 'identity_ambiguous'],
|
||||
['/public-login', 'identity_missing'], ['/login', 'login_redirect'], ['/sign-in', 'login_redirect'],
|
||||
['/account/LOGIN', 'login_redirect'], ['/login-hop', 'login_redirect'],
|
||||
]) {
|
||||
test(`rejects ${path} with a safe ${reason} result`, async () => {
|
||||
await page.goto(`${origin}${path === '/login-hop' ? '/identity' : path}`);
|
||||
if (path === '/login-hop') await page.evaluate(() => history.replaceState({}, '', '/login-hop'));
|
||||
const result = await verifyCookieAuthentication(page, options, origin);
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.reason).toBe(reason);
|
||||
expect(JSON.stringify(result)).not.toMatch(/Synthetic Account|127\.0\.0\.1/);
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects an unauthenticated 200 login redirect even when login markup contains the expected identity', async () => {
|
||||
await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]);
|
||||
await page.goto(`${origin}/protected`);
|
||||
await context.clearCookies();
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('login_redirect');
|
||||
});
|
||||
|
||||
for (const status of [401, 403, 500, 503]) {
|
||||
test(`rejects HTTP ${status} even with a positive identity`, async () => {
|
||||
await page.goto(`${origin}/status/${status}`);
|
||||
expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: false, reason: 'http_error', status });
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects a cross-origin redirect and a redirect that comes back to the original origin', async () => {
|
||||
for (const destination of ['/redirect-cross', '/roundtrip']) {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(path => history.replaceState({}, '', path), destination);
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects same-origin navigation during a delayed identity assertion', async () => {
|
||||
await page.goto(`${origin}/public-login`);
|
||||
const evaluateAll = page.locator.bind(page);
|
||||
const locator = spyOn(page, 'locator').mockImplementation((selector: string) => {
|
||||
const value = evaluateAll(selector);
|
||||
const filter = value.filter.bind(value);
|
||||
spyOn(value, 'filter').mockImplementation((filterOptions: any) => {
|
||||
const filtered = filter(filterOptions);
|
||||
const evaluate = filtered.evaluateAll.bind(filtered);
|
||||
spyOn(filtered, 'evaluateAll').mockImplementation(async (...args: any[]) => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
return evaluate(...args as [any, any]);
|
||||
});
|
||||
return filtered;
|
||||
});
|
||||
return value;
|
||||
});
|
||||
try {
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed');
|
||||
} finally {
|
||||
locator.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('times out an actual Chromium reload without asserting a login', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => history.replaceState({}, '', '/slow'));
|
||||
try {
|
||||
expect(await verifyCookieAuthentication(page, { ...options, timeoutMs: 100 }, origin))
|
||||
.toEqual({ verified: false, reason: 'timeout' });
|
||||
} finally {
|
||||
releaseSlowResponse?.();
|
||||
}
|
||||
});
|
||||
|
||||
test('sanitizes invalid selector errors and handles a closed target', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
const result = await verifyCookieAuthentication(page, { ...options, identitySelector: '[synthetic-token' }, origin);
|
||||
expect(result.reason).toBe('verification_failed');
|
||||
expect(JSON.stringify(result)).not.toContain('synthetic-token');
|
||||
await page.close();
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_closed');
|
||||
expect(await clearCookieTargetStorage(page, origin).then(() => null, error => error)).toMatchObject({ code: 'target_closed' });
|
||||
});
|
||||
|
||||
test('preserves all storage during an explicit authentication check', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
});
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).verified).toBe(true);
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')]))
|
||||
.toEqual(['original', 'original']);
|
||||
});
|
||||
|
||||
test('clears only exact-origin local storage and target-tab session storage', async () => {
|
||||
const sibling = await context.newPage();
|
||||
const other = await context.newPage();
|
||||
const otherHost = await context.newPage();
|
||||
const independentProfile = await browser.newContext();
|
||||
try {
|
||||
const independent = await independentProfile.newPage();
|
||||
const pages = [page, sibling, other, otherHost, independent];
|
||||
await Promise.all(pages.map((tab, index) => tab.goto(index === 2 ? `${otherOrigin}/identity`
|
||||
: index === 3 ? `http://localhost:${server.port}/identity` : `${origin}/identity`)));
|
||||
for (const tab of pages) await tab.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
});
|
||||
await clearCookieTargetStorage(page, origin);
|
||||
const storage = await Promise.all(pages.map(tab => tab.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])));
|
||||
expect(storage).toEqual([[null, null], [null, 'original'], ['original', 'original'], ['original', 'original'], ['original', 'original']]);
|
||||
} finally {
|
||||
await independentProfile.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a different scheme, host, or port without clearing storage', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => localStorage.setItem('local', 'original'));
|
||||
for (const expectedOrigin of [origin.replace('http:', 'https:'), otherOrigin, `http://localhost:${server.port}`]) {
|
||||
expect(await clearCookieTargetStorage(page, expectedOrigin).then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(await page.evaluate(() => localStorage.getItem('local'))).toBe('original');
|
||||
}
|
||||
});
|
||||
|
||||
test('checks target origin and URL inside the storage operation after dispatch races', async () => {
|
||||
for (const destination of [`${otherOrigin}/identity`, `${origin}/other-path`]) {
|
||||
const target = await context.newPage();
|
||||
await target.goto(`${origin}/identity`);
|
||||
const other = await context.newPage();
|
||||
await other.goto(destination);
|
||||
await other.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
});
|
||||
const connect = context.newCDPSession.bind(context);
|
||||
let navigation: Promise<void> | undefined;
|
||||
const raced = spyOn(context, 'newCDPSession').mockImplementation(async attachedTarget => {
|
||||
const session = await connect(attachedTarget);
|
||||
const send = session.send.bind(session);
|
||||
spyOn(session, 'send').mockImplementation(async (method: any, params: any) => {
|
||||
if (method === 'Runtime.callFunctionOn') {
|
||||
navigation = (async () => {
|
||||
await target.goto(destination);
|
||||
await target.evaluate(() => sessionStorage.setItem('session', 'original'));
|
||||
})();
|
||||
await navigation;
|
||||
}
|
||||
return send(method, params);
|
||||
});
|
||||
return session;
|
||||
});
|
||||
try {
|
||||
const error = await clearCookieTargetStorage(target, origin).then(() => null, error => error);
|
||||
expect(error).toMatchObject({ code: 'target_changed' });
|
||||
expect(navigation).toBeDefined();
|
||||
await navigation;
|
||||
} finally {
|
||||
raced.mockRestore();
|
||||
}
|
||||
expect(await target.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']);
|
||||
await target.close();
|
||||
await other.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('reports a partial reset safely when session storage clearing fails', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
Object.defineProperty(sessionStorage, 'clear', { value() { throw new Error('synthetic-token'); } });
|
||||
});
|
||||
const connect = context.newCDPSession.bind(context);
|
||||
const failing = spyOn(context, 'newCDPSession').mockImplementation(async target => {
|
||||
const session = await connect(target);
|
||||
const send = session.send.bind(session);
|
||||
spyOn(session, 'send').mockImplementation(async (method: any, params: any) => {
|
||||
if (method === 'Runtime.callFunctionOn') await send('Runtime.evaluate', {
|
||||
uniqueContextId: params.uniqueContextId,
|
||||
expression: "Object.defineProperty(sessionStorage, 'clear', { value() { throw new Error('synthetic-token'); } })",
|
||||
returnByValue: true, silent: true,
|
||||
});
|
||||
return send(method, params);
|
||||
});
|
||||
return session;
|
||||
});
|
||||
try {
|
||||
await clearCookieTargetStorage(page, origin);
|
||||
throw new Error('Expected reset failure');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect((error as CookieImportError).code).toBe('storage_reset_failed');
|
||||
expect(String(error)).not.toContain('synthetic-token');
|
||||
} finally {
|
||||
failing.mockRestore();
|
||||
}
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual([null, 'original']);
|
||||
});
|
||||
|
||||
test('uses a native isolated clock despite main-world Date and performance tampering', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
Date.now = () => 0;
|
||||
Object.defineProperty(performance, 'now', { value: () => 0 });
|
||||
Object.defineProperty(performance, 'timeOrigin', { value: 0 });
|
||||
});
|
||||
await clearCookieTargetStorage(page, origin);
|
||||
expect(await page.evaluate(() => [Date.now(), performance.now(), performance.timeOrigin])).toEqual([0, 0, 0]);
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual([null, null]);
|
||||
});
|
||||
|
||||
test('a real late isolated dispatch cannot clear storage after the host timeout even with forged page clocks', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
Date.now = () => 0;
|
||||
Object.defineProperty(performance, 'now', { value: () => 0 });
|
||||
Object.defineProperty(performance, 'timeOrigin', { value: 0 });
|
||||
});
|
||||
const connect = context.newCDPSession.bind(context);
|
||||
const observer = await connect(page);
|
||||
await observer.send('Runtime.enable');
|
||||
const tree = await observer.send('Page.getFrameTree');
|
||||
await observer.send('Page.createIsolatedWorld', { frameId: tree.frameTree.frame.id, worldName: 'gstack-cookie-storage-reset', grantUniveralAccess: false });
|
||||
const dispatched = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<any>();
|
||||
let delayedParameters: any;
|
||||
const delaying = spyOn(context, 'newCDPSession').mockImplementation(async target => {
|
||||
const session = await connect(target);
|
||||
const send = session.send.bind(session);
|
||||
spyOn(session, 'send').mockImplementation(async (method: any, params: any) => {
|
||||
if (method === 'Runtime.callFunctionOn') {
|
||||
delayedParameters = params;
|
||||
dispatched.resolve();
|
||||
return release.promise;
|
||||
}
|
||||
return send(method, params);
|
||||
});
|
||||
return session;
|
||||
});
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, origin);
|
||||
await dispatched.promise;
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']);
|
||||
const late = await observer.send('Runtime.callFunctionOn', delayedParameters);
|
||||
expect(late.result.value).toBe('storage_reset_timeout');
|
||||
expect(await page.evaluate(() => [Date.now(), performance.now(), performance.timeOrigin])).toEqual([0, 0, 0]);
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']);
|
||||
release.resolve(late);
|
||||
} finally {
|
||||
release.resolve({ result: { value: 'storage_reset_timeout' } });
|
||||
delaying.mockRestore();
|
||||
await observer.detach();
|
||||
}
|
||||
}, 25_000);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { CookieImportError, importCookies } from '../src/cookie-import-browser';
|
||||
|
||||
let home: string;
|
||||
let homedir: ReturnType<typeof spyOn>;
|
||||
let platform: PropertyDescriptor;
|
||||
let spawn: typeof Bun.spawn;
|
||||
|
||||
function fixture(encrypted: Buffer, hostPlatform: 'darwin' | 'linux' | 'win32' = 'darwin') {
|
||||
const folder = hostPlatform === 'darwin' ? 'Library/Application Support/Dia/User Data/Default'
|
||||
: hostPlatform === 'linux' ? '.config/chromium/Default' : 'AppData/Local/Chromium/User Data/Default';
|
||||
const dir = path.join(home, folder);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(home) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run("INSERT INTO cookies VALUES ('.fixture.test', 'session', '', ?, '/', 0, 1, 1, 0, 1)", [encrypted]);
|
||||
db.close();
|
||||
}
|
||||
|
||||
function cbcCookie(password: string, prefix = 'v10') {
|
||||
const key = crypto.pbkdf2Sync(password, 'saltysalt', prefix === 'v11' ? 1 : 1003, 16, 'sha1');
|
||||
const cipher = crypto.createCipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20));
|
||||
return Buffer.concat([Buffer.from(prefix), cipher.update(Buffer.concat([Buffer.alloc(32), Buffer.from('fixture-value')])), cipher.final()]);
|
||||
}
|
||||
|
||||
function pipe(content?: string): { stream: ReadableStream<Uint8Array>; close(): void; cancelled(): boolean } {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array>;
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value;
|
||||
if (content) controller.enqueue(Buffer.from(content));
|
||||
},
|
||||
cancel() { cancelled = true; },
|
||||
});
|
||||
return { stream, close: () => controller.close(), cancelled: () => cancelled };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-credential-deadline-'));
|
||||
homedir = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
spawn = Bun.spawn;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Bun.spawn = spawn;
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
homedir.mockRestore();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('credential subprocess whole-operation deadline', () => {
|
||||
for (const held of ['stdout', 'stderr'] as const) {
|
||||
test(`rejects an exited Keychain process when ${held} remains open`, async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(Buffer.from('v10synthetic'));
|
||||
const stdout = pipe(held === 'stdout' ? 'fixture-password' : undefined);
|
||||
const stderr = pipe(held === 'stderr' ? 'private-error-detail' : undefined);
|
||||
if (held !== 'stdout') stdout.close();
|
||||
if (held !== 'stderr') stderr.close();
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream, kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 19 as any; });
|
||||
try {
|
||||
const result = importCookies('dia', ['fixture.test']);
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
await expect(result).rejects.toMatchObject({ code: 'keychain_timeout', action: 'retry' });
|
||||
expect(killed).toBe(1);
|
||||
expect(held === 'stdout' ? stdout.cancelled() : stderr.cancelled()).toBe(true);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('bounds a process that never exits', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(Buffer.from('v10synthetic'));
|
||||
const stdout = pipe();
|
||||
const stderr = pipe();
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: new Promise<number>(() => {}), stdout: stdout.stream, stderr: stderr.stream, kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 20 as any; });
|
||||
try {
|
||||
const result = importCookies('dia', ['fixture.test']);
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
await expect(result).rejects.toMatchObject({ code: 'keychain_timeout' });
|
||||
expect(killed).toBe(1);
|
||||
expect(stdout.cancelled()).toBe(true);
|
||||
expect(stderr.cancelled()).toBe(true);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('caps credential output and does not expose it in errors', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(Buffer.from('v10synthetic'));
|
||||
const privateValue = 'private-credential-output';
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob([privateValue.repeat(4_000)]).stream(),
|
||||
stderr: new Blob([]).stream(), kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let error: any;
|
||||
try { await importCookies('dia', ['fixture.test']); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('keychain_error');
|
||||
expect(error.message).not.toContain(privateValue);
|
||||
expect(killed).toBe(1);
|
||||
});
|
||||
|
||||
test('preserves Keychain success and denied/nonexistent policy', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(cbcCookie('fixture-password'));
|
||||
for (const [code, stderr, expected] of [[0, '', 'fixture-value'], [1, 'user canceled private-detail', 'keychain_denied'], [1, 'could not be found private-detail', 'keychain_not_found']] as const) {
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(code), stdout: new Blob([code ? '' : 'fixture-password\n']).stream(),
|
||||
stderr: new Blob([stderr]).stream(), kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn;
|
||||
if (!code) expect((await importCookies('dia', ['fixture.test'])).cookies[0].value).toBe(expected);
|
||||
else await expect(importCookies('dia', ['fixture.test'])).rejects.toMatchObject({ code: expected });
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves Linux secret lookup through concurrent pipe draining', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||
fixture(cbcCookie('test-linux-secret', 'v11'), 'linux');
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob(['test-linux-secret\n']).stream(),
|
||||
stderr: new Blob([]).stream(), kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn;
|
||||
expect((await importCookies('chromium', ['fixture.test'])).cookies[0].value).toBe('fixture-value');
|
||||
});
|
||||
|
||||
test('bounds a Linux secret lookup with an exited child and inherited pipe', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||
fixture(cbcCookie('fixture-other-password', 'v11'), 'linux');
|
||||
const stdout = pipe();
|
||||
const stderr = pipe();
|
||||
stderr.close();
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream,
|
||||
kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 22 as any; });
|
||||
try {
|
||||
const result = importCookies('chromium', ['fixture.test']);
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
expect(await result).toMatchObject({ count: 0, failed: 1 });
|
||||
expect(killed).toBe(1);
|
||||
expect(stdout.cancelled()).toBe(true);
|
||||
} finally { timer.mockRestore(); }
|
||||
});
|
||||
|
||||
test('bounds DPAPI stdout and preserves successful extraction without exposing input', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||
const key = Buffer.alloc(32, 0x42);
|
||||
const nonce = Buffer.alloc(12, 0x22);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
||||
const encrypted = Buffer.concat([Buffer.from('v10'), nonce, cipher.update('fixture-value'), cipher.final(), cipher.getAuthTag()]);
|
||||
fixture(encrypted, 'win32');
|
||||
fs.writeFileSync(path.join(home, 'AppData/Local/Chromium/User Data/Local State'), JSON.stringify({ os_crypt: { encrypted_key: Buffer.from('DPAPIsynthetic').toString('base64') } }));
|
||||
const stdout = pipe(key.toString('base64'));
|
||||
const stderr = pipe();
|
||||
let sent = '';
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream,
|
||||
stdin: { write(value: string) { sent = value; }, end() {} }, kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 21 as any; });
|
||||
try {
|
||||
const result = importCookies('chromium', ['fixture.test']);
|
||||
expect(sent).toBe(Buffer.from('synthetic').toString('base64'));
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
await expect(result).rejects.toMatchObject({ code: 'keychain_timeout' });
|
||||
expect(killed).toBe(1);
|
||||
} finally { timer.mockRestore(); }
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob([key.toString('base64')]).stream(),
|
||||
stderr: new Blob([]).stream(), stdin: { write(value: string) { sent = value; }, end() {} }, kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn;
|
||||
expect((await importCookies('chromium', ['fixture.test'])).cookies[0].value).toBe('fixture-value');
|
||||
});
|
||||
|
||||
test('Node polyfill replay streams work with the bundled importer and a synthetic credential child', () => {
|
||||
const bundle = path.join(home, 'importer.mjs');
|
||||
const build = spawnSync(process.execPath, ['build', path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'), '--target=node', '--outfile', bundle], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(build.status).toBe(0);
|
||||
const node = Bun.which('node')!;
|
||||
const polyfill = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
|
||||
const nodeFixture = path.join(home, 'node-fixture');
|
||||
fs.mkdirSync(nodeFixture);
|
||||
const dir = path.join(nodeFixture, 'Library/Application Support/Dia/User Data/Default');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(nodeFixture) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run("INSERT INTO cookies VALUES ('.fixture.test', 'session', '', ?, '/', 0, 1, 1, 0, 1)", [cbcCookie('fixture-node-password')]);
|
||||
db.close();
|
||||
const result = spawnSync(node, ['--input-type=module', '-e', `
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
require(process.argv[1]);
|
||||
const original = Bun.spawn;
|
||||
Bun.spawn = (_command, options) => original([process.execPath, '-e', 'console.log("fixture-node-password")'], options);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
const { importCookies } = await import(process.argv[2]);
|
||||
const result = await importCookies('dia', ['fixture.test']);
|
||||
console.log(JSON.stringify({ count: result.count, value: result.cookies[0]?.value }));
|
||||
`, polyfill, pathToFileURL(bundle).href], { encoding: 'utf8', timeout: 15_000,
|
||||
env: { ...process.env, HOME: nodeFixture, USERPROFILE: nodeFixture, NODE_NO_WARNINGS: '1' } });
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.stderr).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ count: 1, value: 'fixture-value' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { openCookieDatabase } from '../src/cookie-database';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'cookie-db-')));
|
||||
const dbPath = path.join(root, 'Cookies');
|
||||
const adapterPath = path.join(root, 'cookie-database.mjs');
|
||||
const corruptPath = path.join(root, 'private-corrupt-fixture');
|
||||
const missingPath = path.join(root, 'private-missing-fixture');
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node.js is required for cookie database adapter tests');
|
||||
|
||||
beforeAll(() => {
|
||||
const database = new Database(dbPath);
|
||||
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, encrypted_value BLOB, expires_utc INTEGER, has_expires INTEGER)');
|
||||
const insert = database.query('INSERT INTO cookies VALUES (?, ?, ?, ?, ?)');
|
||||
insert.run('.fixture.test', 'large-expiry', new Uint8Array([0, 127, 255]), 13300000000000001n, 1);
|
||||
insert.run('.fixture.test', 'session', new Uint8Array([]), 0, 0);
|
||||
insert.run('.fixture.test', 'expired', new Uint8Array([]), 500, 1);
|
||||
insert.run('.other.test', 'other-session', new Uint8Array([]), 0, 0);
|
||||
database.close();
|
||||
chmodSync(dbPath, 0o600);
|
||||
writeFileSync(corruptPath, 'private-fixture-not-a-database', { mode: 0o600 });
|
||||
const source = readFileSync(path.resolve(import.meta.dir, '../src/cookie-database.ts'), 'utf8');
|
||||
writeFileSync(adapterPath, new Bun.Transpiler({ loader: 'ts', target: 'node' }).transformSync(source), { mode: 0o600 });
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function runNode(source: string, flags: string[] = []) {
|
||||
const result = spawnSync(node!, ['--input-type=module', ...flags, '-e', source, pathToFileURL(adapterPath).href, dbPath, corruptPath, missingPath], {
|
||||
encoding: 'utf8', timeout: 10_000, env: {
|
||||
PATH: path.dirname(node!), HOME: root, USERPROFILE: root, TEMP: root, TMP: root,
|
||||
NODE_NO_WARNINGS: '1', ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
||||
},
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
describe('cookie database runtime adapter', () => {
|
||||
test('reproduces the old Node null stub before cookie queries can run', () => {
|
||||
const result = runNode(`
|
||||
import { existsSync } from 'node:fs';
|
||||
const Database = null;
|
||||
let attemptedQuery = false;
|
||||
try {
|
||||
const database = new Database(process.argv[2], { readonly: true });
|
||||
attemptedQuery = true;
|
||||
database.query('SELECT host_key FROM cookies').all();
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify({ fixtureExists: existsSync(process.argv[2]), error: error.name, attemptedQuery }));
|
||||
}
|
||||
`);
|
||||
expect(result).toEqual({ fixtureExists: true, error: 'TypeError', attemptedQuery: false });
|
||||
});
|
||||
|
||||
test('Bun returns JSON-safe domain counts and preserves precise expiry integers and blobs', () => {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
const domains = database.query('SELECT host_key AS domain, COUNT(*) AS count FROM cookies WHERE has_expires = 0 OR expires_utc > ? GROUP BY host_key ORDER BY count DESC').all(1000);
|
||||
expect(domains).toEqual([{ domain: '.fixture.test', count: 2 }, { domain: '.other.test', count: 1 }]);
|
||||
expect(JSON.parse(JSON.stringify(domains))).toEqual(domains);
|
||||
const rows = database.query('SELECT name, encrypted_value, expires_utc, has_expires FROM cookies WHERE host_key IN (?, ?) AND expires_utc = ?').all('fixture.test', '.fixture.test', 13300000000000001n) as any[];
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].expires_utc).toBe(13300000000000001n);
|
||||
expect(rows[0].has_expires).toBe(1);
|
||||
expect(Array.from(rows[0].encrypted_value)).toEqual([0, 127, 255]);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Node uses built-in SQLite even with a Bun server polyfill and preserves the same values', () => {
|
||||
const result = runNode(`
|
||||
globalThis.Bun = { fixturePolyfill: true };
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const domains = database.query('SELECT host_key AS domain, COUNT(*) AS count FROM cookies WHERE has_expires = 0 OR expires_utc > ? GROUP BY host_key ORDER BY count DESC').all(1000);
|
||||
const rows = database.query('SELECT name, encrypted_value, expires_utc, has_expires FROM cookies WHERE host_key IN (?, ?) AND expires_utc = ?').all('fixture.test', '.fixture.test', 13300000000000001n);
|
||||
console.log(JSON.stringify({ domains, rowCount: rows.length, expiry: String(rows[0].expires_utc), expiryType: typeof rows[0].expires_utc,
|
||||
flag: rows[0].has_expires, blob: Array.from(rows[0].encrypted_value) }));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
expect(result).toEqual({ domains: [{ domain: '.fixture.test', count: 2 }, { domain: '.other.test', count: 1 }],
|
||||
rowCount: 1, expiry: '13300000000000001', expiryType: 'bigint', flag: 1, blob: [0, 127, 255] });
|
||||
});
|
||||
|
||||
for (const runtime of ['Bun', 'Node']) {
|
||||
test(`${runtime} converts only integers inside the safe Number range`, () => {
|
||||
const sql = 'SELECT 9007199254740991 AS safe_positive, -9007199254740991 AS safe_negative, 9007199254740992 AS unsafe_positive, -9007199254740992 AS unsafe_negative, 9223372036854775807 AS maximum, 1.5 AS fractional, NULL AS empty';
|
||||
let row: any;
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
row = database.query(sql).all()[0];
|
||||
row = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, { type: typeof value, value: String(value) }]));
|
||||
} finally { database.close(); }
|
||||
} else {
|
||||
row = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const row = database.query(${JSON.stringify(sql)}).all()[0];
|
||||
console.log(JSON.stringify(Object.fromEntries(Object.entries(row).map(([key, value]) => [key, { type: typeof value, value: String(value) }]))));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
}
|
||||
expect(row).toEqual({ safe_positive: { type: 'number', value: '9007199254740991' }, safe_negative: { type: 'number', value: '-9007199254740991' },
|
||||
unsafe_positive: { type: 'bigint', value: '9007199254740992' }, unsafe_negative: { type: 'bigint', value: '-9007199254740992' },
|
||||
maximum: { type: 'bigint', value: '9223372036854775807' }, fractional: { type: 'number', value: '1.5' }, empty: { type: 'object', value: 'null' } });
|
||||
});
|
||||
|
||||
test(`${runtime} refuses database writes and leaves the source bytes unchanged`, () => {
|
||||
const before = readFileSync(dbPath);
|
||||
const writes = ["INSERT INTO cookies VALUES ('private-insert', 'attempt', NULL, 0, 0)",
|
||||
"UPDATE cookies SET name = 'private-update'", 'DELETE FROM cookies', 'CREATE TABLE private_created_table (value TEXT)'];
|
||||
let results: any[];
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
results = writes.map(sql => {
|
||||
try { database.query(sql).all(); return { wrote: true }; }
|
||||
catch (error: any) { return { code: error.code, message: error.message }; }
|
||||
});
|
||||
} finally { database.close(); }
|
||||
} else {
|
||||
results = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const results = ${JSON.stringify(writes)}.map(sql => {
|
||||
try { database.query(sql).all(); return { wrote: true }; }
|
||||
catch (error) { return { code: error.code, message: error.message }; }
|
||||
});
|
||||
console.log(JSON.stringify(results));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
}
|
||||
expect(results).toHaveLength(4);
|
||||
for (const result of results) {
|
||||
expect(result.code).toBe('SQLITE_READONLY');
|
||||
expect(result.message).not.toContain('private-');
|
||||
expect(result.message).not.toContain('private_created_table');
|
||||
}
|
||||
expect(readFileSync(dbPath)).toEqual(before);
|
||||
});
|
||||
|
||||
test(`${runtime} keeps parameters bound instead of interpreting SQL-looking values`, () => {
|
||||
const value = ".fixture.test'; DROP TABLE cookies; --";
|
||||
let rows: unknown[];
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
rows = database.query('SELECT name FROM cookies WHERE host_key = ?').all(value);
|
||||
expect(database.query('SELECT COUNT(*) AS count FROM cookies').all()).toEqual([{ count: 4 }]);
|
||||
} finally { database.close(); }
|
||||
} else {
|
||||
const result = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const rows = database.query('SELECT name FROM cookies WHERE host_key = ?').all(${JSON.stringify(value)});
|
||||
console.log(JSON.stringify({ rows, counts: database.query('SELECT COUNT(*) AS count FROM cookies').all() }));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
rows = result.rows;
|
||||
expect(result.counts).toEqual([{ count: 4 }]);
|
||||
}
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
test(`${runtime} supports a mutable close method for copied-profile cleanup`, () => {
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
const close = database.close.bind(database);
|
||||
let cleanup = false;
|
||||
database.close = () => { close(); cleanup = true; };
|
||||
database.close();
|
||||
expect(cleanup).toBe(true);
|
||||
expect(() => database.query('SELECT 1').all()).toThrow();
|
||||
} else {
|
||||
expect(runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
const close = database.close.bind(database);
|
||||
let cleanup = false;
|
||||
database.close = () => { close(); cleanup = true; };
|
||||
database.close();
|
||||
let closed = false;
|
||||
try { database.query('SELECT 1').all(); } catch { closed = true; }
|
||||
console.log(JSON.stringify({ cleanup, closed }));
|
||||
`)).toEqual({ cleanup: true, closed: true });
|
||||
}
|
||||
});
|
||||
|
||||
test(`${runtime} sanitizes open, corrupt-database, query, and binding failures`, () => {
|
||||
const exercise = (open: typeof openCookieDatabase, missing: string, corrupt: string, valid: string) => {
|
||||
const results: { code: string; message: string }[] = [];
|
||||
for (const file of [missing, corrupt]) {
|
||||
let database: ReturnType<typeof openCookieDatabase> | undefined;
|
||||
try { database = open(file); database.query('SELECT * FROM cookies').all(); }
|
||||
catch (error: any) { results.push({ code: error.code, message: error.message }); }
|
||||
finally { database?.close(); }
|
||||
}
|
||||
const database = open(valid);
|
||||
try {
|
||||
try { database.query('SELECT private_query_sentinel FROM cookies').all(); }
|
||||
catch (error: any) { results.push({ code: error.code, message: error.message }); }
|
||||
try { database.query('SELECT ? AS value').all(Symbol('private-binding-sentinel')); }
|
||||
catch (error: any) { results.push({ code: error.code, message: error.message }); }
|
||||
} finally { database.close(); }
|
||||
return results;
|
||||
};
|
||||
const results = runtime === 'Bun' ? exercise(openCookieDatabase, missingPath, corruptPath, dbPath) : runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const exercise = ${exercise.toString()};
|
||||
console.log(JSON.stringify(exercise(openCookieDatabase, process.argv[4], process.argv[3], process.argv[2])));
|
||||
`);
|
||||
expect(results.map((result: any) => result.code)).toEqual(['SQLITE_CANTOPEN', 'SQLITE_CORRUPT', 'SQLITE_ERROR', 'SQLITE_ERROR']);
|
||||
expect(JSON.stringify(results)).not.toMatch(/private[-_]|Cookies|cookie-db-/);
|
||||
expect(existsSync(missingPath)).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ['missing module', 'missing export']) {
|
||||
test(`Node import remains available without SQLite and cookie use fails safely (${failure})`, () => {
|
||||
const result = runNode(`
|
||||
import Module from 'node:module';
|
||||
const original = Module._load;
|
||||
let loads = 0;
|
||||
Module._load = function(name, ...args) {
|
||||
if (name === 'node:sqlite') {
|
||||
loads++;
|
||||
if (${JSON.stringify(failure)} === 'missing export') return {};
|
||||
throw new Error('private-module-error');
|
||||
}
|
||||
return original.call(this, name, ...args);
|
||||
};
|
||||
try {
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const loadsAtImport = loads;
|
||||
try { openCookieDatabase(process.argv[2]); }
|
||||
catch (error) { console.log(JSON.stringify({ loadsAtImport, loads, code: error.code, message: error.message })); }
|
||||
} finally { Module._load = original; }
|
||||
`);
|
||||
expect(result.loadsAtImport).toBe(0);
|
||||
expect(result.loads).toBe(1);
|
||||
expect(result.code).toBe('sqlite_unavailable');
|
||||
expect(result.message).toContain('Node.js 22.13 or newer');
|
||||
expect(result.message).toContain('Upgrade Node.js or enable SQLite');
|
||||
expect(result.message).not.toContain('private-module-error');
|
||||
expect(result.message).not.toContain(dbPath);
|
||||
});
|
||||
}
|
||||
|
||||
test('actual Node with SQLite disabled can import the module and gets version guidance only on cookie use', () => {
|
||||
const result = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
try { openCookieDatabase(process.argv[2]); }
|
||||
catch (error) { console.log(JSON.stringify({ imported: true, code: error.code, message: error.message })); }
|
||||
`, ['--no-experimental-sqlite']);
|
||||
expect(result.imported).toBe(true);
|
||||
expect(result.code).toBe('sqlite_unavailable');
|
||||
expect(result.message).toContain('Node.js 22.13 or newer');
|
||||
expect(result.message).toContain('Upgrade Node.js or enable SQLite');
|
||||
expect(result.message).not.toContain(dbPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterAll, expect, test } from 'bun:test';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, rmdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
import { createFixtureDeleteLease, deleteWithFixtureLease, FixtureDeleteError, type FixtureDeleteBackend } from './fixtures/native-cookie-delete-lease';
|
||||
import { nativeCookieEnvironment } from '../src/cookie-import-native-worker';
|
||||
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'delete-lease-'));
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
const identity = { dev: 7n, ino: 9007199254740993n, mode: 0o100666n };
|
||||
|
||||
function model() {
|
||||
let time = 0;
|
||||
const calls: string[] = [];
|
||||
const backend: FixtureDeleteBackend = {
|
||||
open: () => { calls.push('open'); return 42; },
|
||||
identity: () => { calls.push('identity'); return { ...identity, attributes: 0x80, filesystem: 'NTFS' }; },
|
||||
dispose: () => { calls.push('dispose'); },
|
||||
close: () => { calls.push('close'); },
|
||||
absent: () => { calls.push('absent'); return true; },
|
||||
};
|
||||
const verify = () => { calls.push('verify'); };
|
||||
const clock = { now: () => time, wait: (ms: number) => { calls.push(`wait:${ms}`); time += ms; } };
|
||||
return { backend, calls, verify, clock, advance: (ms: number) => { time += ms; } };
|
||||
}
|
||||
|
||||
test('an admitted identity is deleted once, closed, and checked for absence in that order', () => {
|
||||
const f = model();
|
||||
expect(deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toEqual({ admissionProbes: 1, waitedMs: 0 });
|
||||
expect(f.calls).toEqual(['verify', 'open', 'identity', 'verify', 'dispose', 'close', 'absent']);
|
||||
});
|
||||
|
||||
test('sharing admission can settle inside the same deadline without retrying deletion', () => {
|
||||
const f = model(); let attempts = 0;
|
||||
f.backend.open = () => { f.calls.push('open'); if (++attempts < 3) throw new FixtureDeleteError('admission', 'owned', 32); return 42; };
|
||||
expect(deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toEqual({ admissionProbes: 3, waitedMs: 40 });
|
||||
expect(f.calls.filter(call => call === 'dispose')).toHaveLength(1);
|
||||
expect(f.calls.filter(call => call === 'close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('persistent sharing keeps its first error and performs zero deletes', () => {
|
||||
const f = model(); const first = new FixtureDeleteError('admission', 'owned', 32); let attempts = 0;
|
||||
f.backend.open = () => { attempts++; throw attempts === 1 ? first : new FixtureDeleteError('admission', 'owned', 32); };
|
||||
let failure: unknown;
|
||||
try { deleteWithFixtureLease('owned', identity, 40, f.verify, f.backend, f.clock); } catch (error) { failure = error; }
|
||||
expect(failure).toBe(first);
|
||||
expect(attempts).toBe(2);
|
||||
expect(f.calls).not.toContain('dispose');
|
||||
expect(f.calls).not.toContain('close');
|
||||
});
|
||||
|
||||
test.each([2, 3, 5, 33, 50, 87])('admission error %s is never polled or deleted', code => {
|
||||
const f = model(); const original = new FixtureDeleteError('admission', 'owned', code);
|
||||
f.backend.open = () => { f.calls.push('open'); throw original; };
|
||||
let failure: unknown;
|
||||
try { deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock); } catch (error) { failure = error; }
|
||||
expect(failure).toBe(original);
|
||||
expect(f.calls).toEqual(['verify', 'open']);
|
||||
});
|
||||
|
||||
test.each(['volume', 'inode', 'ReFS', 'readonly', 'directory', 'reparse', 'ancestor'])(
|
||||
'a rejected %s identity closes its handle without deleting', defect => {
|
||||
const f = model();
|
||||
f.backend.identity = () => ({ ...identity, dev: defect === 'volume' ? 8n : identity.dev,
|
||||
ino: defect === 'inode' ? 9007199254740992n : identity.ino, filesystem: defect === 'ReFS' ? 'ReFS' : 'NTFS',
|
||||
attributes: { readonly: 1, directory: 16, reparse: 1024 }[defect] ?? 0x80 });
|
||||
let checks = 0;
|
||||
const verify = () => { if (++checks === 2 && defect === 'ancestor') throw new Error('Owned ancestor changed'); };
|
||||
expect(() => deleteWithFixtureLease('owned', identity, 100, verify, f.backend, f.clock)).toThrow();
|
||||
expect(f.calls.filter(call => call === 'close')).toHaveLength(1);
|
||||
expect(f.calls).not.toContain('dispose');
|
||||
});
|
||||
|
||||
test.each(['before_open', 'after_verify', 'after_identity', 'after_revalidation'])(
|
||||
'expiration %s never grants late deletion', stage => {
|
||||
const f = model(); let checks = 0;
|
||||
if (stage === 'before_open') f.advance(100);
|
||||
const verify = () => { checks++; if (stage === 'after_verify' && checks === 1 || stage === 'after_revalidation' && checks === 2) f.advance(100); };
|
||||
const identify = f.backend.identity;
|
||||
f.backend.identity = (handle, file) => { const result = identify(handle, file); if (stage === 'after_identity') f.advance(100); return result; };
|
||||
expect(() => deleteWithFixtureLease('owned', identity, 100, verify, f.backend, f.clock)).toThrow('deadline');
|
||||
expect(f.calls).not.toContain('dispose');
|
||||
expect(f.calls.filter(call => call === 'close')).toHaveLength(stage.startsWith('before') || stage === 'after_verify' ? 0 : 1);
|
||||
});
|
||||
|
||||
test('a disposition failure is not retried and survives close', () => {
|
||||
const f = model(); const original = new FixtureDeleteError('disposition', 'owned', 32);
|
||||
f.backend.dispose = () => { f.calls.push('dispose'); throw original; };
|
||||
let failure: unknown;
|
||||
try { deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock); } catch (error) { failure = error; }
|
||||
expect(failure).toBe(original);
|
||||
expect(f.calls.filter(call => call === 'dispose')).toHaveLength(1);
|
||||
expect(f.calls.at(-1)).toBe('close');
|
||||
expect(f.calls.some(call => call.startsWith('wait'))).toBe(false);
|
||||
});
|
||||
|
||||
test.each(['close', 'absence'])('%s failure never reports completed removal', stage => {
|
||||
const f = model();
|
||||
if (stage === 'close') f.backend.close = () => { throw new FixtureDeleteError('close', 'owned', 6); };
|
||||
else f.backend.absent = () => false;
|
||||
expect(() => deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toThrow(stage);
|
||||
});
|
||||
|
||||
test('the actual Windows binding uses the checked handle, NTFS identity, and Ex flags without a fallback', () => {
|
||||
const source = readFileSync(path.join(import.meta.dir, 'fixtures/native-cookie-delete-lease.ts'), 'utf8');
|
||||
const start = source.indexOf('export function createFixtureDeleteLease(');
|
||||
expect(start).toBeGreaterThan(0);
|
||||
const body = new Bun.Transpiler({ loader: 'ts' }).transformSync(source.slice(start).replace('export function', 'function'));
|
||||
const calls: string[] = [];
|
||||
const api = {
|
||||
CreateFileW(name: Buffer, access: number, share: number, security: unknown, creation: number, flags: number, template: number) {
|
||||
calls.push('open');
|
||||
expect(name.toString('utf16le')).toBe('namespaced-owned\0');
|
||||
expect([access, share, security, creation, flags, template]).toEqual([0x10080, 3, null, 3, 0x00200000, 0]);
|
||||
return 42;
|
||||
},
|
||||
GetLastError() { calls.push('last-error'); return 0; },
|
||||
GetFileInformationByHandle(handle: number, info: Buffer) {
|
||||
calls.push('identity'); expect(handle).toBe(42); expect(info.length).toBe(52);
|
||||
info.writeUInt32LE(0x80, 0); info.writeUInt32LE(Number(identity.dev), 28);
|
||||
info.writeUInt32LE(Number(identity.ino >> 32n), 44); info.writeUInt32LE(Number(identity.ino & 0xffffffffn), 48);
|
||||
return 1;
|
||||
},
|
||||
GetVolumeInformationByHandleW(handle: number, name: unknown, length: number, serial: unknown, component: unknown, flags: unknown, filesystem: Buffer, size: number) {
|
||||
calls.push('filesystem'); expect([handle, name, length, serial, component, flags, size]).toEqual([42, null, 0, null, null, null, 64]);
|
||||
expect(filesystem.length).toBe(128); filesystem.write('NTFS\0', 'utf16le'); return 1;
|
||||
},
|
||||
SetFileInformationByHandle(handle: number, kind: number, flags: Buffer, size: number) {
|
||||
calls.push('dispose'); expect([handle, kind, flags.length, flags.readUInt32LE(0), size]).toEqual([42, 21, 4, 3, 4]); return 1;
|
||||
},
|
||||
CloseHandle(handle: number) { calls.push('close'); expect(handle).toBe(42); return 1; },
|
||||
};
|
||||
const factory = new Function('process', 'dlopen', 'FFIType', 'ptr', 'unlinkSync', 'lstatSync', 'toNamespacedPath',
|
||||
'FixtureDeleteError', 'deleteWithFixtureLease', 'leaseClock', `${body}\nreturn createFixtureDeleteLease;`)(
|
||||
{ platform: 'win32' }, (name: string) => { expect(name).toBe('kernel32.dll'); return { symbols: api, close() { calls.push('unload'); } }; },
|
||||
{ ptr: 'ptr', u32: 'u32', u64: 'u64', i32: 'i32' }, (value: Buffer) => value,
|
||||
() => { throw new Error('Regular files must not use a path-based fallback'); },
|
||||
() => { calls.push('absence'); throw Object.assign(new Error('Absent'), { code: 'ENOENT' }); },
|
||||
(file: string) => { expect(file).toBe('owned'); return 'namespaced-owned'; },
|
||||
FixtureDeleteError, deleteWithFixtureLease, { now: () => 0, wait: () => { throw new Error('Unexpected wait'); } });
|
||||
const lease = factory(100);
|
||||
lease.unlink('owned', identity, () => { calls.push('verify'); });
|
||||
lease.close();
|
||||
expect(calls).toEqual(['verify', 'open', 'last-error', 'identity', 'filesystem', 'verify', 'dispose', 'close', 'absence', 'unload']);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('the native lease refuses a persistent no-delete-sharing holder', () => {
|
||||
const file = path.join(root, 'persistent'); writeFileSync(file, 'fixture-only');
|
||||
const expected = lstatSync(file, { bigint: true });
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
const name = Buffer.from(file + '\0', 'utf16le');
|
||||
const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 3, null, 3, 0x80, 0);
|
||||
const lease = createFixtureDeleteLease(performance.now() + 1_000);
|
||||
try {
|
||||
expect(BigInt(handle)).not.toBe(0xffffffffffffffffn);
|
||||
expect(BigInt(handle)).not.toBe(0n);
|
||||
let failure: unknown;
|
||||
try { lease.unlink(file, expected, () => {}); } catch (error) { failure = error; }
|
||||
expect(failure).toBeInstanceOf(FixtureDeleteError);
|
||||
expect(failure).toMatchObject({ stage: 'admission', win32Error: 32, code: 'EBUSY' });
|
||||
expect(readFileSync(file, 'utf8')).toBe('fixture-only');
|
||||
expect(lstatSync(file, { bigint: true }).ino).toBe(expected.ino);
|
||||
} finally { lease.close(); if (BigInt(handle) !== 0xffffffffffffffffn && BigInt(handle) !== 0n) kernel.symbols.CloseHandle(handle); kernel.close(); }
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('a compatible reader retains its data while the admitted delete removes the namespace', () => {
|
||||
const directory = mkdtempSync(path.join(root, 'reader-'));
|
||||
const file = path.join(directory, 'data'); writeFileSync(file, 'fixture-only');
|
||||
const expected = lstatSync(file, { bigint: true });
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
ReadFile: { args: [FFIType.u64, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
const name = Buffer.from(file + '\0', 'utf16le');
|
||||
const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 7, null, 3, 0x80, 0);
|
||||
const lease = createFixtureDeleteLease(performance.now() + 2_000);
|
||||
try {
|
||||
expect(BigInt(handle)).not.toBe(0xffffffffffffffffn);
|
||||
expect(BigInt(handle)).not.toBe(0n);
|
||||
let checks = 0;
|
||||
lease.unlink(file, expected, () => { if (++checks === 2) expect(() => renameSync(file, path.join(directory, 'moved'))).toThrow(); });
|
||||
expect(checks).toBe(2);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
rmdirSync(directory);
|
||||
const buffer = Buffer.alloc(32), bytes = Buffer.alloc(4);
|
||||
expect(kernel.symbols.ReadFile(handle, ptr(buffer), buffer.length, ptr(bytes), null)).toBe(1);
|
||||
expect(buffer.subarray(0, bytes.readUInt32LE()).toString()).toBe('fixture-only');
|
||||
} finally { lease.close(); if (BigInt(handle) !== 0xffffffffffffffffn && BigInt(handle) !== 0n) kernel.symbols.CloseHandle(handle); kernel.close(); }
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('an independently released holder admits one identity-bound deletion', async () => {
|
||||
const directory = mkdtempSync(path.join(root, 'released-'));
|
||||
const file = path.join(directory, 'held'), ready = path.join(directory, 'ready'), release = path.join(directory, 'release');
|
||||
writeFileSync(file, 'fixture-only'); const expected = lstatSync(file, { bigint: true });
|
||||
const script = `
|
||||
const { dlopen, FFIType, ptr } = await import('bun:ffi');
|
||||
const { existsSync, writeFileSync } = await import('node:fs');
|
||||
const api = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
const name = Buffer.from(${JSON.stringify(file)} + '\\0', 'utf16le');
|
||||
const handle = api.symbols.CreateFileW(ptr(name), 0x80000000, 3, null, 3, 0x80, 0);
|
||||
if (BigInt(handle) === 0xffffffffffffffffn || BigInt(handle) === 0n) throw new Error('Holder open failed');
|
||||
try {
|
||||
writeFileSync(${JSON.stringify(ready)}, 'ready');
|
||||
while (!existsSync(${JSON.stringify(release)})) await Bun.sleep(10);
|
||||
} finally { if (!api.symbols.CloseHandle(handle)) throw new Error('Holder close failed'); api.close(); }
|
||||
`;
|
||||
const child: ChildProcess = spawn(process.execPath, ['--no-env-file', '--no-install', '--config=NUL', '-e', script],
|
||||
{ env: nativeCookieEnvironment(process.env), stdio: 'ignore', windowsHide: true });
|
||||
let spawnError: Error | undefined;
|
||||
child.once('error', error => { spawnError = error; });
|
||||
const closed = new Promise<void>(resolve => child.once('close', () => resolve()));
|
||||
const timer = setTimeout(() => child.kill(), 5_000);
|
||||
let lease: ReturnType<typeof createFixtureDeleteLease> | undefined;
|
||||
try {
|
||||
const deadline = performance.now() + 3_000;
|
||||
while (!existsSync(ready) && child.exitCode === null && !spawnError && performance.now() < deadline) await Bun.sleep(10);
|
||||
expect(spawnError).toBeUndefined();
|
||||
expect(existsSync(ready)).toBe(true);
|
||||
let blocked = 0;
|
||||
lease = createFixtureDeleteLease(performance.now() + 2_000, () => { blocked++; writeFileSync(release, 'release'); });
|
||||
lease.unlink(file, expected, () => {});
|
||||
expect(blocked).toBe(1);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
await closed;
|
||||
expect(child.exitCode).toBe(0);
|
||||
} finally { clearTimeout(timer); lease?.close(); child.kill(); await closed; }
|
||||
}, 10_000);
|
||||
|
||||
test.skipIf(process.platform !== 'win32').each(['identity', 'readonly', 'reparse'])(
|
||||
'the native lease refuses %s without removing the file or target', defect => {
|
||||
const directory = mkdtempSync(path.join(root, 'refused-'));
|
||||
const file = path.join(directory, 'data'); writeFileSync(file, 'fixture-only');
|
||||
const expected = lstatSync(file, { bigint: true });
|
||||
const destination = path.join(directory, 'destination');
|
||||
if (defect === 'readonly') chmodSync(file, 0o444);
|
||||
if (defect === 'reparse') {
|
||||
unlinkSync(file); mkdirSync(destination); writeFileSync(path.join(destination, 'preserved'), 'fixture-only');
|
||||
symlinkSync(destination, file, 'junction');
|
||||
}
|
||||
const lease = createFixtureDeleteLease(performance.now() + 2_000);
|
||||
try {
|
||||
expect(() => lease.unlink(file, defect === 'identity' ? { ...expected, ino: expected.ino + 1n } : expected, () => {})).toThrow();
|
||||
expect(existsSync(file)).toBe(true);
|
||||
if (defect === 'reparse') expect(readFileSync(path.join(destination, 'preserved'), 'utf8')).toBe('fixture-only');
|
||||
else expect(readFileSync(file, 'utf8')).toBe('fixture-only');
|
||||
} finally { lease.close(); if (defect === 'readonly') chmodSync(file, 0o666); }
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,128 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { release, tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { nativeBrowserPaths } from '../src/cookie-import-native';
|
||||
import { nativeCookieEnvironment, probeNativeCookieMember } from '../src/cookie-import-native-worker';
|
||||
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, NATIVE_BROWSER_VERSION_COMMAND, NATIVE_QUALIFICATION_DATA } from '../src/cookie-import-native-integrity';
|
||||
import { createNativeCookieJob, NativeCookieJobError, nativeCookieDiagnostic, type NativeCookieDiagnostic, type NativeCookieJob } from '../src/cookie-import-native-job';
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
console.error('Native cookie qualification requires Windows; no cases ran and no qualification was issued.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.env.GITHUB_ACTIONS !== 'true' || process.env.CI !== 'true') {
|
||||
console.error('Native cookie qualification requires a disposable GitHub Actions runner and never uses an existing browser profile.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const output = mkdtempSync(path.join(process.argv[2] || tmpdir(), 'cookie-native-qualification-'));
|
||||
function incomplete(reason: string, diagnostic?: NativeCookieDiagnostic): never {
|
||||
const receipt = { status: 'incomplete', reason, ...(diagnostic ? { diagnostic } : {}), counts: { pass: 0, fail: 0, skip: 0 }, activation: 'No build was qualified; production extraction remains disabled.' };
|
||||
writeFileSync(path.join(output, 'qualification.json'), JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ ...receipt, artifactDirectory: output }));
|
||||
process.exit(2);
|
||||
}
|
||||
if (Bun.version !== '1.4.0') incomplete('bun_1_4_0_required');
|
||||
let emptyJob: NativeCookieJob | undefined;
|
||||
try {
|
||||
emptyJob = await createNativeCookieJob();
|
||||
if (emptyJob.activeProcesses() !== 0) throw new NativeCookieJobError('job_query');
|
||||
emptyJob.terminate();
|
||||
if (emptyJob.activeProcesses() !== 0) throw new NativeCookieJobError('job_query');
|
||||
emptyJob.close();
|
||||
const preflight = { status: 'passed', activeProcesses: 0 };
|
||||
writeFileSync(path.join(output, 'job-preflight.json'), JSON.stringify(preflight, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ nativeJobPreflight: preflight }));
|
||||
} catch (error) {
|
||||
const diagnostic = nativeCookieDiagnostic(error, 'job_create');
|
||||
try { emptyJob?.close(); } catch {}
|
||||
writeFileSync(path.join(output, 'job-preflight.json'), JSON.stringify({ status: 'failed', diagnostic }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
incomplete('native_job_preflight_failed', diagnostic);
|
||||
}
|
||||
const memberProbe = await probeNativeCookieMember();
|
||||
const memberPreflight = 'cookies' in memberProbe ? { status: 'passed', activeProcesses: 0 } : { status: 'failed', error: memberProbe.error, diagnostic: memberProbe.diagnostic };
|
||||
writeFileSync(path.join(output, 'member-preflight.json'), JSON.stringify(memberPreflight, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ nativeMemberPreflight: memberPreflight }));
|
||||
if ('error' in memberProbe) incomplete('native_member_preflight_failed', memberProbe.diagnostic);
|
||||
const root = path.resolve(import.meta.dir, '../..');
|
||||
let sourceHashes: Record<string, string>;
|
||||
try {
|
||||
sourceHashes = await nativeCodeHashes(root, Date.now() + 25_000);
|
||||
} catch {
|
||||
incomplete('source_inputs_unavailable_or_timed_out');
|
||||
}
|
||||
const environment = nativeCookieEnvironment(process.env);
|
||||
const browser = nativeBrowserPaths('Edge', environment);
|
||||
const executable = browser.executables.find(existsSync);
|
||||
const node = Bun.which('node');
|
||||
if (!executable || !node) incomplete('node_or_edge_not_installed');
|
||||
if (existsSync(browser.userDataDir)) incomplete('existing_default_profile_refused');
|
||||
const executableSha256 = await hashNativeFile(executable, Date.now() + 25_000);
|
||||
const nodeProbe = spawnSync(node, ['-p', 'JSON.stringify({ version: process.version, architecture: process.arch })'], { env: environment, encoding: 'utf8', timeout: 10_000, windowsHide: true });
|
||||
if (nodeProbe.status !== 0) incomplete('node_runtime_preflight_failed');
|
||||
const nodeInfo = JSON.parse(nodeProbe.stdout);
|
||||
if (nodeInfo.architecture !== process.arch || !['x64', 'arm64'].includes(process.arch)) incomplete('runtime_architecture_mismatch');
|
||||
const require = createRequire(import.meta.url);
|
||||
const playwrightVersion = require('playwright/package.json').version;
|
||||
if (playwrightVersion !== '1.62.1') incomplete('playwright_1_62_1_required');
|
||||
const versionProbe = spawnSync(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [
|
||||
'-NoProfile', '-NonInteractive', '-Command', NATIVE_BROWSER_VERSION_COMMAND,
|
||||
], { env: { ...environment, GSTACK_QUALIFY_BROWSER_EXE: executable }, encoding: 'utf8', timeout: 10_000, windowsHide: true });
|
||||
const versionErrorCode = (versionProbe.error as NodeJS.ErrnoException | undefined)?.code;
|
||||
const versionMetadata = {
|
||||
exitCode: versionProbe.status,
|
||||
signal: versionProbe.signal,
|
||||
spawnError: versionProbe.error ? (['ENOENT', 'EACCES', 'EPERM', 'ETIMEDOUT'].includes(versionErrorCode || '') ? versionErrorCode : 'spawn_failed') : undefined,
|
||||
stdoutBytes: Buffer.byteLength(versionProbe.stdout || ''),
|
||||
stderrBytes: Buffer.byteLength(versionProbe.stderr || ''),
|
||||
versionValid: /^\d+(?:\.\d+){2,3}$/.test((versionProbe.stdout || '').trim()),
|
||||
};
|
||||
writeFileSync(path.join(output, 'browser-version-preflight.json'), JSON.stringify(versionMetadata, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ nativeBrowserVersionPreflight: versionMetadata }));
|
||||
if (versionProbe.status !== 0 || !versionMetadata.versionValid) incomplete('browser_version_preflight_failed');
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', 'test', 'browse/test/cookie-import-native-job.test.ts', '--test-name-pattern', '^native Windows process qualification'], {
|
||||
cwd: root,
|
||||
env: { ...environment, CI: 'true', GITHUB_ACTIONS: 'true', GSTACK_COOKIE_NATIVE_DEFAULT_FIXTURE: '1', NO_COLOR: '1' },
|
||||
encoding: 'utf8',
|
||||
timeout: 300_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
const log = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
writeFileSync(path.join(output, 'qualification.log'), log, { mode: 0o600, flag: 'wx' });
|
||||
const count = (kind: string) => Number([...log.matchAll(new RegExp(`(?:^|\\n)\\s*(\\d+) ${kind}\\b`, 'g'))].at(-1)?.[1] ?? 0);
|
||||
const counts = { pass: count('pass'), fail: count('fail'), skip: count('skip') };
|
||||
let inputsUnchanged = false;
|
||||
try {
|
||||
const deadline = Date.now() + 25_000;
|
||||
inputsUnchanged = await hashNativeFile(executable, deadline) === executableSha256 && nativeCodeMatches(sourceHashes, await nativeCodeHashes(root, deadline));
|
||||
} catch {}
|
||||
const passed = result.status === 0 && !result.error && counts.pass === 7 && counts.fail === 0 && counts.skip === 0 && inputsUnchanged && !existsSync(browser.userDataDir);
|
||||
const receipt = {
|
||||
status: passed ? 'passed' : 'failed',
|
||||
scope: 'Edge default-profile v20 extraction, pipe transport, profile identity, and owned-process cleanup',
|
||||
qualifiedBuild: {
|
||||
browserName: 'Edge',
|
||||
architecture: nodeInfo.architecture,
|
||||
windowsRelease: release(),
|
||||
executableSha256,
|
||||
nodeVersion: nodeInfo.version,
|
||||
bunVersion: Bun.version,
|
||||
playwrightVersion,
|
||||
sourceHashes,
|
||||
},
|
||||
browserVersion: versionProbe.stdout.trim(),
|
||||
supervisorArchitecture: process.arch,
|
||||
jobPreflight: { status: 'passed', activeProcesses: 0 },
|
||||
memberPreflight,
|
||||
counts,
|
||||
inputsUnchanged,
|
||||
sourceHashes,
|
||||
activationData: NATIVE_QUALIFICATION_DATA,
|
||||
activation: 'Review this receipt and add only this exact qualifiedBuild to the separate activation data; the runner never enables extraction and activation does not modify the recorded code hashes.',
|
||||
};
|
||||
writeFileSync(path.join(output, 'qualification.json'), JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ status: receipt.status, counts, artifactDirectory: output }));
|
||||
process.exitCode = passed ? 0 : 1;
|
||||
@@ -0,0 +1,436 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { release, tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { nativeBrowserPaths, importNativeCookies } from '../src/cookie-import-native';
|
||||
import { nativeCookieEnvironment, NATIVE_COOKIE_NODE_SCRIPT, NATIVE_PROGRESS_PREFIX } from '../src/cookie-import-native-worker';
|
||||
import { parseNativeCookieDiagnostic } from '../src/cookie-import-native-job';
|
||||
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, NATIVE_CODE_INPUTS, NATIVE_QUALIFICATION_DATA, readNativeQualifications } from '../src/cookie-import-native-integrity';
|
||||
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'native-cookies-'));
|
||||
const env = { LOCALAPPDATA: 'C:\\fixture\\Local', PROGRAMFILES: 'C:\\Apps', 'PROGRAMFILES(X86)': 'C:\\Apps32' };
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node is required for native-cookie transport tests');
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function isolatedEnv(): NodeJS.ProcessEnv {
|
||||
const local = path.join(root, 'AppData', 'Local');
|
||||
const roaming = path.join(root, 'AppData', 'Roaming');
|
||||
const temporary = path.join(local, 'Temp');
|
||||
for (const directory of [local, roaming, temporary]) mkdirSync(directory, { recursive: true });
|
||||
return {
|
||||
PATH: path.dirname(node!),
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
LOCALAPPDATA: local,
|
||||
APPDATA: roaming,
|
||||
TEMP: temporary,
|
||||
TMP: temporary,
|
||||
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function expectNativeProgress(stderr: string) {
|
||||
const lines = stderr.trim().split(/\r?\n/).filter(Boolean);
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
return lines.map(line => {
|
||||
expect(line.startsWith(NATIVE_PROGRESS_PREFIX)).toBe(true);
|
||||
const record = JSON.parse(line.slice(NATIVE_PROGRESS_PREFIX.length));
|
||||
expect(parseNativeCookieDiagnostic(record)).toEqual(record);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
function adapter(options: object, extraEnv: object = {}) {
|
||||
const script = `
|
||||
const { importNativeCookies } = await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/cookie-import-native.ts'))});
|
||||
const { CookieImportError } = await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'))});
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
Object.defineProperty(process.versions, 'bun', { value: undefined });
|
||||
process.env.LOCALAPPDATA = ${JSON.stringify(env.LOCALAPPDATA)};
|
||||
Object.assign(process.env, ${JSON.stringify(extraEnv)});
|
||||
try { console.log(JSON.stringify({ cookies: await importNativeCookies(${JSON.stringify(options)}) })); }
|
||||
catch (error) { console.log(JSON.stringify({ code: error.code, message: error.message, typed: error instanceof CookieImportError })); }
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
const options = {
|
||||
browserName: 'Edge',
|
||||
userDataDir: 'C:\\fixture\\Local\\Microsoft\\Edge\\User Data',
|
||||
profile: 'Default',
|
||||
domains: ['example.test'],
|
||||
};
|
||||
|
||||
describe('native-cookie production admission', () => {
|
||||
test('the real member entry reports boot and decoded input before refusing an unowned job', () => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts'), '--member-smoke'], {
|
||||
env: isolatedEnv(), input: JSON.stringify({ jobName: 'Local\\gstack-cookie-00000000-0000-0000-0000-000000000000' }), encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'worker_boot', memberMode: true });
|
||||
expect(progress).toContainEqual({ stage: 'member_input' });
|
||||
expect(progress).toContainEqual({ stage: 'member_decoded' });
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ error: 'native_supervision_failed', diagnostic: { stage: 'job_open' } });
|
||||
});
|
||||
|
||||
test.each([['malformed', '{'], ['oversized', 'x'.repeat(1024 * 1024 + 1)]])('the real member rejects %s input before opening a job', (_name, input) => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts'), '--member-smoke'], {
|
||||
env: isolatedEnv(), input, encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'member_input' });
|
||||
expect(progress).not.toContainEqual({ stage: 'member_decoded' });
|
||||
expect(progress).not.toContainEqual({ stage: 'job_open' });
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ error: 'native_supervision_failed', diagnostic: { stage: 'member_input' } });
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('qualification refuses non-Windows without issuing a receipt', () => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--config=/dev/null', path.resolve(import.meta.dir, 'cookie-import-native-qualification.ts'), root], {
|
||||
env: isolatedEnv(), encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toContain('no cases ran and no qualification was issued');
|
||||
});
|
||||
|
||||
test('activation data is separate from the receipt code inputs', () => {
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-import-native.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-database.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-import-native-worker.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/scripts/build-node-server.sh');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/dist/server-node.mjs');
|
||||
expect(NATIVE_CODE_INPUTS).not.toContain(NATIVE_QUALIFICATION_DATA);
|
||||
});
|
||||
|
||||
test('empty selection never launches or widens to all cookies', async () => {
|
||||
expect(await importNativeCookies({ ...options, domains: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects unsupported production runtime', async () => {
|
||||
await expect(importNativeCookies(options)).rejects.toMatchObject({ code: 'not_supported' });
|
||||
});
|
||||
|
||||
test('rejects invalid domains before launching', async () => {
|
||||
for (const domain of ['https://example.test', 'example.test/path', '*', 'example.test\n--flag']) {
|
||||
await expect(importNativeCookies({ ...options, domains: [domain] })).rejects.toMatchObject({ code: 'invalid_domain' });
|
||||
}
|
||||
});
|
||||
|
||||
test('valid Chromium hostname forms reach runtime admission', async () => {
|
||||
for (const domain of ['service_name.example.test', '-service.example.test', '[::1]']) {
|
||||
await expect(importNativeCookies({ ...options, domains: [domain] })).rejects.toMatchObject({ code: 'not_supported' });
|
||||
}
|
||||
});
|
||||
|
||||
test('unqualified Windows extraction is closed with a typed safe error', () => {
|
||||
expect(adapter(options)).toMatchObject({ code: 'native_unqualified', typed: true });
|
||||
});
|
||||
|
||||
test('environment flags cannot activate unqualified extraction', () => {
|
||||
expect(adapter(options, { GSTACK_COOKIE_NATIVE_QUALIFY: '1', GSTACK_NATIVE_COOKIES: '1' })).toMatchObject({ code: 'native_unqualified', typed: true });
|
||||
});
|
||||
|
||||
test('Chrome default user-data policy also blocks numbered profiles', () => {
|
||||
const result = adapter({ ...options, browserName: 'Chrome', userDataDir: 'C:\\fixture\\Local\\Google\\Chrome\\User Data', profile: 'Profile 2' });
|
||||
expect(result.code).toBe('native_profile_unsupported');
|
||||
expect(result.message).toContain('Chrome 136');
|
||||
expect(result.message).toContain('both pipe and TCP');
|
||||
expect(result.message).toContain('sign in manually');
|
||||
});
|
||||
|
||||
test('does not substitute a different browser for an unsupported identity', () => {
|
||||
expect(adapter({ ...options, browserName: 'Dia' })).toMatchObject({ code: 'not_supported', typed: true });
|
||||
});
|
||||
|
||||
test('rejects mismatched and copied profile roots', () => {
|
||||
expect(adapter({ ...options, browserName: 'Brave' })).toMatchObject({ code: 'native_profile_unsupported' });
|
||||
expect(adapter({ ...options, userDataDir: 'C:\\copied-profile' })).toMatchObject({ code: 'native_profile_unsupported' });
|
||||
});
|
||||
|
||||
test('rejects profile traversal without echoing the supplied text', () => {
|
||||
const result = adapter({ ...options, profile: '../sensitive-sentinel' });
|
||||
expect(result.code).toBe('invalid_profile');
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test('maps every supported browser to only its own executable and data root', () => {
|
||||
const expected = [
|
||||
['Chrome', 'Google\\Chrome', 'chrome.exe'],
|
||||
['Chromium', 'Chromium', 'chrome.exe'],
|
||||
['Brave', 'BraveSoftware\\Brave-Browser', 'brave.exe'],
|
||||
['Edge', 'Microsoft\\Edge', 'msedge.exe'],
|
||||
];
|
||||
for (const [name, relative, exe] of expected) {
|
||||
const mapping = nativeBrowserPaths(name, env);
|
||||
expect(mapping.userDataDir).toBe(`${env.LOCALAPPDATA}\\${relative}\\User Data`);
|
||||
expect(mapping.executables.length).toBeGreaterThan(0);
|
||||
for (const candidate of mapping.executables) expect(candidate.endsWith(`\\${relative}\\Application\\${exe}`)).toBe(true);
|
||||
}
|
||||
expect(nativeBrowserPaths('Edge', env).executables[0]).toStartWith('C:\\Apps32');
|
||||
expect(nativeBrowserPaths('Brave', env).executables.join(' ')).not.toContain('chrome.exe');
|
||||
});
|
||||
|
||||
test('browser environment excludes inherited secrets and runtime injection', () => {
|
||||
expect(nativeCookieEnvironment({ ...env, SystemRoot: 'C:\\Windows', API_KEY: 'sensitive-sentinel', NODE_OPTIONS: '--require=unsafe', DEBUG: 'pw:*', PWDEBUG: '1' })).toEqual({ ...env, SystemRoot: 'C:\\Windows' });
|
||||
});
|
||||
});
|
||||
|
||||
async function qualifiedAdapterFixture() {
|
||||
const fixture = mkdtempSync(path.join(root, 'admission-'));
|
||||
for (const file of NATIVE_CODE_INPUTS) {
|
||||
const destination = path.join(fixture, file);
|
||||
mkdirSync(path.dirname(destination), { recursive: true });
|
||||
if (file === 'browse/dist/server-node.mjs') continue;
|
||||
copyFileSync(path.resolve(import.meta.dir, '../..', file === 'browse/dist/bun-polyfill.cjs' ? 'browse/src/bun-polyfill.cjs' : file), destination);
|
||||
}
|
||||
const activation = path.join(fixture, NATIVE_QUALIFICATION_DATA);
|
||||
writeFileSync(activation, '[]\n');
|
||||
const bundle = path.join(fixture, 'browse/dist/server-node.mjs');
|
||||
const build = async () => {
|
||||
const result = await Bun.build({
|
||||
entrypoints: [path.join(fixture, 'browse/src/cookie-import-native.ts')],
|
||||
target: 'node',
|
||||
define: { 'import.meta.dir': JSON.stringify(path.join(fixture, 'browse/src')) },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
writeFileSync(bundle, await result.outputs[0].text());
|
||||
};
|
||||
await build();
|
||||
const probe = spawnSync(node!, ['-p', 'JSON.stringify({ version: process.version, architecture: process.arch })'], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(probe.status).toBe(0);
|
||||
const runtime = JSON.parse(probe.stdout);
|
||||
const sourceHashes = await nativeCodeHashes(fixture, Date.now() + 25_000);
|
||||
const receipt = {
|
||||
browserName: 'Edge', architecture: runtime.architecture, windowsRelease: release(),
|
||||
executableSha256: '0'.repeat(64), nodeVersion: runtime.version, bunVersion: Bun.version,
|
||||
playwrightVersion: '1.62.1', sourceHashes,
|
||||
};
|
||||
const virtualRoot = `C:\\gstack-${path.basename(fixture)}`;
|
||||
const windowsEnv = { LOCALAPPDATA: `${virtualRoot}\\Local`, PROGRAMFILES: `${virtualRoot}\\Apps`, 'PROGRAMFILES(X86)': `${virtualRoot}\\Apps32` };
|
||||
const input = { ...options, userDataDir: nativeBrowserPaths('Edge', windowsEnv).userDataDir };
|
||||
const invoke = () => {
|
||||
const script = `
|
||||
import cp from 'node:child_process';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
let spawns = 0;
|
||||
cp.spawn = () => { spawns++; throw new Error('Unexpected browser launch before source admission'); };
|
||||
syncBuiltinESMExports();
|
||||
const { importNativeCookies } = await import(${JSON.stringify(pathToFileURL(bundle).href)});
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
Object.assign(process.env, ${JSON.stringify(windowsEnv)});
|
||||
try { await importNativeCookies(${JSON.stringify(input)}); console.log(JSON.stringify({ accepted: true, spawns })); }
|
||||
catch (error) { console.log(JSON.stringify({ code: error.code, message: error.message, spawns, typed: error.name === 'CookieImportError' })); }
|
||||
`;
|
||||
const result = spawnSync(node!, ['--input-type=module', '-e', script], { cwd: fixture, env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
return { fixture, activation, bundle, build, receipt, invoke };
|
||||
}
|
||||
|
||||
describe('production adapter source-bound qualification', () => {
|
||||
test('matching code passes admission, while later worker/core/database/bundle edits reject the old receipt', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0, typed: true });
|
||||
writeFileSync(fixture.activation, JSON.stringify([fixture.receipt]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0, typed: true });
|
||||
for (const file of ['browse/src/cookie-import-native-worker.ts', 'browse/src/cookie-import-native-job.ts', 'browse/src/cookie-import-native-integrity.ts', 'browse/src/cookie-import-browser.ts', 'browse/src/cookie-database.ts', 'browse/scripts/build-node-server.sh', 'browse/dist/server-node.mjs']) {
|
||||
const target = path.join(fixture.fixture, file);
|
||||
const original = readFileSync(target);
|
||||
writeFileSync(target, Buffer.concat([original, Buffer.from('\n')]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0, typed: true });
|
||||
writeFileSync(target, original);
|
||||
}
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0, typed: true });
|
||||
});
|
||||
|
||||
test('activation-only edits preserve all bound bytes, including a rebuilt Node bundle', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
const before = readFileSync(fixture.bundle);
|
||||
writeFileSync(fixture.activation, JSON.stringify([fixture.receipt], null, 2) + '\n');
|
||||
await fixture.build();
|
||||
expect(readFileSync(fixture.bundle)).toEqual(before);
|
||||
expect(await nativeCodeHashes(fixture.fixture, Date.now() + 25_000)).toEqual(fixture.receipt.sourceHashes);
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0 });
|
||||
});
|
||||
|
||||
test('partial or legacy receipts cannot activate even when their browser/runtime tuple matches', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
const sourceHashes = { ...fixture.receipt.sourceHashes };
|
||||
delete sourceHashes['browse/src/cookie-database.ts'];
|
||||
writeFileSync(fixture.activation, JSON.stringify([{ ...fixture.receipt, sourceHashes }]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0 });
|
||||
writeFileSync(fixture.activation, JSON.stringify([{ ...fixture.receipt, sourceHashes: undefined }]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0 });
|
||||
expect(nativeCodeMatches(sourceHashes, fixture.receipt.sourceHashes)).toBe(false);
|
||||
});
|
||||
|
||||
test('qualification reads have a size cap and share the operation deadline', async () => {
|
||||
const fixture = mkdtempSync(path.join(root, 'bounds-'));
|
||||
const activation = path.join(fixture, NATIVE_QUALIFICATION_DATA);
|
||||
mkdirSync(path.dirname(activation), { recursive: true });
|
||||
writeFileSync(activation, ' '.repeat(1024 * 1024 + 1));
|
||||
await expect(readNativeQualifications(fixture, Date.now() + 25_000)).rejects.toThrow('native_unqualified');
|
||||
await expect(hashNativeFile(activation, Date.now() - 1)).rejects.toThrow('native_timeout');
|
||||
});
|
||||
|
||||
test('a stalled source read aborts and destroys its stream at the deadline', async () => {
|
||||
const fixture = mkdtempSync(path.join(root, 'stalled-read-'));
|
||||
const entry = path.join(fixture, 'integrity.mjs');
|
||||
const built = await Bun.build({ entrypoints: [path.resolve(import.meta.dir, '../src/cookie-import-native-integrity.ts')], target: 'node' });
|
||||
expect(built.success).toBe(true);
|
||||
writeFileSync(entry, await built.outputs[0].text());
|
||||
const script = `
|
||||
import fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
let aborted = false;
|
||||
let closed = false;
|
||||
fs.createReadStream = (file, options) => {
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.once('close', () => { closed = true; });
|
||||
options.signal.addEventListener('abort', () => { aborted = true; stream.destroy(new Error('synthetic interruption')); }, { once: true });
|
||||
return stream;
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
const { hashNativeFile } = await import(${JSON.stringify(pathToFileURL(entry).href)});
|
||||
const started = Date.now();
|
||||
let code;
|
||||
try { await hashNativeFile('synthetic-source', started + 20); }
|
||||
catch (error) { code = error.message; }
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
console.log(JSON.stringify({ code, aborted, closed, elapsed: Date.now() - started }));
|
||||
`;
|
||||
const result = spawnSync(node!, ['--input-type=module', '-e', script], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
const outcome = JSON.parse(result.stdout);
|
||||
expect(outcome).toMatchObject({ code: 'native_timeout', aborted: true, closed: true });
|
||||
expect(outcome.elapsed).toBeLessThan(1500);
|
||||
});
|
||||
});
|
||||
|
||||
function runNodeWorker(mode: string, cookies: object[] = [], exitCode = 21) {
|
||||
const fixture = mkdtempSync(path.join(root, 'worker-'));
|
||||
const observation = path.join(fixture, 'observed.json');
|
||||
const playwrightEntry = path.join(fixture, 'playwright.cjs');
|
||||
writeFileSync(playwrightEntry, `
|
||||
exports.chromium = {
|
||||
async launchPersistentContext(userDataDir, options) {
|
||||
require('node:fs').writeFileSync(${JSON.stringify(observation)}, JSON.stringify({ userDataDir, options, nodeVersion: process.version }));
|
||||
if (${JSON.stringify(mode)} === 'locked') throw new Error('ProcessSingleton sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'policy') throw new Error('Remote debugging requires a non-default data directory sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'failure') throw new Error('sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'process-exit') throw new Error('<process did exit: exitCode=${exitCode}, signal=null> sensitive-sentinel');
|
||||
return { cookies: async () => ${JSON.stringify(cookies)}, close: async () => {} };
|
||||
},
|
||||
};
|
||||
`);
|
||||
const userDataDir = path.join(fixture, 'profile');
|
||||
mkdirSync(userDataDir);
|
||||
const result = spawnSync(node!, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
|
||||
env: isolatedEnv(),
|
||||
input: JSON.stringify({ playwrightEntry, userDataDir, executablePath: 'C:\\Apps\\Microsoft\\Edge\\Application\\msedge.exe', profile: 'Profile 2', domains: ['EXAMPLE.TEST.'], deadline: Date.now() + 25_000 }),
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'node_input' });
|
||||
expect(progress).toContainEqual({ stage: 'node_load' });
|
||||
return { result: JSON.parse(result.stdout), observation: JSON.parse(readFileSync(observation, 'utf8')), userDataDir };
|
||||
}
|
||||
|
||||
describe('production Node Playwright worker', () => {
|
||||
test('real synthetic Playwright pipe smoke uses no TCP and leaves no browser', () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require('playwright');
|
||||
const fixture = mkdtempSync(path.join(root, 'pipe-'));
|
||||
const observation = path.join(fixture, 'launch.json');
|
||||
const playwrightEntry = path.join(fixture, 'instrumented-playwright.cjs');
|
||||
writeFileSync(playwrightEntry, `
|
||||
const cp = require('node:child_process');
|
||||
const spawn = cp.spawn;
|
||||
cp.spawn = function(command, args, options) {
|
||||
const child = spawn.call(this, command, args, options);
|
||||
require('node:fs').writeFileSync(${JSON.stringify(observation)}, JSON.stringify({ args, pid: child.pid }));
|
||||
return child;
|
||||
};
|
||||
const { chromium } = require(${JSON.stringify(require.resolve('playwright'))});
|
||||
exports.chromium = { async launchPersistentContext(root, options) {
|
||||
const context = await chromium.launchPersistentContext(root, options);
|
||||
await context.addCookies([{ name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/' }]);
|
||||
return context;
|
||||
} };
|
||||
`);
|
||||
const result = spawnSync(node!, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
|
||||
env: isolatedEnv(),
|
||||
input: JSON.stringify({ playwrightEntry, userDataDir: path.join(fixture, 'User Data'), executablePath: chromium.executablePath(), profile: 'Default', domains: ['example.test'], deadline: Date.now() + 25_000 }),
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'browser_launch' });
|
||||
expect(progress).toContainEqual({ stage: 'cookie_read' });
|
||||
expect(JSON.parse(result.stdout).cookies).toHaveLength(1);
|
||||
const launched = JSON.parse(readFileSync(observation, 'utf8'));
|
||||
expect(launched.args).toContain('--remote-debugging-pipe');
|
||||
expect(launched.args.some((arg: string) => arg.startsWith('--remote-debugging-port'))).toBe(false);
|
||||
expect(launched.args.some((arg: string) => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))).toBe(false);
|
||||
expect(launched.args).toContain(`--user-data-dir=${path.join(fixture, 'User Data')}`);
|
||||
expect(() => process.kill(launched.pid, 0)).toThrow();
|
||||
}, 35_000);
|
||||
|
||||
test('launches the requested profile once through Playwright with no TCP or bypass arguments', () => {
|
||||
const { result, observation, userDataDir } = runNodeWorker('success');
|
||||
expect(result).toEqual({ cookies: [] });
|
||||
expect(observation.userDataDir).toBe(userDataDir);
|
||||
expect(observation.options).toMatchObject({
|
||||
executablePath: 'C:\\Apps\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
args: ['--profile-directory=Profile 2'],
|
||||
handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false,
|
||||
headless: true,
|
||||
chromiumSandbox: true,
|
||||
});
|
||||
expect(observation.options.timeout).toBeGreaterThan(0);
|
||||
expect(observation.options.timeout).toBeLessThanOrEqual(25_000);
|
||||
expect(observation.nodeVersion).toStartWith('v');
|
||||
expect(Object.keys(observation.options.env)).not.toContain('NODE_OPTIONS');
|
||||
});
|
||||
|
||||
test('bare/dotted matching preserves scope and cookie attributes', () => {
|
||||
const cookie = { name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/', httpOnly: true, secure: true, sameSite: 'Lax', expires: -1 };
|
||||
const selected = [cookie, { ...cookie, domain: '.example.test' }];
|
||||
const { result } = runNodeWorker('success', [...selected, { ...cookie, domain: 'other.example.test' }, { ...cookie, domain: 'badexample.test' }]);
|
||||
expect(result.cookies).toEqual(selected);
|
||||
});
|
||||
|
||||
test.each([['locked', 'browser_running'], ['policy', 'native_profile_unsupported'], ['failure', 'native_failed']])('classifies %s without leaking browser stderr or retrying', (mode, error) => {
|
||||
const { result } = runNodeWorker(mode);
|
||||
expect(result).toEqual({ error, diagnostic: { stage: 'browser_launch' } });
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test('reports only the managed browser exit code, not raw launch errors', () => {
|
||||
const { result } = runNodeWorker('process-exit');
|
||||
expect(result).toEqual({ error: 'browser_running', diagnostic: { stage: 'browser_launch', exitCode: 21 } });
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test.each([0, 1, 20, 22, 24, -1, -1073741819])('does not classify unrelated browser exit %d as a profile lock', exitCode => {
|
||||
const { result } = runNodeWorker('process-exit', [], exitCode);
|
||||
expect(result).toEqual({ error: 'native_failed', diagnostic: { stage: 'browser_launch', exitCode } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'cookie-node-')));
|
||||
const bundle = path.join(root, 'importer.mjs');
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node.js is required for importer runtime coverage');
|
||||
|
||||
beforeAll(() => {
|
||||
const build = spawnSync(process.execPath, ['build', path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'), '--target=node', '--outfile', bundle], {
|
||||
encoding: 'utf8', timeout: 30_000,
|
||||
});
|
||||
expect(build.status).toBe(0);
|
||||
const profile = path.join(root, '.config/chromium/Default');
|
||||
mkdirSync(profile, { recursive: true });
|
||||
expect(realpathSync(profile).startsWith(root + path.sep)).toBe(true);
|
||||
const dbPath = path.join(profile, 'Cookies');
|
||||
const database = new Database(dbPath);
|
||||
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
database.run("INSERT INTO cookies VALUES ('.fixture.test', 'synthetic', 'fixture-value', x'', '/', 0, 0, 1, 0, 1)");
|
||||
database.close();
|
||||
const windows = path.join(root, 'AppData/Local/Chromium/User Data/Default');
|
||||
mkdirSync(windows, { recursive: true });
|
||||
expect(realpathSync(windows).startsWith(root + path.sep)).toBe(true);
|
||||
copyFileSync(dbPath, path.join(windows, 'Cookies'));
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('actual Node importer runtime', () => {
|
||||
test('lists and imports cookies through the bundled production module', () => {
|
||||
const child = spawnSync(node!, ['--input-type=module', '-e', `
|
||||
const { listDomains, importCookies } = await import(process.argv[1]);
|
||||
const domains = listDomains('chromium');
|
||||
const result = await importCookies('chromium', ['fixture.test']);
|
||||
console.log(JSON.stringify({ domains, count: result.count, failed: result.failed, scope: Object.keys(result.domainCounts), cookieName: result.cookies[0]?.name }));
|
||||
`, pathToFileURL(bundle).href], {
|
||||
encoding: 'utf8', timeout: 15_000,
|
||||
env: { HOME: root, USERPROFILE: root, LOCALAPPDATA: path.join(root, 'AppData/Local'), TEMP: root, TMP: root, NODE_NO_WARNINGS: '1', PATH: path.dirname(node!), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) },
|
||||
});
|
||||
expect(child.error).toBeUndefined();
|
||||
expect(child.status).toBe(0);
|
||||
expect(child.stderr).toBe('');
|
||||
expect(JSON.parse(child.stdout)).toEqual({ domains: { browser: 'Chromium', domains: [{ domain: '.fixture.test', count: 1 }] }, count: 1, failed: 0, scope: ['.fixture.test'], cookieName: 'synthetic' });
|
||||
});
|
||||
|
||||
test('Node server build does not stub away the database', () => {
|
||||
const script = readFileSync(path.resolve(import.meta.dir, '../scripts/build-node-server.sh'), 'utf8');
|
||||
expect(script).not.toContain('const Database = null');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
import { getCookieProfiles, parseCookieImportArgs, runCookieImport } from '../src/cookie-import-operation';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
import { handleReadCommand } from '../src/read-commands';
|
||||
import { handleWriteCommand } from '../src/write-commands';
|
||||
import * as importer from '../src/cookie-import-browser';
|
||||
|
||||
let home: string;
|
||||
let homeMock: ReturnType<typeof spyOn>;
|
||||
let currentUrl: string;
|
||||
let page: any;
|
||||
let context: any;
|
||||
let session: any;
|
||||
let bm: BrowserManager;
|
||||
let cdp: any;
|
||||
|
||||
function installProfile(name = 'Default', domain = '.example.test', badCookie = false) {
|
||||
const dir = path.join(home, '.config/chromium', name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(home) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [domain, 'session', 'synthetic-session', Buffer.alloc(0), '/']);
|
||||
if (badCookie) db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [domain, 'broken', '', Buffer.from('v20synthetic'), '/']);
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function route(method: string, pathname: string, body?: unknown, cookie?: string) {
|
||||
const url = new URL('http://127.0.0.1:9470/cookie-picker' + pathname);
|
||||
let pickerInstance = '';
|
||||
if (cookie) {
|
||||
const document = await handleCookiePickerRoute(new URL(url.origin + '/cookie-picker'), new Request(url.origin + '/cookie-picker', {
|
||||
headers: { Cookie: cookie },
|
||||
}), bm, 'fixture');
|
||||
const html = await document.text();
|
||||
pickerInstance = JSON.parse(html.match(/<script id="picker-config" type="application\/json">(.*?)<\/script>/s)![1]).pickerInstance;
|
||||
}
|
||||
return handleCookiePickerRoute(url, new Request(url, {
|
||||
method,
|
||||
headers: cookie ? { Cookie: cookie, Origin: url.origin, 'Content-Type': 'application/json', 'X-Gstack-Picker-Instance': pickerInstance } : { Authorization: 'Bearer fixture', 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}), bm, 'fixture');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-op-'));
|
||||
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
currentUrl = 'https://example.test/protected';
|
||||
const cdpEvents = new EventEmitter();
|
||||
const pageEvents = new EventEmitter();
|
||||
const frame = {};
|
||||
cdp = {
|
||||
on: cdpEvents.on.bind(cdpEvents), off: cdpEvents.off.bind(cdpEvents), detach: mock(async () => {}),
|
||||
send: mock(async (method: string, params: any) => {
|
||||
if (method === 'Page.getFrameTree') return { frameTree: { frame: { id: 'fixture-frame' } } };
|
||||
if (method === 'Runtime.enable') return {};
|
||||
if (method === 'Page.createIsolatedWorld') {
|
||||
cdpEvents.emit('Runtime.executionContextCreated', { context: { name: params.worldName, id: 7, uniqueId: 'fixture-world', auxData: { frameId: 'fixture-frame', isDefault: false } } });
|
||||
return { executionContextId: 7 };
|
||||
}
|
||||
if (method === 'Runtime.evaluate') return { result: { value: 100 } };
|
||||
if (method === 'Runtime.callFunctionOn') return { result: { value: 'cleared' } };
|
||||
throw new Error('Unexpected protocol method');
|
||||
}),
|
||||
};
|
||||
context = { addCookies: mock(async () => {}), clearCookies: mock(async () => {}),
|
||||
browser: () => ({ browserType: () => ({ name: () => 'chromium' }) }), newCDPSession: mock(async () => cdp) };
|
||||
page = {
|
||||
url: () => currentUrl,
|
||||
isClosed: () => false,
|
||||
context: () => context,
|
||||
evaluate: mock(async () => 'cleared'),
|
||||
reload: mock(async () => { throw new Error('Should not reload'); }),
|
||||
mainFrame: () => frame, on: pageEvents.on.bind(pageEvents), off: pageEvents.off.bind(pageEvents),
|
||||
};
|
||||
session = { getPage: () => page, getFrame: () => null, getActiveFrameOrPage: () => page };
|
||||
bm = new BrowserManager();
|
||||
spyOn(bm, 'getActiveSession').mockReturnValue(session);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
homeMock.mockRestore();
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 300_000 + 3_600_000 + 1;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('cookie picker mutation origin policy', () => {
|
||||
for (const action of ['import', 'remove']) {
|
||||
for (const origin of [undefined, 'null', 'http://127.0.0.1:9988', 'http://localhost:9470', 'https://attacker.test']) {
|
||||
test(`${action} rejects session mutation from ${origin ?? 'missing origin'} before touching the target`, async () => {
|
||||
installProfile();
|
||||
const code = generatePickerCode({ target: { page, url: currentUrl } });
|
||||
const exchanged = await route('GET', `?code=${code}`);
|
||||
const cookie = exchanged.headers.get('set-cookie')!;
|
||||
const url = new URL(`http://127.0.0.1:9470/cookie-picker/${action}`);
|
||||
const response = await handleCookiePickerRoute(url, new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie, 'Content-Type': 'text/plain', 'Sec-Fetch-Site': 'same-site', ...(origin === undefined ? {} : { Origin: origin }) },
|
||||
body: JSON.stringify({ browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true }),
|
||||
}), bm, 'fixture');
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.json()).code).toBe('invalid_origin');
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(context.clearCookies).not.toHaveBeenCalled();
|
||||
expect(cdp.send).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test(`${action} accepts the real picker origin and preserves bearer authorization without browser headers`, async () => {
|
||||
installProfile();
|
||||
const code = generatePickerCode({ target: { page, url: currentUrl } });
|
||||
const exchanged = await route('GET', `?code=${code}`);
|
||||
const cookie = exchanged.headers.get('set-cookie')!;
|
||||
const body = { browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true };
|
||||
expect((await route('POST', `/${action}`, body, cookie)).status).toBe(200);
|
||||
expect((await route('POST', `/${action}`, body)).status).toBe(200);
|
||||
if (action === 'import') {
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(2);
|
||||
expect(cdp.send.mock.calls.filter(([method]: [string]) => method === 'Runtime.callFunctionOn')).toHaveLength(2);
|
||||
} else {
|
||||
expect(context.clearCookies).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('cookie import argument and selection policy', () => {
|
||||
test('parses flag values independently from the browser position', () => {
|
||||
expect(parseCookieImportArgs(['--domain', '.EXAMPLE.TEST', 'chromium', '--profile', 'Profile 2'])).toEqual({ browser: 'chromium', domains: ['example.test'], profile: 'Profile 2' });
|
||||
expect(parseCookieImportArgs(['--domain', 'example.test'])).toEqual({ browser: 'comet', domains: ['example.test'] });
|
||||
});
|
||||
|
||||
test('rejects missing duplicate unknown and incompatible options', () => {
|
||||
for (const args of [['--domain'], ['--profile', '--all'], ['--unknown'], ['chrome', 'edge'], ['--all', '--domain', 'example.test'], ['--all', '--clear-storage'], ['--all', '--all']]) {
|
||||
expect(() => parseCookieImportArgs(args)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('recommends a unique domain match without overriding explicit profiles', async () => {
|
||||
installProfile();
|
||||
installProfile('Profile 2', '.other.test');
|
||||
expect((await getCookieProfiles('chromium', ['example.test'])).recommendedProfile).toBe('Default');
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Profile 2', domains: ['other.test'] }, { page, url: currentUrl }, () => {});
|
||||
expect(result.profile).toBe('Profile 2');
|
||||
});
|
||||
|
||||
test('does not guess among matching or unreadable profiles', async () => {
|
||||
installProfile();
|
||||
installProfile('Profile 2');
|
||||
expect((await getCookieProfiles('chromium', ['example.test'])).recommendedProfile).toBeUndefined();
|
||||
await expect(runCookieImport({ browser: 'chromium', domains: ['example.test'] }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'profile_required' });
|
||||
fs.writeFileSync(path.join(home, '.config/chromium/Profile 2/Cookies'), 'not a database');
|
||||
const profiles = await getCookieProfiles('chromium', ['example.test']);
|
||||
expect(profiles.recommendedProfile).toBeUndefined();
|
||||
expect(profiles.profiles.find(profile => profile.name === 'Profile 2')?.unavailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registered import callers', () => {
|
||||
test('picker applies cookies and activates the actual downstream JS-origin guard', async () => {
|
||||
installProfile();
|
||||
const response = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['example.test'] });
|
||||
expect(response.status).toBe(200);
|
||||
const receipt = await response.json();
|
||||
expect(receipt.imported).toBe(1);
|
||||
expect(receipt.verification.reason).toBe('not_requested');
|
||||
expect(bm.getCookieImportedDomains().has('.example.test')).toBe(true);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(page.reload).not.toHaveBeenCalled();
|
||||
currentUrl = 'https://unrelated.test/';
|
||||
await expect(handleReadCommand('js', ['document.cookie'], session, bm)).rejects.toThrow('JS execution blocked');
|
||||
});
|
||||
|
||||
test('direct command imports both domain spellings and preserves the domain guard', async () => {
|
||||
installProfile();
|
||||
const result = await handleWriteCommand('cookie-import-browser', ['--domain', 'example.test', 'chromium'], session, bm);
|
||||
expect(result).toContain('Imported 1 cookies');
|
||||
expect(result).toContain('Authentication: not_requested');
|
||||
expect(bm.hasCookieImports()).toBe(true);
|
||||
await expect(handleWriteCommand('cookie-import-browser', ['chromium', '--domain', 'other.test'], session, bm)).rejects.toMatchObject({ code: 'target_mismatch' });
|
||||
});
|
||||
|
||||
test('returns partial and zero receipts without exposing cookie values', async () => {
|
||||
installProfile('Default', '.example.test', true);
|
||||
const response = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['example.test'] });
|
||||
const text = await response.text();
|
||||
const receipt = JSON.parse(text);
|
||||
expect(receipt.outcome).toBe('partial');
|
||||
expect(receipt.imported).toBe(1);
|
||||
expect(receipt.failed).toBe(1);
|
||||
expect(text).not.toContain('synthetic-session');
|
||||
const empty = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['missing.test'] });
|
||||
expect(await empty.json()).toMatchObject({ imported: 0, outcome: 'empty' });
|
||||
});
|
||||
|
||||
test('native fallback cannot erase unresolved source-cookie failures', async () => {
|
||||
installProfile();
|
||||
const db = new Database(path.join(home, '.config/chromium/Default/Cookies'));
|
||||
db.run("UPDATE cookies SET value = '', encrypted_value = ?", [Buffer.from('v20synthetic')]);
|
||||
db.close();
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
const native = spyOn(importer, 'importCookiesViaCdp').mockResolvedValue({ cookies: [], count: 0, failed: 0, domainCounts: {} });
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
try {
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {});
|
||||
expect(native).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({ imported: 0, failed: 1, outcome: 'failed', failureReasons: { native_unrecovered: 1 } });
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
native.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('all-domain mode requires explicit consent and never resets storage', async () => {
|
||||
installProfile();
|
||||
const result = await handleWriteCommand('cookie-import-browser', ['chromium', '--profile', 'Default', '--all'], session, bm);
|
||||
expect(result).toContain('Used --all');
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
await expect(handleWriteCommand('cookie-import-browser', ['chromium', '--all', '--clear-storage'], session, bm)).rejects.toMatchObject({ code: 'bad_request' });
|
||||
});
|
||||
|
||||
test('preflights requested verification before importing or resetting', async () => {
|
||||
installProfile();
|
||||
await expect(runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'], verifyAuth: true, clearStorage: true }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'verification_not_configured' });
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects unsupported reset before reading profiles or applying cookies', async () => {
|
||||
context.browser = () => ({ browserType: () => ({ name: () => 'firefox' }) });
|
||||
await expect(runCookieImport({ browser: 'chromium', domains: ['example.test'], clearStorage: true }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'storage_reset_unsupported' });
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ordinary imports on other target engines remain available', async () => {
|
||||
installProfile();
|
||||
context.browser = () => ({ browserType: () => ({ name: () => 'webkit' }) });
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {});
|
||||
expect(result.imported).toBe(1);
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('zero import never resets or reports verified', async () => {
|
||||
installProfile();
|
||||
currentUrl = 'https://empty.example.test/';
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['empty.example.test'], verifyAuth: true }, { page, url: currentUrl }, () => {}, { identitySelector: '.identity', expectedIdentity: 'Synthetic' });
|
||||
expect(result.imported).toBe(0);
|
||||
expect(result.verification.verified).toBe(false);
|
||||
expect(page.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('explicit reset precedes application and reports later application failure', async () => {
|
||||
installProfile();
|
||||
context.addCookies.mockImplementation(async () => { throw new Error('synthetic-sensitive-error'); });
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'], clearStorage: true }, { page, url: currentUrl }, domains => bm.trackCookieImportDomains(domains));
|
||||
expect(cdp.send.mock.calls.filter(([method]: [string]) => method === 'Runtime.callFunctionOn')).toHaveLength(1);
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ reset: 'cleared', imported: 0, outcome: 'failed' });
|
||||
expect(JSON.stringify(result)).not.toContain('synthetic-sensitive-error');
|
||||
expect(bm.hasCookieImports()).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a target switch during the import before a reset', async () => {
|
||||
installProfile();
|
||||
const pending = runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'], clearStorage: true }, { page, url: currentUrl }, () => {});
|
||||
currentUrl = 'https://other.test/';
|
||||
await expect(pending).rejects.toMatchObject({ code: 'target_changed' });
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('serializes context mutations instead of replaying duplicate imports', async () => {
|
||||
installProfile();
|
||||
const blocked = Promise.withResolvers<void>();
|
||||
context.addCookies.mockImplementation(() => blocked.promise);
|
||||
const first = runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {});
|
||||
await expect(runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'import_busy' });
|
||||
blocked.resolve();
|
||||
expect((await first).imported).toBe(1);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('picker session retains its captured destination instead of the newly active page', async () => {
|
||||
installProfile();
|
||||
const code = generatePickerCode({ target: { page, url: currentUrl } });
|
||||
const exchange = await route('GET', '?code=' + code);
|
||||
const cookie = exchange.headers.get('set-cookie')!.split(';')[0];
|
||||
const otherAdd = mock(async () => {});
|
||||
spyOn(bm, 'getActiveSession').mockReturnValue({ getPage: () => ({ context: () => ({ addCookies: otherAdd }), url: () => 'https://other.test/' }) } as any);
|
||||
const response = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['example.test'] }, cookie);
|
||||
expect((await response.json()).imported).toBe(1);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
expect(otherAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('one-time picker codes last five minutes and fail at the expiry boundary', async () => {
|
||||
const now = Date.now;
|
||||
const start = now();
|
||||
const code = generatePickerCode();
|
||||
Date.now = () => start + 299_999;
|
||||
try { expect((await route('GET', '?code=' + code)).status).toBe(302); } finally { Date.now = now; }
|
||||
const expired = generatePickerCode();
|
||||
Date.now = () => now() + 300_000;
|
||||
try { expect((await route('GET', '?code=' + expired)).status).toBe(403); } finally { Date.now = now; }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { findInstalledBrowsers, importCookies, listProfiles, cookieDomainMatches, CookieImportError, normalizeCookieDomain, withCookieReadRetry } from '../src/cookie-import-browser';
|
||||
|
||||
let home: string;
|
||||
let oldHome: string | undefined;
|
||||
let oldUserProfile: string | undefined;
|
||||
let spawn: typeof Bun.spawn;
|
||||
let homeMock: ReturnType<typeof spyOn>;
|
||||
|
||||
function profile(dir: string, name: string, cookieDomain = '.example.test') {
|
||||
const target = path.join(home, dir, name);
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
expect(fs.realpathSync(target).startsWith(fs.realpathSync(home) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(target, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [cookieDomain, 'fixture', 'synthetic-value', Buffer.alloc(0), '/']);
|
||||
db.close();
|
||||
return target;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-wave-'));
|
||||
oldHome = process.env.HOME;
|
||||
oldUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
spawn = Bun.spawn;
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
if (!['secret-tool', 'security'].includes(command[0])) throw new Error('Unexpected fixture subprocess');
|
||||
return { stdout: new Blob(['fixture-password']).stream(), stderr: new Blob([]).stream(), exited: Promise.resolve(0), kill() {} };
|
||||
}) as typeof Bun.spawn;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Bun.spawn = spawn;
|
||||
homeMock.mockRestore();
|
||||
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
|
||||
if (oldUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = oldUserProfile;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('cookie import reliability', () => {
|
||||
test('discovers Dia with only a numbered macOS profile', () => {
|
||||
profile('Library/Application Support/Dia/User Data', 'Profile 2');
|
||||
expect(findInstalledBrowsers().map(browser => browser.name)).toContain('Dia');
|
||||
expect(listProfiles('dia')[0].name).toBe('Profile 2');
|
||||
});
|
||||
|
||||
test('prefers current Local State names and sorts profile numbers naturally', () => {
|
||||
const root = '.config/chromium';
|
||||
for (const name of ['Profile 10', 'Profile 2', 'Default']) {
|
||||
const dir = profile(root, name);
|
||||
fs.writeFileSync(path.join(dir, 'Preferences'), JSON.stringify({ profile: { name: 'Old name' } }));
|
||||
}
|
||||
fs.writeFileSync(path.join(home, root, 'Local State'), JSON.stringify({ profile: { info_cache: { 'Profile 2': { name: 'Current name' } } } }));
|
||||
expect(listProfiles('chromium')).toEqual([
|
||||
{ name: 'Default', displayName: 'Old name' },
|
||||
{ name: 'Profile 2', displayName: 'Current name' },
|
||||
{ name: 'Profile 10', displayName: 'Old name' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('malformed metadata preserves the directory identity', () => {
|
||||
const dir = profile('.config/chromium', 'Default');
|
||||
fs.writeFileSync(path.join(home, '.config/chromium/Local State'), '{');
|
||||
fs.writeFileSync(path.join(dir, 'Preferences'), '{');
|
||||
expect(listProfiles('chromium')).toEqual([{ name: 'Default', displayName: 'Default' }]);
|
||||
});
|
||||
|
||||
test('bare and dotted domain selection import the same stored row without widening scope', async () => {
|
||||
const dir = profile('.config/chromium', 'Default');
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run("INSERT INTO cookies VALUES ('evil-example.test', 'other', 'synthetic-other', x'', '/', 0, 1, 1, 0, 1)");
|
||||
db.close();
|
||||
for (const domain of ['example.test', '.example.test', 'EXAMPLE.TEST.']) {
|
||||
const result = await importCookies('chromium', [domain]);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.cookies[0].domain).toBe('.example.test');
|
||||
}
|
||||
});
|
||||
|
||||
test('reports partial decrypt reasons without error or cookie values', async () => {
|
||||
const dir = profile('.config/chromium', 'Default');
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run("INSERT INTO cookies VALUES ('.example.test', 'broken', '', ?, '/', 0, 1, 1, 0, 1)", [Buffer.from('v20synthetic')]);
|
||||
db.close();
|
||||
const result = await importCookies('chromium', ['example.test']);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.failureReasons).toEqual({ unsupported_encryption: 1 });
|
||||
});
|
||||
|
||||
test('domain counts do not inherit object prototype properties', async () => {
|
||||
profile('.config/chromium', 'Default', 'constructor');
|
||||
const result = await importCookies('chromium', ['constructor']);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.domainCounts.constructor).toBe(1);
|
||||
expect(JSON.stringify(result.domainCounts)).toBe('{"constructor":1}');
|
||||
});
|
||||
|
||||
test('domain matching preserves host-only boundaries and rejects malformed input', () => {
|
||||
expect(cookieDomainMatches('app.example.test', '.example.test')).toBe(true);
|
||||
expect(cookieDomainMatches('app.example.test', 'example.test')).toBe(false);
|
||||
expect(cookieDomainMatches('evil-example.test', '.example.test')).toBe(false);
|
||||
expect(normalizeCookieDomain('.EXAMPLE.TEST.')).toBe('example.test');
|
||||
expect(normalizeCookieDomain('service_name.example.test')).toBe('service_name.example.test');
|
||||
expect(normalizeCookieDomain('-service.example.test')).toBe('-service.example.test');
|
||||
expect(normalizeCookieDomain('::1')).toBe('[::1]');
|
||||
expect(normalizeCookieDomain('[0:0:0:0:0:0:0:1]')).toBe('[::1]');
|
||||
expect(cookieDomainMatches('[::1]', '[::1]')).toBe(true);
|
||||
for (const domain of ['', 'https://example.test', 'example.test/path', 'user@example.test', '..example.test', 'example.test:80', '*.example.test']) {
|
||||
expect(() => normalizeCookieDomain(domain)).toThrow(CookieImportError);
|
||||
}
|
||||
});
|
||||
|
||||
for (const domain of ['service_name.example.test', '[::1]', '-service.example.test']) {
|
||||
test(`imports the Chromium-supported hostname ${domain}`, async () => {
|
||||
profile('.config/chromium', 'Default', domain);
|
||||
const result = await importCookies('chromium', [domain]);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.cookies[0].domain).toBe(domain);
|
||||
});
|
||||
}
|
||||
|
||||
test('does not automatically retry permission denial or corrupt databases', async () => {
|
||||
for (const code of ['keychain_denied', 'keychain_timeout', 'db_corrupt']) {
|
||||
let attempts = 0;
|
||||
await expect(withCookieReadRetry(() => {
|
||||
attempts++;
|
||||
throw new CookieImportError('Safe fixture error', code, 'retry');
|
||||
})).rejects.toThrow('Safe fixture error');
|
||||
expect(attempts).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes adapter failures without exposing database error text', async () => {
|
||||
for (const [source, expected] of [['SQLITE_CORRUPT', 'db_corrupt'], ['SQLITE_READONLY', 'db_permission'], ['SQLITE_ERROR', 'db_read_error']]) {
|
||||
let caught: any;
|
||||
try { await withCookieReadRetry(() => { throw Object.assign(new Error('synthetic-private-db-detail'), { code: source }); }); }
|
||||
catch (error) { caught = error; }
|
||||
expect(caught).toBeInstanceOf(CookieImportError);
|
||||
expect(caught.code).toBe(expected);
|
||||
expect(caught.message).not.toContain('synthetic-private-db-detail');
|
||||
}
|
||||
});
|
||||
|
||||
test('actual Keychain denial is sanitized, never repeated, and clears its deadline', async () => {
|
||||
const dir = profile('Library/Application Support/Dia/User Data', 'Default');
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run("UPDATE cookies SET value = '', encrypted_value = ?", [Buffer.from('v10synthetic-encrypted-row')]);
|
||||
db.close();
|
||||
let requests = 0;
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
expect(command).toEqual(['security', 'find-generic-password', '-s', 'Dia Safe Storage', '-w']);
|
||||
requests++;
|
||||
return { stdout: new Blob([]).stream(), stderr: new Blob(['user canceled synthetic-private-detail']).stream(), exited: Promise.resolve(1), kill() {} };
|
||||
}) as typeof Bun.spawn;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockReturnValue(123 as any);
|
||||
const clear = spyOn(globalThis, 'clearTimeout').mockImplementation(() => {});
|
||||
try {
|
||||
let error: any;
|
||||
try { await withCookieReadRetry(() => importCookies('dia', ['example.test'])); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('keychain_denied');
|
||||
expect(error.message).not.toContain('synthetic-private-detail');
|
||||
expect(requests).toBe(1);
|
||||
expect(clear).toHaveBeenCalledWith(123);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
clear.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('bounds transient database retries to three attempts', async () => {
|
||||
let attempts = 0;
|
||||
await expect(withCookieReadRetry(() => {
|
||||
attempts++;
|
||||
throw new CookieImportError('Locked', 'db_locked', 'retry');
|
||||
})).rejects.toThrow('Locked');
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { sendCommand } from '../src/cli';
|
||||
|
||||
afterEach(() => mock.restore());
|
||||
|
||||
describe('cookie import CLI transport', () => {
|
||||
const state = { port: 9470, token: 'synthetic-fixture' } as any;
|
||||
|
||||
test('allows the bounded import stages to finish without changing other command budgets', async () => {
|
||||
const budgets: number[] = [];
|
||||
spyOn(AbortSignal, 'timeout').mockImplementation(ms => {
|
||||
budgets.push(ms);
|
||||
return new AbortController().signal;
|
||||
});
|
||||
spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('Synthetic receipt'));
|
||||
const output = spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await sendCommand(state, 'cookie-import-browser', ['chromium', '--domain', 'example.test']);
|
||||
await sendCommand(state, 'text', []);
|
||||
expect(budgets).toEqual([90_000, 30_000]);
|
||||
expect(output).toHaveBeenCalledWith('Synthetic receipt');
|
||||
});
|
||||
|
||||
for (const failure of ['ECONNRESET', 'ECONNREFUSED', 'AbortError', 'TimeoutError', 'fetch failed']) {
|
||||
test(`does not replay an import after ${failure}`, async () => {
|
||||
const request = spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
throw Object.assign(new Error(failure), { code: failure, name: failure });
|
||||
});
|
||||
await expect(sendCommand(state, 'cookie-import-browser', ['chromium', '--all'])).rejects.toThrow('was not replayed');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('does not automatically retry cookie import after an authorization change', async () => {
|
||||
const request = spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('Unauthorized', { status: 401 }));
|
||||
await expect(sendCommand(state, 'cookie-import-browser', ['chromium', '--all'])).rejects.toThrow('retry manually');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
|
||||
for (const sameOrigin of [false, true]) {
|
||||
test(`two real picker windows cannot reset the other ${sameOrigin ? 'same-origin tab' : 'same-host different-port origin'}`, async () => {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), 'picker-binding-'));
|
||||
const profile = path.join(home, '.config/chromium/Default');
|
||||
mkdirSync(profile, { recursive: true });
|
||||
expect(realpathSync(profile).startsWith(realpathSync(home) + path.sep)).toBe(true);
|
||||
const database = new Database(path.join(profile, 'Cookies'));
|
||||
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
database.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 0, 1, 0, 1)', ['127.0.0.1', 'fixture', 'synthetic', Buffer.alloc(0), '/']);
|
||||
database.close();
|
||||
const serveTarget = () => Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
|
||||
const authenticated = (request.headers.get('cookie') ?? '').split(';').some(value => value.trim() === 'fixture=synthetic');
|
||||
return new Response(authenticated ? '<div id="fixture-identity">Synthetic account</div>' : '<div>Not signed in</div>', {
|
||||
headers: { 'Content-Type': 'text/html' }, status: authenticated ? 200 : 401,
|
||||
});
|
||||
} });
|
||||
const firstServer = serveTarget();
|
||||
const secondServer = sameOrigin ? firstServer : serveTarget();
|
||||
let browser: Browser | undefined;
|
||||
let picker: ReturnType<typeof Bun.serve> | undefined;
|
||||
let homeMock: ReturnType<typeof spyOn> | undefined;
|
||||
const selector = process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
const identity = process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
process.env.GSTACK_COOKIE_AUTH_SELECTOR = '#fixture-identity';
|
||||
process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = 'Synthetic account';
|
||||
const destination = await browser.newContext();
|
||||
destination.setDefaultTimeout(5_000);
|
||||
const targetA = await destination.newPage();
|
||||
const targetB = await destination.newPage();
|
||||
await targetA.goto(`http://127.0.0.1:${firstServer.port}/a`);
|
||||
await targetB.goto(`http://127.0.0.1:${secondServer.port}/b`);
|
||||
for (const target of [targetA, targetB]) {
|
||||
await target.evaluate(() => { localStorage.setItem('keep', 'preserved'); sessionStorage.setItem('keep', 'preserved'); });
|
||||
}
|
||||
const bm = { getActiveSession: () => ({ getPage: () => targetA }), trackCookieImportDomains() {} } as any;
|
||||
picker = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: request => handleCookiePickerRoute(new URL(request.url), request, bm) });
|
||||
const pickerOrigin = `http://127.0.0.1:${picker.port}`;
|
||||
const client = await browser.newContext();
|
||||
client.setDefaultTimeout(5_000);
|
||||
const windowA = await client.newPage();
|
||||
const windowB = await client.newPage();
|
||||
const importButton = /^(?:Import|Reimport) 127\.0\.0\.1$/;
|
||||
const errors: string[] = [];
|
||||
for (const window of [windowA, windowB]) window.on('pageerror', error => errors.push(error.message));
|
||||
const open = async (window: typeof windowA, target: typeof targetA) => {
|
||||
const code = generatePickerCode({ browser: 'Chromium', target: { page: target, url: target.url() } });
|
||||
await window.goto(pickerOrigin + '/cookie-picker?code=' + code);
|
||||
await window.getByRole('button', { name: importButton }).waitFor();
|
||||
await window.locator('#clear-storage').check();
|
||||
await window.locator('#verify-auth').check();
|
||||
};
|
||||
await open(windowA, targetA);
|
||||
await open(windowB, targetB);
|
||||
await windowA.getByRole('button', { name: importButton }).click();
|
||||
await windowA.getByRole('status').filter({ hasText: 'Reopen the picker from the intended page before continuing.' }).waitFor();
|
||||
for (const target of [targetA, targetB]) {
|
||||
expect(await target.evaluate(() => [localStorage.getItem('keep'), sessionStorage.getItem('keep')])).toEqual(['preserved', 'preserved']);
|
||||
}
|
||||
expect(await destination.cookies()).toEqual([]);
|
||||
expect(await targetA.locator('#fixture-identity').count()).toBe(0);
|
||||
expect(await targetB.locator('#fixture-identity').count()).toBe(0);
|
||||
await windowB.getByRole('button', { name: importButton }).click();
|
||||
await windowB.getByRole('status').filter({ hasText: 'Authentication verified on the captured target.' }).waitFor();
|
||||
expect(await targetB.evaluate(() => [localStorage.getItem('keep'), sessionStorage.getItem('keep')])).toEqual([null, null]);
|
||||
expect(await targetA.evaluate(() => [localStorage.getItem('keep'), sessionStorage.getItem('keep')])).toEqual([sameOrigin ? null : 'preserved', 'preserved']);
|
||||
expect(await targetB.locator('#fixture-identity').innerText()).toBe('Synthetic account');
|
||||
expect(await targetA.locator('#fixture-identity').count()).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
homeMock?.mockRestore();
|
||||
if (selector === undefined) delete process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
else process.env.GSTACK_COOKIE_AUTH_SELECTOR = selector;
|
||||
if (identity === undefined) delete process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
else process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = identity;
|
||||
await browser?.close();
|
||||
picker?.stop(true);
|
||||
firstServer.stop(true);
|
||||
if (!sameOrigin) secondServer.stop(true);
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 3_900_001;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 40_000);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
import * as importer from '../src/cookie-import-browser';
|
||||
import * as operation from '../src/cookie-import-operation';
|
||||
import * as auth from '../src/cookie-auth-verification';
|
||||
|
||||
const origin = 'http://127.0.0.1:9470';
|
||||
let bm: any;
|
||||
let context: any;
|
||||
let reads: ReturnType<typeof spyOn>[];
|
||||
let reset: ReturnType<typeof spyOn>;
|
||||
let verify: ReturnType<typeof spyOn>;
|
||||
let previousSelector: string | undefined;
|
||||
let previousIdentity: string | undefined;
|
||||
|
||||
function page(url: string) {
|
||||
return { url: () => url, isClosed: () => false, context: () => context } as any;
|
||||
}
|
||||
|
||||
async function request(path: string, cookie?: string, instance?: string, body?: unknown, bearer?: string) {
|
||||
const url = new URL(origin + '/cookie-picker' + path);
|
||||
return handleCookiePickerRoute(url, new Request(url, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: { Origin: origin, 'Content-Type': 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
...(instance ? { 'X-Gstack-Picker-Instance': instance } : {}),
|
||||
...(bearer ? { Authorization: 'Bearer ' + bearer } : {}) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
}), bm, 'fixture-bearer');
|
||||
}
|
||||
|
||||
async function open(target: any) {
|
||||
const code = generatePickerCode({ target: { page: target, url: target.url() } });
|
||||
const exchanged = await request('?code=' + code);
|
||||
expect(exchanged.status).toBe(302);
|
||||
const cookie = exchanged.headers.get('set-cookie')!.split(';')[0];
|
||||
const response = await request('', cookie);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
const config = JSON.parse(html.match(/<script id="picker-config" type="application\/json">(.*?)<\/script>/s)![1]);
|
||||
return { cookie, config, html, code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
previousSelector = process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
previousIdentity = process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
process.env.GSTACK_COOKIE_AUTH_SELECTOR = '#fixture-identity';
|
||||
process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = 'Synthetic account';
|
||||
context = { addCookies: mock(async () => {}), clearCookies: mock(async () => {}),
|
||||
browser: () => ({ browserType: () => ({ name: () => 'chromium' }) }) };
|
||||
bm = { getActiveSession: () => ({ getPage: () => page('https://example.test/current') }), trackCookieImportDomains: mock(() => {}) };
|
||||
reads = [
|
||||
spyOn(importer, 'findInstalledBrowsers').mockReturnValue([{ name: 'Chromium', aliases: ['chromium'] }] as any),
|
||||
spyOn(operation, 'getCookieProfiles').mockResolvedValue({ profiles: [{ name: 'Default', displayName: 'Synthetic' }], recommendedProfile: 'Default' }),
|
||||
spyOn(importer, 'listDomains').mockReturnValue({ browser: 'Chromium', domains: [{ domain: '.example.test', count: 1 }] }),
|
||||
spyOn(importer, 'importCookies').mockResolvedValue({ cookies: [{ name: 'fixture', value: 'synthetic', domain: '.example.test', path: '/',
|
||||
expires: -1, secure: true, httpOnly: true, sameSite: 'Lax' }], count: 1, failed: 0, domainCounts: { '.example.test': 1 } }),
|
||||
];
|
||||
reset = spyOn(auth, 'clearCookieTargetStorage').mockResolvedValue(undefined);
|
||||
verify = spyOn(auth, 'verifyCookieAuthentication').mockResolvedValue({ verified: true, reason: 'verified' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
if (previousSelector === undefined) delete process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
else process.env.GSTACK_COOKIE_AUTH_SELECTOR = previousSelector;
|
||||
if (previousIdentity === undefined) delete process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
else process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = previousIdentity;
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 3_900_001;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
});
|
||||
|
||||
describe('cookie picker document binding', () => {
|
||||
test('renders independent instance identifiers without exposing authentication credentials', async () => {
|
||||
const first = await open(page('https://example.test/a'));
|
||||
const second = await open(page('https://example.test/b'));
|
||||
for (const picker of [first, second]) {
|
||||
expect(picker.config.pickerInstance).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(picker.html).not.toContain(picker.cookie.slice('gstack_picker='.length));
|
||||
expect(picker.html).not.toContain(picker.code);
|
||||
expect(picker.html).not.toContain('fixture-bearer');
|
||||
}
|
||||
expect(first.config.pickerInstance).not.toBe(second.config.pickerInstance);
|
||||
});
|
||||
|
||||
test('missing and forged instance headers fail before discovery or import', async () => {
|
||||
const picker = await open(page('https://example.test/a'));
|
||||
for (const instance of [undefined, 'forged', picker.cookie.slice('gstack_picker='.length)]) {
|
||||
for (const [path, payload] of [['/browsers'], ['/import', { browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true, verifyAuth: true }]] as const) {
|
||||
const response = await request(path, picker.cookie, instance, payload);
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.json()).code).toBe('picker_changed');
|
||||
}
|
||||
}
|
||||
for (const read of reads) expect(read).not.toHaveBeenCalled();
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
expect(verify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a rendered instance identifier never authorizes an unauthenticated request', async () => {
|
||||
const picker = await open(page('https://example.test/a'));
|
||||
for (const cookie of [undefined, 'gstack_picker=' + picker.config.pickerInstance]) {
|
||||
const response = await request('/browsers', cookie, picker.config.pickerInstance);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ error: 'Unauthorized' });
|
||||
}
|
||||
for (const read of reads) expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('bearer authorization ignores an unrelated cookie and stale picker identifier', async () => {
|
||||
const first = await open(page('https://example.test/a'));
|
||||
const second = await open(page('https://example.test/b'));
|
||||
const current = page('https://example.test/current');
|
||||
bm.getActiveSession = () => ({ getPage: () => current });
|
||||
const response = await request('/import', second.cookie, first.config.pickerInstance,
|
||||
{ browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true, verifyAuth: true }, 'fixture-bearer');
|
||||
expect(response.status).toBe(200);
|
||||
expect(reset).toHaveBeenCalledWith(current, 'https://example.test');
|
||||
expect(verify).toHaveBeenCalledWith(current, { identitySelector: '#fixture-identity', expectedIdentity: 'Synthetic account' }, 'https://example.test');
|
||||
});
|
||||
|
||||
for (const [scenario, firstUrl, secondUrl] of [
|
||||
['same-host different-port origins', 'https://example.test:8443/a', 'https://example.test:9443/b'],
|
||||
['same-origin different tabs', 'https://example.test/a', 'https://example.test/b'],
|
||||
]) {
|
||||
test(`rejects an old window before reads or mutations for ${scenario}`, async () => {
|
||||
const first = await open(page(firstUrl));
|
||||
const target = page(secondUrl);
|
||||
const second = await open(target);
|
||||
const body = { browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true, verifyAuth: true };
|
||||
const calls: Array<[string, unknown?]> = [['/browsers'], ['/profiles?browser=Chromium'], ['/domains?browser=Chromium&profile=Default'],
|
||||
['/imported'], ['/import', body], ['/remove', { domains: ['example.test'] }]];
|
||||
for (const [path, payload] of calls) {
|
||||
const response = await request(path, second.cookie, first.config.pickerInstance, payload);
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toMatchObject({ code: 'picker_changed', error: expect.stringContaining('Reopen') });
|
||||
}
|
||||
for (const read of reads) expect(read).not.toHaveBeenCalled();
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
expect(verify).not.toHaveBeenCalled();
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(context.clearCookies).not.toHaveBeenCalled();
|
||||
expect(first.config.targetOrigin).toBe(new URL(firstUrl).origin);
|
||||
for (const [path, payload] of calls) {
|
||||
expect((await request(path, second.cookie, second.config.pickerInstance, payload)).status).toBe(200);
|
||||
}
|
||||
for (const read of reads) expect(read).toHaveBeenCalledTimes(1);
|
||||
expect(reset).toHaveBeenCalledWith(target, new URL(secondUrl).origin);
|
||||
expect(verify).toHaveBeenCalledWith(target, { identitySelector: '#fixture-identity', expectedIdentity: 'Synthetic account' }, new URL(secondUrl).origin);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
expect(context.clearCookies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
|
||||
test('a same-site cross-port browser request carries the picker cookie but cannot mutate the session', async () => {
|
||||
let removed = 0;
|
||||
const observed: Array<{ origin: string | null; cookiePresent: boolean; status: number; contentType: string | null }> = [];
|
||||
const bm = { getActiveSession: () => ({ getPage: () => ({ context: () => ({ clearCookies: async () => { removed++; } }) }) }) } as any;
|
||||
const picker = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(request) {
|
||||
const response = await handleCookiePickerRoute(new URL(request.url), request, bm);
|
||||
if (request.method === 'POST') observed.push({ origin: request.headers.get('origin'), cookiePresent: /(?:^|;\s*)gstack_picker=/.test(request.headers.get('cookie') ?? ''), status: response.status, contentType: request.headers.get('content-type') });
|
||||
if (request.method === 'GET' && response.status === 200) {
|
||||
const config = (await response.text()).match(/<script id="picker-config" type="application\/json">.*?<\/script>/s)![0];
|
||||
return new Response('<title>Synthetic authorized picker</title>' + config, { headers: { 'Content-Type': 'text/html' } });
|
||||
}
|
||||
return response;
|
||||
} });
|
||||
const attacker = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('<title>Synthetic cross-port source</title>', { headers: { 'Content-Type': 'text/html' } }) });
|
||||
let browser: Browser | undefined;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
const pickerOrigin = `http://127.0.0.1:${picker.port}`;
|
||||
const attackerOrigin = `http://127.0.0.1:${attacker.port}`;
|
||||
await page.goto(`${pickerOrigin}/cookie-picker?code=${generatePickerCode()}`);
|
||||
const sameOriginStatus = await page.evaluate(async () => (await fetch('/cookie-picker/remove', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json',
|
||||
'X-Gstack-Picker-Instance': JSON.parse(document.getElementById('picker-config')!.textContent!).pickerInstance }, body: JSON.stringify({ domains: ['synthetic.test'] }),
|
||||
})).status);
|
||||
expect(sameOriginStatus).toBe(200);
|
||||
expect(removed).toBe(1);
|
||||
observed.length = 0;
|
||||
await page.goto(attackerOrigin);
|
||||
for (const action of ['import', 'remove']) {
|
||||
await page.evaluate(async ({ target, action }) => {
|
||||
await fetch(`${target}/cookie-picker/${action}`, { method: 'POST', mode: 'no-cors', credentials: 'include',
|
||||
headers: { 'Content-Type': 'text/plain' }, body: JSON.stringify({ browser: 'Chromium', domains: ['synthetic.test'], clearStorage: true }) });
|
||||
}, { target: pickerOrigin, action });
|
||||
}
|
||||
expect(observed).toEqual([1, 2].map(() => ({ origin: attackerOrigin, cookiePresent: true, status: 403, contentType: 'text/plain' })));
|
||||
expect(removed).toBe(1);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
picker.stop(true);
|
||||
attacker.stop(true);
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 3_600_001;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
}
|
||||
}, 40_000);
|
||||
@@ -378,16 +378,20 @@ describe('cookie-picker-routes', () => {
|
||||
|
||||
expect(html).not.toContain(authToken);
|
||||
expect(html).not.toContain('AUTH_TOKEN');
|
||||
expect(html).not.toContain(session);
|
||||
});
|
||||
|
||||
test('data routes accept session cookie', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const session = await getSessionCookie(bm, 'test-token');
|
||||
const document = await handleCookiePickerRoute(makeUrl('/cookie-picker'), makeReq('GET', undefined, { Cookie: `gstack_picker=${session}` }), bm, 'test-token');
|
||||
const html = await document.text();
|
||||
const { pickerInstance } = JSON.parse(html.match(/<script id="picker-config" type="application\/json">(.*?)<\/script>/s)![1]);
|
||||
|
||||
const url = makeUrl('/cookie-picker/browsers');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Cookie': `gstack_picker=${session}` },
|
||||
headers: { 'Cookie': `gstack_picker=${session}`, 'X-Gstack-Picker-Instance': pickerInstance },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { getCookiePickerHTML } from '../src/cookie-picker-ui';
|
||||
|
||||
type PickerOptions = NonNullable<Parameters<typeof getCookiePickerHTML>[1]>;
|
||||
type ApiRequest = { path: string; method: string; browser: string | null; profile: string | null; pickerInstance: string | null; body?: any };
|
||||
|
||||
describe('rendered cookie picker', () => {
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let origin: string;
|
||||
let options: PickerOptions;
|
||||
let requests: ApiRequest[];
|
||||
let handler: (request: ApiRequest) => Response | Promise<Response> | undefined;
|
||||
let profiles: Record<string, { profiles: { name: string; displayName: string; unavailable?: boolean }[]; recommendedProfile?: string }>;
|
||||
let browsers: { name: string; aliases?: string[] }[];
|
||||
let imported: { domain: string; count: number }[];
|
||||
let errors: string[];
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/cookie-picker') return new Response(getCookiePickerHTML(server.port!, options), { headers: { 'Content-Type': 'text/html' } });
|
||||
if (!url.pathname.startsWith('/cookie-picker/')) return new Response(null, { status: 204 });
|
||||
const record = { path: url.pathname.slice('/cookie-picker'.length), method: request.method,
|
||||
browser: url.searchParams.get('browser'), profile: url.searchParams.get('profile'),
|
||||
pickerInstance: request.headers.get('X-Gstack-Picker-Instance'),
|
||||
...(request.method === 'POST' ? { body: await request.json() } : {}) };
|
||||
requests.push(record);
|
||||
const response = handler(record);
|
||||
if (response) return response;
|
||||
if (record.path === '/browsers') return Response.json({ browsers });
|
||||
if (record.path === '/imported') return Response.json({ domains: imported });
|
||||
if (record.path === '/profiles') return Response.json(profiles[record.browser!] || { profiles: [] });
|
||||
if (record.path === '/domains') return Response.json({ domains: [{ domain: '.' + record.browser!.toLowerCase() + '-' + record.profile!.toLowerCase().replaceAll(' ', '-') + '.test', count: 4 }] });
|
||||
if (record.path === '/import') return Response.json({ browser: record.body.browser, profile: record.body.profile,
|
||||
imported: 2, failed: 0, domainCounts: Object.fromEntries(record.body.domains.map((domain: string) => [domain, 2])),
|
||||
failureReasons: {}, outcome: 'imported', message: 'Cookies copied.', reset: 'not_requested',
|
||||
verification: { verified: false, reason: 'not_requested' } });
|
||||
if (record.path === '/remove') return Response.json({ removed: record.body.domains.length });
|
||||
return Response.json({ error: 'Fixture route unavailable' }, { status: 404 });
|
||||
} });
|
||||
origin = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
options = {};
|
||||
requests = [];
|
||||
imported = [];
|
||||
handler = () => undefined;
|
||||
browsers = [{ name: 'Chrome', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'] }, { name: 'Dia', aliases: ['dia'] }];
|
||||
profiles = {
|
||||
Chrome: { profiles: [{ name: 'Default', displayName: 'Personal' }, { name: 'Profile 1', displayName: 'Personal' }], recommendedProfile: 'Default' },
|
||||
Dia: { profiles: [{ name: 'Profile 1', displayName: 'Work' }, { name: 'Profile 2', displayName: 'Work' }], recommendedProfile: 'Profile 2' },
|
||||
};
|
||||
errors = [];
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.close();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
server?.stop(true);
|
||||
});
|
||||
|
||||
async function openPicker() {
|
||||
await page.goto(`${origin}/cookie-picker`);
|
||||
await page.locator('.pill').first().waitFor();
|
||||
}
|
||||
|
||||
test('sends the rendered instance on every discovery and mutation request', async () => {
|
||||
options = { pickerInstance: 'synthetic-picker-instance' };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Remove .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Removed imported cookies' }).waitFor();
|
||||
expect([...new Set(requests.map(request => request.path))].sort()).toEqual(['/browsers', '/domains', '/import', '/imported', '/profiles', '/remove']);
|
||||
expect(requests.every(request => request.pickerInstance === options.pickerInstance)).toBe(true);
|
||||
});
|
||||
|
||||
test('a stale picker fails with actionable reopen guidance instead of a success receipt', async () => {
|
||||
options = { pickerInstance: 'stale-picker-instance' };
|
||||
handler = request => request.path === '/import'
|
||||
? Response.json({ code: 'picker_changed', error: 'Reopen the picker.' }, { status: 403 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Reopen the picker from the intended page before continuing.' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Authentication was not verified.');
|
||||
expect(await page.locator('#imported-domains').textContent()).toContain('No cookies imported yet');
|
||||
});
|
||||
|
||||
test('preserves default storage and leaves verification disabled without a bound target', async () => {
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).waitFor();
|
||||
for (const selector of ['#clear-storage', '#verify-auth']) {
|
||||
expect(await page.locator(selector).isChecked()).toBe(false);
|
||||
expect(await page.locator(selector).isDisabled()).toBe(true);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.find(request => request.path === '/import')?.body).toEqual({ browser: 'Chrome', profile: 'Default',
|
||||
domains: ['.chrome-default.test'], clearStorage: false, verifyAuth: false });
|
||||
expect(await page.getByRole('status').textContent()).toContain('Storage preserved. Authentication not checked.');
|
||||
expect(await page.getByRole('status').isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
test('does not enable verification or storage reset merely because a target and assertion exist', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
await openPicker();
|
||||
expect(await page.locator('#target-origin').textContent()).toBe('https://target.test');
|
||||
for (const selector of ['#clear-storage', '#verify-auth']) {
|
||||
expect(await page.locator(selector).isChecked()).toBe(false);
|
||||
expect(await page.locator(selector).isEnabled()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('prechecks only explicitly requested options and supports keyboard changes', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true, clearStorage: true, verifyAuth: true };
|
||||
await openPicker();
|
||||
for (const selector of ['#clear-storage', '#verify-auth']) {
|
||||
expect(await page.locator(selector).isChecked()).toBe(true);
|
||||
await page.locator(selector).focus();
|
||||
await page.keyboard.press('Space');
|
||||
expect(await page.locator(selector).isChecked()).toBe(false);
|
||||
}
|
||||
const add = page.getByRole('button', { name: 'Import .chrome-default.test', exact: true });
|
||||
await add.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.find(request => request.path === '/import')?.body.verifyAuth).toBe(false);
|
||||
expect(requests.find(request => request.path === '/import')?.body.clearStorage).toBe(false);
|
||||
expect(await page.getByRole('button', { name: 'Reimport .chrome-default.test', exact: true }).evaluate(element => element === document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
for (const targetOrigin of [undefined, 'about:blank', 'javascript:alert(1)']) {
|
||||
test(`disables explicit mutation options for an unavailable target ${String(targetOrigin).split(':')[0]}`, async () => {
|
||||
options = { targetOrigin, clearStorage: true, verifyAuth: true, verificationAvailable: true };
|
||||
await openPicker();
|
||||
expect(await page.locator('#clear-storage').isChecked()).toBe(false);
|
||||
expect(await page.locator('#verify-auth').isChecked()).toBe(false);
|
||||
expect(await page.locator('#clear-storage').isDisabled()).toBe(true);
|
||||
expect(await page.locator('#verify-auth').isDisabled()).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('disables verification without configuration even when requested', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verifyAuth: true };
|
||||
await openPicker();
|
||||
expect(await page.locator('#verify-auth').isDisabled()).toBe(true);
|
||||
expect(await page.locator('#verify-auth').isChecked()).toBe(false);
|
||||
expect(await page.locator('#clear-storage').isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps reset disabled on unsupported targets without disabling import', async () => {
|
||||
options = { targetOrigin: 'https://target.test', clearStorage: true, storageResetAvailable: false };
|
||||
await openPicker();
|
||||
expect(await page.locator('#clear-storage').isDisabled()).toBe(true);
|
||||
expect(await page.locator('#clear-storage').isChecked()).toBe(false);
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.find(request => request.path === '/import')?.body.clearStorage).toBe(false);
|
||||
expect(await page.locator('#clear-storage').isDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test('requires explicit selection among ambiguous profiles and shows directory discriminators', async () => {
|
||||
delete profiles.Chrome.recommendedProfile;
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'No unambiguous profile' }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').count()).toBe(0);
|
||||
expect(await page.locator('.profile-pill').allTextContents()).toEqual(['Personal (Default)', 'Personal (Profile 1)']);
|
||||
expect(requests.filter(request => request.path === '/domains')).toHaveLength(0);
|
||||
expect(await page.locator('#btn-import-all').isVisible()).toBe(false);
|
||||
await page.getByRole('button', { name: 'Personal (Profile 1)', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
test('does not guess a sole profile when the server cannot recommend it', async () => {
|
||||
profiles.Chrome = { profiles: [{ name: 'Default', displayName: 'Personal', unavailable: true }] };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'No unambiguous profile' }).waitFor();
|
||||
expect(await page.locator('.profile-pill').textContent()).toContain('could not inspect');
|
||||
expect(requests.filter(request => request.path === '/domains')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('uses the explicit profile only for its matching browser before painting the active pill', async () => {
|
||||
options = { browser: 'chrome', profile: 'Profile 1' };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
});
|
||||
|
||||
test('recognizes supported browser aliases and binds the explicit profile to the canonical browser', async () => {
|
||||
options = { browser: 'GOOGLE-CHROME', profile: 'Profile 1' };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Chrome');
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
await page.getByRole('button', { name: 'Chrome', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/profiles').map(request => request.browser)).toEqual(['Chrome', 'Dia', 'Chrome']);
|
||||
});
|
||||
|
||||
test('does not interpret a browser alias substring as an installed browser', async () => {
|
||||
options = { browser: 'google' };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'requested browser is unavailable' }).waitFor();
|
||||
expect(await page.locator('.pill.active').count()).toBe(0);
|
||||
expect(requests.filter(request => request.path === '/profiles')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not silently replace a missing explicit profile or browser', async () => {
|
||||
options = { browser: 'Chrome', profile: 'Missing' };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'requested profile is unavailable' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/domains')).toHaveLength(0);
|
||||
options = { browser: 'Missing browser', profile: 'Default' };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'requested browser is unavailable' }).waitFor();
|
||||
expect(await page.locator('.pill.active').count()).toBe(0);
|
||||
});
|
||||
|
||||
for (const earlierFirst of [true, false]) {
|
||||
test(`ignores stale profile responses when the earlier request returns ${earlierFirst ? 'first' : 'last'}`, async () => {
|
||||
const chrome = Promise.withResolvers<Response>();
|
||||
const dia = Promise.withResolvers<Response>();
|
||||
const chromeSeen = Promise.withResolvers<void>();
|
||||
const diaSeen = Promise.withResolvers<void>();
|
||||
handler = request => {
|
||||
if (request.path !== '/profiles') return;
|
||||
if (request.browser === 'Chrome') { chromeSeen.resolve(); return chrome.promise; }
|
||||
diaSeen.resolve(); return dia.promise;
|
||||
};
|
||||
await openPicker();
|
||||
await chromeSeen.promise;
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await diaSeen.promise;
|
||||
if (earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('/profiles?browser=Chrome'));
|
||||
chrome.resolve(Response.json(profiles.Chrome));
|
||||
await response;
|
||||
expect(await page.locator('.profile-pill.active').count()).toBe(0);
|
||||
}
|
||||
dia.resolve(Response.json(profiles.Dia));
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
if (!earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('/profiles?browser=Chrome'));
|
||||
chrome.resolve(Response.json(profiles.Chrome));
|
||||
await response;
|
||||
}
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Dia');
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
expect(requests.filter(request => request.path === '/domains').map(request => request.browser)).toEqual(['Dia']);
|
||||
});
|
||||
}
|
||||
|
||||
for (const earlierFirst of [true, false]) {
|
||||
test(`ignores stale domain responses when the earlier request returns ${earlierFirst ? 'first' : 'last'}`, async () => {
|
||||
const first = Promise.withResolvers<Response>();
|
||||
const second = Promise.withResolvers<Response>();
|
||||
const firstSeen = Promise.withResolvers<void>();
|
||||
const secondSeen = Promise.withResolvers<void>();
|
||||
handler = request => {
|
||||
if (request.path !== '/domains') return;
|
||||
if (request.profile === 'Default') { firstSeen.resolve(); return first.promise; }
|
||||
secondSeen.resolve(); return second.promise;
|
||||
};
|
||||
await openPicker();
|
||||
await firstSeen.promise;
|
||||
await page.getByRole('button', { name: 'Personal (Profile 1)', exact: true }).click();
|
||||
await secondSeen.promise;
|
||||
if (earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('&profile=Default'));
|
||||
first.resolve(Response.json({ domains: [{ domain: '.stale.test', count: 4 }] }));
|
||||
await response;
|
||||
expect(await page.locator('.btn-add').count()).toBe(0);
|
||||
}
|
||||
second.resolve(Response.json({ domains: [{ domain: '.current.test', count: 2 }] }));
|
||||
await page.getByRole('button', { name: 'Import .current.test', exact: true }).waitFor();
|
||||
if (!earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('&profile=Default'));
|
||||
first.resolve(Response.json({ domains: [{ domain: '.stale.test', count: 4 }] }));
|
||||
await response;
|
||||
}
|
||||
expect(await page.locator('#source-domains .domain-name').allTextContents()).toEqual(['.current.test']);
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
});
|
||||
}
|
||||
|
||||
for (const outcome of ['partial', 'empty', 'failed']) {
|
||||
test(`reports an HTTP-200 ${outcome} receipt persistently rather than as silent success`, async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ imported: outcome === 'partial' ? 2 : 0, failed: 3,
|
||||
domainCounts: outcome === 'partial' ? { '.chrome-default.test': 2 } : {}, failureReasons: { unsupported_encryption: 3 },
|
||||
outcome, message: 'Synthetic import receipt.', reset: outcome === 'failed' ? 'failed' : 'not_requested', verification: { verified: false, reason: 'not_requested' } }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Synthetic import receipt.' }).waitFor();
|
||||
const status = await page.getByRole('status').textContent();
|
||||
expect(status).toContain(outcome === 'partial' ? 'Partial import.' : outcome === 'empty' ? 'No cookies imported.' : 'Import failed.');
|
||||
expect(status).toContain('3 failed.');
|
||||
expect(status).toContain('unsupported encryption: 3.');
|
||||
expect(status).toContain('Authentication not checked.');
|
||||
expect(await page.getByRole('status').getAttribute('class')).toContain(outcome === 'failed' ? 'error' : 'warning');
|
||||
expect(await page.locator('.btn-add.imported').count()).toBe(0);
|
||||
expect(await page.locator('#imported-domains .domain-name').count()).toBe(outcome === 'partial' ? 1 : 0);
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('sets imported counts on explicit reimport rather than adding them again', async () => {
|
||||
imported = [{ domain: '.chrome-default.test', count: 6 }];
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Reimport .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('#imported-domains .domain-count').textContent()).toBe('2');
|
||||
await page.getByRole('button', { name: 'Reimport .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('#imported-domains .domain-count').textContent()).toBe('2');
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('serializes imports, disables source switches, and does not repeat POSTs on double submit', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
imported = [{ domain: '.already.test', count: 1 }];
|
||||
const result = Promise.withResolvers<Response>();
|
||||
const seen = Promise.withResolvers<void>();
|
||||
handler = request => { if (request.path === '/import') { seen.resolve(); return result.promise; } };
|
||||
await openPicker();
|
||||
const add = page.getByRole('button', { name: 'Import .chrome-default.test', exact: true });
|
||||
await add.evaluate((element: HTMLButtonElement) => { element.click(); element.click(); });
|
||||
await seen.promise;
|
||||
expect(await page.getByRole('button', { name: 'Dia', exact: true }).isDisabled()).toBe(true);
|
||||
expect(await page.getByRole('button', { name: 'Personal (Profile 1)', exact: true }).isDisabled()).toBe(true);
|
||||
for (const selector of ['#btn-import-all', '#clear-storage', '#verify-auth', '#search', '.btn-trash']) expect(await page.locator(selector).isDisabled()).toBe(true);
|
||||
await page.locator('#btn-import-all').evaluate((element: HTMLButtonElement) => element.click());
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).evaluate((element: HTMLButtonElement) => element.click());
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
result.resolve(Response.json({ imported: 1, failed: 0, domainCounts: { '.chrome-default.test': 1 }, outcome: 'imported', reset: 'not_requested', verification: { verified: false, reason: 'not_requested' } }));
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Chrome');
|
||||
expect(await page.getByRole('button', { name: 'Dia', exact: true }).isEnabled()).toBe(true);
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('serializes removal with imports and retains counts on a failed removal', async () => {
|
||||
imported = [{ domain: '.already.test', count: 1 }];
|
||||
const result = Promise.withResolvers<Response>();
|
||||
const seen = Promise.withResolvers<void>();
|
||||
handler = request => { if (request.path === '/remove') { seen.resolve(); return result.promise; } };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Remove .already.test', exact: true }).click();
|
||||
await seen.promise;
|
||||
expect(await page.locator('.btn-add').isDisabled()).toBe(true);
|
||||
await page.locator('.btn-add').evaluate((element: HTMLButtonElement) => element.click());
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(0);
|
||||
result.resolve(Response.json({ error: 'synthetic-private-error' }, { status: 500 }));
|
||||
await page.getByRole('status').filter({ hasText: 'Cookie removal did not complete.' }).waitFor();
|
||||
expect(await page.locator('#imported-domains .domain-count').textContent()).toBe('1');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(requests.filter(request => request.path === '/remove')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('reports mutation errors without replaying the POST or exposing raw errors', async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ error: 'synthetic-private-error', action: 'retry' }, { status: 503 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Import did not complete' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
expect(await page.locator('.btn-add').isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
for (const [code, guidance] of [
|
||||
['keychain_denied', 'Allow access in the OS permission prompt or settings'],
|
||||
['keychain_timeout', 'Check for a pending OS permission prompt'],
|
||||
['keychain_error', 'Check the OS credential store'],
|
||||
['db_locked', 'Close the source browser, then retry manually'],
|
||||
['db_corrupt', 'Choose another profile or sign in manually'],
|
||||
['db_permission', 'Check source-profile permissions'],
|
||||
['db_read_error', 'Cookie data could not be read from this profile'],
|
||||
['sqlite_unavailable', 'Node.js 22.13 or newer with built-in SQLite enabled'],
|
||||
['storage_reset_unsupported', 'Storage reset requires a Chromium target'],
|
||||
['profile_required', 'Choose a source profile explicitly'],
|
||||
['target_changed', 'Reopen the picker from the intended HTTP(S) page'],
|
||||
['target_closed', 'The captured target is closed'],
|
||||
['target_mismatch', 'Select its cookie domain or reopen the picker'],
|
||||
['not_supported', 'Native cookie import is unsupported'],
|
||||
['native_profile_unsupported', 'This browser profile does not support native cookie extraction'],
|
||||
['native_unqualified', 'process ownership and cleanup are not qualified'],
|
||||
['native_cleanup_failed', 'Inspect the source browser before any manual retry'],
|
||||
['native_supervision_failed', 'Native browser supervision could not start'],
|
||||
['native_timeout', 'Native cookie extraction timed out'],
|
||||
['browser_running', 'Close it yourself before retrying'],
|
||||
]) {
|
||||
test(`shows actionable allowlisted ${code} guidance without server prose or automatic replay`, async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ code, action: 'retry',
|
||||
error: '<img src=x onerror="window.errorXss=1"> synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Import did not complete' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain(guidance);
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).errorXss)).toBeUndefined();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
expect(await page.locator('.btn-add').isEnabled()).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const code of ['unknown_private_code', '__proto__', '<img src=x onerror="window.errorXss=1">']) {
|
||||
test(`uses safe generic guidance for an unallowlisted error code ${code.startsWith('<') ? 'markup' : code}`, async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ code, error: 'synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Import did not complete' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Inspect the source and destination before retrying');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain(code);
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const path of ['/profiles', '/domains']) {
|
||||
test(`preserves safe actionable guidance on ${path} read failures`, async () => {
|
||||
handler = request => request.path === path ? Response.json({ code: 'db_locked', error: 'synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'source cookie database is busy' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Close the source browser, then retry manually');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(requests.filter(request => request.path === path)).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('permits a deliberate manual retry after permission recovery without replaying automatically', async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ code: 'keychain_denied', action: 'retry', error: 'synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Keychain access was denied' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
handler = () => undefined;
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('imports the visible filtered domains in one request with explicit target options', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
handler = request => request.path === '/domains' ? Response.json({ domains: [{ domain: '.one.test', count: 3 }, { domain: '.two.test', count: 2 }, { domain: '.other.invalid', count: 1 }] }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .one.test', exact: true }).waitFor();
|
||||
await page.getByRole('textbox', { name: 'Search cookie domains' }).fill('.test');
|
||||
await page.locator('#clear-storage').check();
|
||||
await page.locator('#verify-auth').check();
|
||||
await page.getByRole('button', { name: 'Import All (2)', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
expect(requests.find(request => request.path === '/import')?.body).toEqual({ browser: 'Chrome', profile: 'Default', domains: ['.one.test', '.two.test'], clearStorage: true, verifyAuth: true });
|
||||
expect(await page.getByRole('status').textContent()).toContain('Authentication not verified');
|
||||
});
|
||||
|
||||
for (const importedCount of [0, 2]) {
|
||||
test(`requires a requested positive check and nonzero import before verified (${importedCount} imported)`, async () => {
|
||||
options = { targetOrigin: 'https://target.test', verifyAuth: true, verificationAvailable: true, clearStorage: true };
|
||||
handler = request => request.path === '/import' ? Response.json({ imported: importedCount, failed: 0, domainCounts: {}, outcome: importedCount ? 'imported' : 'empty', reset: 'cleared', verification: { verified: true, reason: 'verified' } }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Storage cleared for' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain(importedCount ? 'Authentication verified on the captured target.' : 'Authentication not verified.');
|
||||
});
|
||||
}
|
||||
|
||||
test('does not claim verification for an unrequested check even if a response says verified', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
handler = request => request.path === '/import' ? Response.json({ imported: 2, failed: 0, domainCounts: {}, outcome: 'imported', verification: { verified: true } }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Authentication not checked.');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('Authentication verified');
|
||||
});
|
||||
|
||||
test('escapes script configuration, profile attributes, domain attributes, and status text', async () => {
|
||||
const payload = '\"\'><img src=x onerror="window.fixtureXss=1"></script><script>window.fixtureXss=1</script>';
|
||||
options = { browser: payload, profile: payload, targetOrigin: 'https://target.test' };
|
||||
browsers = [{ name: payload }];
|
||||
profiles = { [payload]: { profiles: [{ name: payload, displayName: payload }] } };
|
||||
handler = request => request.path === '/domains' ? Response.json({ domains: [{ domain: payload, count: 1 }] })
|
||||
: request.path === '/import' ? Response.json({ imported: 1, failed: 0, domainCounts: { [payload]: 1 }, outcome: 'imported', message: payload }) : undefined;
|
||||
await openPicker();
|
||||
await page.locator('.btn-add').waitFor();
|
||||
expect(await page.locator('.profile-pill').getAttribute('data-profile')).toBe(payload);
|
||||
expect(await page.locator('.btn-add').getAttribute('data-domain')).toBe(payload);
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).fixtureXss)).toBeUndefined();
|
||||
await page.locator('.btn-add').click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).fixtureXss)).toBeUndefined();
|
||||
expect(requests.find(request => request.path === '/import')?.body.profile).toBe(payload);
|
||||
expect(await page.locator('.btn-trash').getAttribute('data-domain')).toBe(payload);
|
||||
});
|
||||
|
||||
test('serializes only a safe target origin, never credentials, path, or query data', async () => {
|
||||
const target = new URL('https://target.test/private-path?fixture-token=private-value');
|
||||
target.username = 'fixture-user';
|
||||
target.password = 'fixture';
|
||||
options = { targetOrigin: target.href };
|
||||
const html = getCookiePickerHTML(server.port!, options);
|
||||
expect(html).not.toContain('fixture-user');
|
||||
expect(html).not.toContain('private-path');
|
||||
expect(html).not.toContain('private-value');
|
||||
await openPicker();
|
||||
expect(await page.locator('#target-origin').textContent()).toBe('https://target.test');
|
||||
expect(await page.getByRole('status').getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseGuiReadiness, runGuiReadiness, validateGuiReadinessAuthority } from '../../.github/scripts/qualify-dia-macos';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'dia-gui-readiness-test-')));
|
||||
const source = path.resolve(import.meta.dir, '../../.github/scripts/dia-gui-readiness.c');
|
||||
const executable = path.join(root, 'metadata-probe');
|
||||
const names = ['chrome', 'chromium', 'arc', 'dia', 'comet', 'brave', 'edge', 'safari', 'cookies'];
|
||||
const observation = () => ({ protocol: 1, supported: true, identity: { effectiveUidMatches: true, homeMatchesRegistered: true },
|
||||
security: { status: 0, graphicAccess: true, rootSession: false, tty: false, remote: false },
|
||||
quartz: { present: true, sameUid: true, loginDone: true, onConsole: true }, browserRoots: null as any });
|
||||
|
||||
beforeAll(() => {
|
||||
const wrapper = path.join(root, 'metadata-probe.c');
|
||||
writeFileSync(wrapper, `#define main native_readiness_main\n#include ${JSON.stringify(source)}\n#undef main
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) return 2;
|
||||
int home = open(argv[1], O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK);
|
||||
if (home < 0) return 2;
|
||||
print_browser_roots(home, getuid()); close(home); printf("\\n"); return 0;
|
||||
}`);
|
||||
const compiler = Bun.which('clang');
|
||||
if (!compiler) throw new Error('clang is required for the POSIX metadata regression');
|
||||
const compiled = spawnSync(compiler, ['-std=c11', '-O2', '-Wall', '-Wextra', wrapper,
|
||||
...(process.platform === 'darwin' ? ['-framework', 'Security', '-framework', 'ApplicationServices'] : []), '-o', executable],
|
||||
{ encoding: 'utf8', timeout: 30_000 });
|
||||
if (compiled.status !== 0) throw new Error('metadata_fixture_compile_failed');
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const inspect = (home: string) => {
|
||||
const result = spawnSync(executable, [home], { encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
|
||||
describe('read-only GUI readiness', () => {
|
||||
test('metadata inspection reports missing fixed roots without creating state', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'empty-')));
|
||||
const before = readdirSync(home);
|
||||
const result = inspect(home);
|
||||
expect(Object.keys(result)).toEqual(names);
|
||||
for (const name of names) expect(result[name]).toEqual({ state: 'absent', kind: null, ownerMatches: null, ancestorBlocked: false });
|
||||
expect(readdirSync(home)).toEqual(before);
|
||||
expect(lstatSync(home).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
test('metadata inspection never follows ancestor or target symlinks', () => {
|
||||
const outside = realpathSync(mkdtempSync(path.join(root, 'other-')));
|
||||
writeFileSync(path.join(outside, 'private-sentinel'), 'unchanged fixture contents');
|
||||
const linked = realpathSync(mkdtempSync(path.join(root, 'linked-')));
|
||||
symlinkSync(outside, path.join(linked, 'Library'), 'dir');
|
||||
const blocked = inspect(linked);
|
||||
for (const name of names) expect(blocked[name]).toEqual({ state: 'unavailable', kind: 'symlink', ownerMatches: true, ancestorBlocked: true });
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'target-')));
|
||||
mkdirSync(path.join(home, 'Library'));
|
||||
symlinkSync(outside, path.join(home, 'Library/Safari'), 'dir');
|
||||
expect(inspect(home).safari).toEqual({ state: 'present', kind: 'symlink', ownerMatches: true, ancestorBlocked: false });
|
||||
expect(readdirSync(outside)).toEqual(['private-sentinel']);
|
||||
expect(readFileSync(path.join(outside, 'private-sentinel'), 'utf8')).toBe('unchanged fixture contents');
|
||||
});
|
||||
|
||||
test('wrong ancestor types and access failures are unavailable, never absent', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'wrong-type-')));
|
||||
writeFileSync(path.join(home, 'Library'), 'not a directory');
|
||||
expect(inspect(home).dia).toEqual({ state: 'unavailable', kind: 'file', ownerMatches: true, ancestorBlocked: true });
|
||||
const denied = realpathSync(mkdtempSync(path.join(root, 'denied-')));
|
||||
const library = path.join(denied, 'Library');
|
||||
mkdirSync(library, { mode: 0o700 });
|
||||
chmodSync(library, 0);
|
||||
try {
|
||||
expect(inspect(denied).dia).toEqual({ state: 'unavailable', kind: 'directory', ownerMatches: true, ancestorBlocked: true });
|
||||
expect(lstatSync(library).mode & 0o777).toBe(0);
|
||||
} finally { chmodSync(library, 0o700); }
|
||||
});
|
||||
|
||||
test('present root metadata does not open contents or change them', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'present-')));
|
||||
const safari = path.join(home, 'Library/Safari');
|
||||
mkdirSync(safari, { recursive: true });
|
||||
const file = path.join(safari, 'private-sentinel');
|
||||
writeFileSync(file, 'private fixture content');
|
||||
chmodSync(file, 0);
|
||||
const before = lstatSync(file);
|
||||
const result = inspect(home);
|
||||
expect(result.safari).toEqual({ state: 'present', kind: 'directory', ownerMatches: true, ancestorBlocked: false });
|
||||
expect(lstatSync(file).mode).toBe(before.mode);
|
||||
expect(lstatSync(file).mtimeMs).toBe(before.mtimeMs);
|
||||
expect(JSON.stringify(result)).not.toContain('private');
|
||||
chmodSync(file, 0o600);
|
||||
expect(readFileSync(file, 'utf8')).toBe('private fixture content');
|
||||
});
|
||||
|
||||
test('GUI usability requires both the security capability and the caller-owned logged-in Quartz session', () => {
|
||||
const original = observation();
|
||||
const before = JSON.stringify(original);
|
||||
Object.freeze(original.identity); Object.freeze(original.security); Object.freeze(original.quartz); Object.freeze(original);
|
||||
expect(parseGuiReadiness(original, false).usableGui).toBe(true);
|
||||
expect(JSON.stringify(original)).toBe(before);
|
||||
for (const changed of [
|
||||
{ ...observation(), security: { ...observation().security, graphicAccess: false } },
|
||||
{ ...observation(), quartz: { present: false, sameUid: null, loginDone: null, onConsole: null } },
|
||||
{ ...observation(), quartz: { present: true, sameUid: false, loginDone: null, onConsole: null } },
|
||||
{ ...observation(), quartz: { ...observation().quartz, loginDone: false } },
|
||||
{ ...observation(), identity: { effectiveUidMatches: true, homeMatchesRegistered: false } },
|
||||
{ ...observation(), security: { status: -60500, graphicAccess: null, rootSession: null, tty: null, remote: null } },
|
||||
]) expect(parseGuiReadiness(changed, false).usableGui).toBe(false);
|
||||
});
|
||||
|
||||
test('schema rejects extra identity text, cross-UID session details and false absence claims', () => {
|
||||
for (const changed of [
|
||||
{ ...observation(), username: 'private-name' },
|
||||
{ ...observation(), quartz: { ...observation().quartz, uid: 501 } },
|
||||
{ ...observation(), quartz: { present: true, sameUid: false, loginDone: true, onConsole: true } },
|
||||
{ ...observation(), security: { ...observation().security, status: -1 } },
|
||||
{ ...observation(), security: { ...observation().security, graphicAccess: 1 } },
|
||||
]) expect(() => parseGuiReadiness(changed, false)).toThrow('invalid_gui_readiness_receipt');
|
||||
const roots = Object.fromEntries(names.map(name => [name, { state: 'absent', kind: null, ownerMatches: null, ancestorBlocked: false }]));
|
||||
expect(parseGuiReadiness({ ...observation(), browserRoots: roots }, true).browserRoots).toEqual(roots);
|
||||
expect(() => parseGuiReadiness({ ...observation(), browserRoots: roots }, false)).toThrow('invalid_gui_readiness_receipt');
|
||||
expect(() => parseGuiReadiness({ ...observation(), browserRoots: { ...roots, dia: { state: 'absent', kind: 'symlink', ownerMatches: true, ancestorBlocked: true } } }, true))
|
||||
.toThrow('invalid_gui_readiness_receipt');
|
||||
});
|
||||
|
||||
test('GUI authority cannot carry browser/comparison authority or placeholder destination hashes', () => {
|
||||
const account: any = { work: '/private/tmp/dn-fixture', guiReadiness: { mode: 'gui-readiness-only', executable: '/private/tmp/dn-fixture/bin/gui-readiness',
|
||||
executableSha256: 'a'.repeat(64), sourceSha256: 'b'.repeat(64) } };
|
||||
expect(() => validateGuiReadinessAuthority(account, 'coordinator')).not.toThrow();
|
||||
for (const changed of [{ ...account, launchComparison: {} }, { ...account, destinationExecutable: '' }, { ...account, destinationSha256: 'a'.repeat(64) },
|
||||
{ ...account, guiReadiness: null }, { ...account, guiReadiness: { ...account.guiReadiness, executableSha256: ['a'.repeat(64)] } },
|
||||
{ ...account, guiReadiness: { ...account.guiReadiness, executable: '/unowned/probe' } }]) {
|
||||
expect(() => validateGuiReadinessAuthority(changed, 'coordinator')).toThrow('gui_readiness_authority_invalid');
|
||||
}
|
||||
expect(() => validateGuiReadinessAuthority(account, 'comparison-driver')).toThrow('gui_readiness_authority_invalid');
|
||||
});
|
||||
|
||||
test('the registered probe validates hashes, bounds execution, and discards unrecognized output', async () => {
|
||||
const hash = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
const exeHash = hash(executable);
|
||||
const sourceHash = hash(source);
|
||||
const input = observation();
|
||||
const execute = ((command: string, args: string[], options: any) => {
|
||||
expect(command).toBe(executable); expect(args).toEqual([]);
|
||||
expect(options.timeout).toBeGreaterThan(0); expect(options.timeout).toBeLessThanOrEqual(5000);
|
||||
expect(options.killSignal).toBe('SIGKILL'); expect(options.maxBuffer).toBe(16 * 1024);
|
||||
return { status: 0, stdout: JSON.stringify(input), stderr: 'private diagnostic text' };
|
||||
}) as typeof spawnSync;
|
||||
const result = await runGuiReadiness(executable, exeHash, source, sourceHash, false, { HOME: root }, 5000, execute);
|
||||
expect(result.available).toBe(true);
|
||||
expect(JSON.stringify(result)).not.toContain('private diagnostic');
|
||||
await expect(runGuiReadiness(executable, 'c'.repeat(64), source, sourceHash, false, {}, 5000, execute)).rejects.toThrow('gui_readiness_inputs_changed');
|
||||
const malformed = (() => ({ status: 0, stdout: JSON.stringify({ ...input, username: 'private-name' }), stderr: '' })) as typeof spawnSync;
|
||||
expect((await runGuiReadiness(executable, exeHash, source, sourceHash, false, {}, 5000, malformed)).available).toBe(false);
|
||||
for (const timeout of [0, NaN, Infinity]) await expect(runGuiReadiness(executable, exeHash, source, sourceHash, false, {}, timeout, execute)).rejects.toThrow('gui_readiness_budget_exhausted');
|
||||
});
|
||||
|
||||
test('the readiness launcher loads without node_modules or Playwright and rejects conflicting CLI modes', () => {
|
||||
const directory = path.join(root, 'no-dependencies/.github/scripts');
|
||||
mkdirSync(directory, { recursive: true });
|
||||
for (const file of ['run-dia-native-qualification.ts', 'qualify-dia-macos.ts']) copyFileSync(path.resolve(import.meta.dir, '../../.github/scripts', file), path.join(directory, file));
|
||||
for (const args of [['--gui-readiness-only'], ['--gui-readiness-only', '--launch-comparison', 'node']]) {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=/dev/null', path.join(directory, 'run-dia-native-qualification.ts'), ...args],
|
||||
{ cwd: root, env: { HOME: root, PATH: '/usr/bin:/bin' }, encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(2); expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout).reason).toBe('fresh_account_launcher_preflight_failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { compareDiaLaunchReceipts, normalizedLaunchHashes } from '../../.github/scripts/dia-launch-driver.mjs';
|
||||
import { runDiaLaunchComparison, safeDiaComparisonResponse } from '../../.github/scripts/qualify-dia-macos';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'dc-')));
|
||||
const driverFile = path.resolve(import.meta.dir, '../../.github/scripts/dia-launch-driver.mjs');
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('diagnostic-only Dia runtime comparison', () => {
|
||||
test('normalization replaces only declared owned path boundaries', () => {
|
||||
const first = normalizedLaunchHashes(['--user-data-dir=/owned/a/home/profile', '--headless'], { HOME: '/owned/a/home', PATH: '/usr/bin:/bin' }, { home: '/owned/a/home' });
|
||||
const second = normalizedLaunchHashes(['--user-data-dir=/owned/b/home/profile', '--headless'], { HOME: '/owned/b/home', PATH: '/usr/bin:/bin' }, { home: '/owned/b/home' });
|
||||
expect(first).toEqual(second);
|
||||
expect(normalizedLaunchHashes(['--user-data-dir=/owned/a/home-other'], {}, { home: '/owned/a/home' }).argvSha256)
|
||||
.not.toBe(normalizedLaunchHashes(['--user-data-dir=/owned/b/home-other'], {}, { home: '/owned/b/home' }).argvSha256);
|
||||
expect(normalizedLaunchHashes(['--label=https://private.invalid/owned/a/home'], {}, { home: '/owned/a/home' }).argvSha256)
|
||||
.not.toBe(normalizedLaunchHashes(['--label=https://private.invalid/owned/b/home'], {}, { home: '/owned/b/home' }).argvSha256);
|
||||
expect(JSON.stringify(first)).not.toContain('/owned');
|
||||
});
|
||||
|
||||
test('IPC rejects raw payloads at any nesting depth', () => {
|
||||
for (const value of [{ args: ['private-argv'] }, { cleanup: { raw: 'private-value' } }, { error: 'private-error-value' },
|
||||
{ startupPages: { categories: ['https://private.invalid'] } }, { driver: { release: '/private/path' } }]) {
|
||||
expect(safeDiaComparisonResponse(value)).toBe(false);
|
||||
}
|
||||
expect(safeDiaComparisonResponse({ protocol: 1, purpose: 'source', error: 'operation_timeout', cleanup: { confirmed: false } })).toBe(true);
|
||||
});
|
||||
|
||||
test('the actual driver entry refuses an unapproved host without exposing its request', () => {
|
||||
for (const executable of [process.execPath, Bun.which('node')!]) {
|
||||
const result = spawnSync(executable, [driverFile, '/private-untrusted/account.json'], {
|
||||
env: { PATH: '/usr/bin:/bin', HOME: root }, input: '{"private":"request-value"}', encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toEqual({ protocol: 1, ready: false, error: 'driver_admission_failed', cleanup: { confirmed: false } });
|
||||
}
|
||||
});
|
||||
|
||||
test('the real bounded stdin callback works under both runtimes and refuses raw request payloads', () => {
|
||||
const entry = path.join(root, 'stdin-probe.mjs');
|
||||
writeFileSync(entry, `import { readComparisonRequest } from ${JSON.stringify(pathToFileURL(driverFile).href)};
|
||||
try { const request = readComparisonRequest(); process.stdout.write(JSON.stringify({purpose:request.purpose})); }
|
||||
catch { process.stdout.write(JSON.stringify({rejected:true})); }`);
|
||||
for (const executable of [process.execPath, Bun.which('node')!]) {
|
||||
for (const input of ['{"purpose":"control"}', '{"purpose":"control","env":{"PRIVATE":"value"}}', 'x'.repeat(16 * 1024 + 1)]) {
|
||||
const result = spawnSync(executable, [entry], { env: { HOME: root, PATH: '/usr/bin:/bin' }, input, encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toEqual(input === '{"purpose":"control"}' ? { purpose: 'control' } : { rejected: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
test('both pinned runtimes execute the same protected worker with matched normalized inputs', async () => {
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node 24.18 is required for the comparison regression');
|
||||
const entry = path.join(root, 'probe.mjs');
|
||||
const executablePath = realpathSync((await import('playwright')).chromium.executablePath());
|
||||
writeFileSync(entry, `import { runProtectedLaunch } from ${JSON.stringify(pathToFileURL(driverFile).href)};
|
||||
const home = process.env.HOME;
|
||||
const result = await runProtectedLaunch(${JSON.stringify(executablePath)}, home + '/profile',
|
||||
{ HOME: home, TMPDIR: home + '/tmp', PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' }, 'control', { home });
|
||||
process.stdout.write(JSON.stringify(result) + '\\n');`);
|
||||
const results: any[] = [];
|
||||
for (const [name, executable] of [['bun', process.execPath], ['node', node]]) {
|
||||
const home = path.join(root, name);
|
||||
mkdirSync(home);
|
||||
mkdirSync(path.join(home, 'tmp'));
|
||||
const result = spawnSync(executable, [entry], { env: { HOME: home, PATH: '/usr/bin:/bin:/usr/sbin:/sbin' },
|
||||
encoding: 'utf8', timeout: 40_000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
const observed = JSON.parse(result.stdout);
|
||||
expect(observed.ready).toBe(true);
|
||||
expect(observed.protocolResponded).toBe(true);
|
||||
expect(observed.cleanup.confirmed).toBe(true);
|
||||
expect(observed.cleanup.childClosed).toBe(true);
|
||||
expect(observed.cleanup.groupAbsent).toBe(true);
|
||||
expect(observed.cleanup.launchSettled).toBe(true);
|
||||
expect(observed.rootCount).toBe(1);
|
||||
expect(observed.launchAttempts[0].pipeFlag).toBe(true);
|
||||
expect(observed.launchAttempts[0].sandboxDisablingFlag).toBe(false);
|
||||
expect(observed.samplingEnabled).toBe(false);
|
||||
expect(safeDiaComparisonResponse(observed)).toBe(true);
|
||||
expect(JSON.stringify(observed)).not.toContain(home);
|
||||
results.push(observed);
|
||||
}
|
||||
expect(results[0].argvSha256).toBe(results[1].argvSha256);
|
||||
expect(results[0].environmentSha256).toBe(results[1].environmentSha256);
|
||||
}, 90_000);
|
||||
|
||||
const arm = (runtime: 'bun' | 'node', ready: boolean) => {
|
||||
const sha = 'a'.repeat(64);
|
||||
const config = { mode: 'launch-only', runtime, qualificationCredit: false, executableSha256: runtime === 'bun' ? sha : 'b'.repeat(64), driverSha256: sha, helpersSha256: sha };
|
||||
const driver = { runtime, version: runtime === 'bun' ? '1.4.0' : '24.18.0', architecture: 'arm64', os: 'darwin', release: '24.6.0', playwright: '1.62.1',
|
||||
executableSha256: config.executableSha256, driverSha256: sha, helpersSha256: sha };
|
||||
const result = (purpose: string, success: boolean) => ({ protocol: 1, purpose, ready: success, launchReturned: success, protocolResponded: success,
|
||||
samplingEnabled: false, rootCount: 1, supervisor: { closed: true, exitCode: 0 }, cleanup: { confirmed: true, childClosed: true, groupAbsent: true, launchSettled: true,
|
||||
groups: [{ absenceConfirmed: true, childCloseObserved: true }] }, startupPages: { allowed: true }, postProbePages: { allowed: true },
|
||||
argvSha256: sha, environmentSha256: sha, driver, launchAttempts: [{ sandboxRequired: true, sandboxDisablingFlag: false, pipeFlag: true,
|
||||
tcpDebuggingFlag: false, mockKeychainFlag: false, passwordStoreFlag: false, firstRunSuppressed: false, headlessFlag: true,
|
||||
expectedProfile: true, detached: true, shellDisabled: true, stdioCount: 5, extraPipeDescriptors: true, profileArgumentCount: 1 }] });
|
||||
return { launchComparison: config, counts: { pass: 0, fail: 0, skip: 0 }, launcher: { accountGuid: runtime + '-separate-account', sourceRevision: 'c'.repeat(40), archiveSha256: sha, destinationSha256: sha },
|
||||
launcherCleanup: { serviceStopped: true, userDomainStopped: true, userProcessesStopped: true, accountRemoved: true, groupRemoved: true, stagingRemoved: true },
|
||||
backgroundPreflight: { status: 'passed', comparisonControl: result('control', true) },
|
||||
qualification: { launchComparison: { qualificationCredit: false, source: result('source', ready) }, counts: { pass: 0, fail: 0, skip: 0 },
|
||||
keychainStage: 'completed', isolation: { registeredIdentity: true, sharedRegisteredHome: true },
|
||||
cleanup: { ownedBrowsersStopped: true, sourceProfileRemoved: true, keychainRestored: true, mountDetached: true, fixtureRemoved: true },
|
||||
platform: { os: 'darwin', architecture: 'arm64', bun: '1.4.0', playwright: '1.62.1', release: '24.6.0' },
|
||||
artifact: { signatureVerified: true, gatekeeperNotarized: true, macosCompatibility: { compatible: true, hostVersion: '15.7.9' },
|
||||
architectures: ['arm64'], sha256: sha, executableSha256: sha, version: '1.49.1', bundleId: 'company.thebrowser.dia', team: 'S6N382Y83G' } } };
|
||||
};
|
||||
|
||||
test('paired receipts distinguish runtime readiness without awarding qualification credit', () => {
|
||||
expect(compareDiaLaunchReceipts(arm('bun', false), arm('node', true))).toEqual({ comparable: true, qualificationCredit: false, outcome: 'node_only_ready' });
|
||||
expect(compareDiaLaunchReceipts(arm('bun', false), arm('node', false)).outcome).toBe('neither_ready');
|
||||
expect(compareDiaLaunchReceipts(arm('bun', true), arm('node', true)).outcome).toBe('both_ready');
|
||||
});
|
||||
|
||||
test('incomplete or incompatible arms cannot support a runtime conclusion', () => {
|
||||
const left = arm('bun', false);
|
||||
const mutations = [
|
||||
(right: any) => { right.backgroundPreflight.comparisonControl.ready = false; },
|
||||
(right: any) => { right.launcherCleanup.userDomainStopped = false; },
|
||||
(right: any) => { right.qualification.cleanup.sourceProfileRemoved = false; },
|
||||
(right: any) => { right.qualification.artifact.executableSha256 = 'b'.repeat(64); },
|
||||
(right: any) => { right.qualification.launchComparison.source.argvSha256 = 'b'.repeat(64); },
|
||||
(right: any) => { right.qualification.launchComparison.source.launchAttempts[0].sandboxDisablingFlag = true; },
|
||||
(right: any) => { right.qualification.launchComparison.source.driver.version = '24.19.0'; },
|
||||
(right: any) => { right.qualification.launchComparison.source.cleanup.launchSettled = false; },
|
||||
(right: any) => { right.counts.pass = 1; },
|
||||
(right: any) => { right.launcher.accountGuid = left.launcher.accountGuid; },
|
||||
(right: any) => { delete right.qualification.platform; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const right = arm('node', true);
|
||||
mutate(right);
|
||||
expect(compareDiaLaunchReceipts(left, right).comparable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('comparison supervisor binds code hashes and rejects extra private response fields', async () => {
|
||||
const work = path.join(root, 'ipc');
|
||||
mkdirSync(path.join(work, 'bin'), { recursive: true });
|
||||
mkdirSync(path.join(work, 'repo/.github/scripts'), { recursive: true });
|
||||
const executable = path.join(work, 'bin/node');
|
||||
const driver = path.join(work, 'repo/.github/scripts/dia-launch-driver.mjs');
|
||||
const helpers = path.join(work, 'repo/.github/scripts/qualify-dia-macos.ts');
|
||||
for (const file of [executable, driver, helpers]) writeFileSync(file, 'synthetic code');
|
||||
const hash = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
const account: any = { work, snapshot: path.join(work, 'repo'), configFile: path.join(work, 'account.json'), environment: { HOME: work },
|
||||
launchComparison: { mode: 'launch-only', runtime: 'node', executable, executableSha256: hash(executable), driverSha256: hash(driver), helpersSha256: hash(helpers) } };
|
||||
const response: any = { protocol: 1, purpose: 'control', ready: false, launchReturned: false, samplingEnabled: false, cleanup: { confirmed: false },
|
||||
driver: { runtime: 'node', version: '24.18.0', architecture: 'arm64', os: 'darwin', playwright: '1.62.1',
|
||||
executableSha256: hash(executable), driverSha256: hash(driver), helpersSha256: hash(helpers) } };
|
||||
let calls = 0;
|
||||
const execute = ((command: string, args: string[], options: any) => {
|
||||
calls++;
|
||||
expect(command).toBe(executable);
|
||||
expect(args).toEqual([driver, account.configFile]);
|
||||
expect(JSON.parse(options.input)).toEqual({ purpose: 'control' });
|
||||
expect(options.timeout).toBeLessThanOrEqual(40_000);
|
||||
expect(options.maxBuffer).toBe(64 * 1024);
|
||||
return { status: 0, stdout: JSON.stringify(response), stderr: '' };
|
||||
}) as typeof spawnSync;
|
||||
expect((await runDiaLaunchComparison(account, 'control', undefined, execute)).supervisor.exitCode).toBe(0);
|
||||
response.error = 'private-error-value';
|
||||
expect((await runDiaLaunchComparison(account, 'control', undefined, execute)).error).toBe('driver_exchange_failed');
|
||||
writeFileSync(executable, 'changed executable');
|
||||
await expect(runDiaLaunchComparison(account, 'control', undefined, execute)).rejects.toThrow('comparison_driver_inputs_changed');
|
||||
expect(calls).toBe(2);
|
||||
await expect(runDiaLaunchComparison({ ...account, launchComparison: undefined }, 'control', undefined, execute)).rejects.toThrow('comparison_driver_authority_missing');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+125
@@ -0,0 +1,125 @@
|
||||
import { lstatSync, unlinkSync } from 'node:fs';
|
||||
import { toNamespacedPath } from 'node:path';
|
||||
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
|
||||
type Identity = { dev: bigint; ino: bigint; mode: bigint };
|
||||
type Handle = number | bigint;
|
||||
type Stage = 'admission' | 'identity' | 'disposition' | 'close' | 'absence';
|
||||
|
||||
export class FixtureDeleteError extends Error {
|
||||
readonly code: string;
|
||||
readonly syscall: string;
|
||||
constructor(public stage: Stage, public path: string, public win32Error?: number, public deadlineExceeded = false) {
|
||||
super(`Owned fixture deletion failed at ${stage}${deadlineExceeded ? ' (deadline)' : win32Error === undefined ? '' : ` (Windows ${win32Error})`}`);
|
||||
this.name = 'FixtureDeleteError';
|
||||
this.code = deadlineExceeded ? 'ETIMEDOUT' : win32Error === 32 ? 'EBUSY' : win32Error === 2 || win32Error === 3 ? 'ENOENT' : win32Error === 5 ? 'EACCES' : 'EIO';
|
||||
this.syscall = { admission: 'open', identity: 'fstat', disposition: 'unlink', close: 'close', absence: 'lstat' }[stage];
|
||||
}
|
||||
}
|
||||
|
||||
export interface FixtureDeleteBackend {
|
||||
open(file: string): Handle;
|
||||
identity(handle: Handle, file: string): { dev: bigint; ino: bigint; attributes: number; filesystem: string };
|
||||
dispose(handle: Handle, file: string): void;
|
||||
close(handle: Handle, file: string): void;
|
||||
absent(file: string): boolean;
|
||||
}
|
||||
|
||||
type LeaseClock = { now(): number; wait(ms: number): void; onSharing?(): void };
|
||||
const leaseClock: LeaseClock = { now: () => performance.now(), wait: ms => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } };
|
||||
|
||||
export function deleteWithFixtureLease(file: string, expected: Identity, deadline: number, verify: () => void,
|
||||
backend: FixtureDeleteBackend, clock: LeaseClock = leaseClock): { admissionProbes: number; waitedMs: number } {
|
||||
if (!Number.isFinite(deadline)) throw new Error('Invalid fixture deletion deadline');
|
||||
let handle: Handle | undefined;
|
||||
let firstSharing: FixtureDeleteError | undefined;
|
||||
let admissionProbes = 0;
|
||||
let waitedMs = 0;
|
||||
while (handle === undefined) {
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
verify();
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
try { admissionProbes++; handle = backend.open(file); }
|
||||
catch (error) {
|
||||
if (!(error instanceof FixtureDeleteError) || error.stage !== 'admission' || error.win32Error !== 32) throw error;
|
||||
if (!firstSharing) { firstSharing = error; clock.onSharing?.(); }
|
||||
const remaining = deadline - clock.now();
|
||||
if (remaining <= 0) throw firstSharing;
|
||||
const before = clock.now();
|
||||
clock.wait(Math.min(20, remaining));
|
||||
waitedMs += Math.max(0, clock.now() - before);
|
||||
}
|
||||
}
|
||||
let failed = false;
|
||||
let failure: unknown;
|
||||
try {
|
||||
const current = backend.identity(handle, file);
|
||||
if (current.filesystem !== 'NTFS' || current.dev !== expected.dev || current.ino !== expected.ino
|
||||
|| (current.attributes & (0x1 | 0x10 | 0x400)) !== 0) throw new FixtureDeleteError('identity', file);
|
||||
verify();
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
backend.dispose(handle, file);
|
||||
} catch (error) { failed = true; failure = error; }
|
||||
try { backend.close(handle, file); }
|
||||
catch (error) {
|
||||
if (failed) console.error(JSON.stringify({ nativeFixtureDeleteCloseFailure: {
|
||||
stage: error instanceof FixtureDeleteError ? error.stage : 'close',
|
||||
win32Error: error instanceof FixtureDeleteError ? error.win32Error : undefined,
|
||||
} }));
|
||||
else { failed = true; failure = error; }
|
||||
}
|
||||
if (failed) throw failure;
|
||||
if (!backend.absent(file)) throw new FixtureDeleteError('absence', file);
|
||||
return { admissionProbes, waitedMs };
|
||||
}
|
||||
|
||||
export function createFixtureDeleteLease(deadline: number, onSharing?: () => void) {
|
||||
if (process.platform !== 'win32') return { unlink: (file: string, _identity: Identity, _verify: () => void) => unlinkSync(file), close: () => {} };
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
GetFileInformationByHandle: { args: [FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
|
||||
GetVolumeInformationByHandleW: { args: [FFIType.u64, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
||||
SetFileInformationByHandle: { args: [FFIType.u64, FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
GetLastError: { args: [], returns: FFIType.u32 },
|
||||
});
|
||||
const backend: FixtureDeleteBackend = {
|
||||
open(file) {
|
||||
const name = Buffer.from(toNamespacedPath(file) + '\0', 'utf16le');
|
||||
const handle = kernel.symbols.CreateFileW(ptr(name), 0x10080, 3, null, 3, 0x00200000, 0);
|
||||
const error = kernel.symbols.GetLastError();
|
||||
if (BigInt(handle) === 0xffffffffffffffffn || BigInt(handle) === 0n) throw new FixtureDeleteError('admission', file, error);
|
||||
return handle;
|
||||
},
|
||||
identity(handle, file) {
|
||||
const info = Buffer.alloc(52);
|
||||
if (!kernel.symbols.GetFileInformationByHandle(handle, ptr(info))) throw new FixtureDeleteError('identity', file, kernel.symbols.GetLastError());
|
||||
const filesystem = Buffer.alloc(128);
|
||||
if (!kernel.symbols.GetVolumeInformationByHandleW(handle, null, 0, null, null, null, ptr(filesystem), 64)) throw new FixtureDeleteError('identity', file, kernel.symbols.GetLastError());
|
||||
return { dev: BigInt(info.readUInt32LE(28)), ino: (BigInt(info.readUInt32LE(44)) << 32n) | BigInt(info.readUInt32LE(48)),
|
||||
attributes: info.readUInt32LE(0), filesystem: filesystem.toString('utf16le').split('\0')[0] };
|
||||
},
|
||||
dispose(handle, file) {
|
||||
const flags = Buffer.alloc(4);
|
||||
flags.writeUInt32LE(3);
|
||||
if (!kernel.symbols.SetFileInformationByHandle(handle, 21, ptr(flags), 4)) throw new FixtureDeleteError('disposition', file, kernel.symbols.GetLastError());
|
||||
},
|
||||
close(handle, file) {
|
||||
if (!kernel.symbols.CloseHandle(handle)) throw new FixtureDeleteError('close', file, kernel.symbols.GetLastError());
|
||||
},
|
||||
absent(file) {
|
||||
try { lstatSync(file); return false; }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true; throw error; }
|
||||
},
|
||||
};
|
||||
return {
|
||||
unlink(file: string, identity: Identity, verify: () => void) {
|
||||
if ((identity.mode & 0o170000n) !== 0o100000n) { verify(); unlinkSync(file); return; }
|
||||
const receipt = deleteWithFixtureLease(file, identity, deadline, verify, backend, { ...leaseClock, onSharing });
|
||||
if (receipt.admissionProbes > 1) console.log(JSON.stringify({ nativeFixtureSharingAdmission: {
|
||||
objectDev: identity.dev.toString(), objectIno: identity.ino.toString(), ...receipt, dispositionCalls: 1,
|
||||
} }));
|
||||
},
|
||||
close() { kernel.close(); },
|
||||
};
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { lstatSync, realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
|
||||
let stage = 'platform';
|
||||
|
||||
class FileOwnerProbeError extends Error {
|
||||
errorCode?: string;
|
||||
constructor(public stage: string, error: unknown) {
|
||||
super('owner_query_failed');
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (['EPERM', 'EACCES', 'EBUSY', 'ENOENT', 'EINVAL', 'ENOTDIR', 'ENAMETOOLONG', 'ETIMEDOUT'].includes(code || '')) this.errorCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
function inspect() {
|
||||
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) return { available: false, reason: 'not_windows' };
|
||||
stage = 'input_decode';
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
if (typeof input.root !== 'string' || typeof input.file !== 'string' || !Number.isSafeInteger(input.testPid)) return { available: false, reason: 'invalid_input' };
|
||||
stage = 'resolve_root';
|
||||
const root = realpathSync(input.root);
|
||||
stage = 'resolve_file';
|
||||
const file = realpathSync(input.file);
|
||||
const relative = path.relative(root, file);
|
||||
stage = 'file_stat';
|
||||
const initial = lstatSync(input.file, { bigint: true });
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative) || !initial.isFile() || initial.isSymbolicLink()
|
||||
|| relative !== path.relative(root, path.resolve(input.file))) return { available: false, reason: 'outside_owned_fixture' };
|
||||
if (input.expectedIdentity !== undefined && (input.expectedIdentity?.dev !== initial.dev.toString()
|
||||
|| input.expectedIdentity?.ino !== initial.ino.toString())) return { available: false, reason: 'failed_object_identity_changed' };
|
||||
const unchanged = () => {
|
||||
stage = 'file_recheck';
|
||||
const current = lstatSync(input.file, { bigint: true });
|
||||
return current.isFile() && !current.isSymbolicLink() && current.dev === initial.dev && current.ino === initial.ino
|
||||
&& realpathSync(input.file) === file;
|
||||
};
|
||||
stage = 'load_restart_manager';
|
||||
const restart = dlopen(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'rstrtmgr.dll'), {
|
||||
RmStartSession: { args: [FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmRegisterResources: { args: [FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmGetList: { args: [FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmEndSession: { args: [FFIType.u32], returns: FFIType.u32 },
|
||||
});
|
||||
stage = 'load_kernel';
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
|
||||
GetProcessTimes: { args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
QueryFullProcessImageNameW: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
let session: number | undefined;
|
||||
try {
|
||||
const sessionBuffer = Buffer.alloc(4);
|
||||
const key = Buffer.alloc(66);
|
||||
stage = 'session_start';
|
||||
let status = restart.symbols.RmStartSession(ptr(sessionBuffer), 0, ptr(key));
|
||||
if (status !== 0) return { available: false, reason: 'session_start', status };
|
||||
session = sessionBuffer.readUInt32LE(0);
|
||||
const wideFile = Buffer.from(file + '\0', 'utf16le');
|
||||
const names = Buffer.alloc(8);
|
||||
names.writeBigUInt64LE(BigInt(ptr(wideFile)));
|
||||
stage = 'register_file';
|
||||
status = restart.symbols.RmRegisterResources(session, 1, ptr(names), 0, null, 0, null);
|
||||
if (status !== 0) return { available: false, reason: 'register_file', status };
|
||||
const needed = Buffer.alloc(4);
|
||||
const count = Buffer.alloc(4);
|
||||
const rebootReasons = Buffer.alloc(4);
|
||||
stage = 'owner_count';
|
||||
status = restart.symbols.RmGetList(session, ptr(needed), ptr(count), null, ptr(rebootReasons));
|
||||
if (status === 0 && needed.readUInt32LE(0) === 0) return unchanged()
|
||||
? { available: true, owners: [], rebootReasons: rebootReasons.readUInt32LE(0) }
|
||||
: { available: false, reason: 'failed_object_identity_changed' };
|
||||
const entries = needed.readUInt32LE(0);
|
||||
if (status !== 234 || entries < 1 || entries > 64) return { available: false, reason: 'owner_count', status, entries };
|
||||
const information = Buffer.alloc(entries * 668);
|
||||
count.writeUInt32LE(entries);
|
||||
stage = 'owner_list';
|
||||
status = restart.symbols.RmGetList(session, ptr(needed), ptr(count), ptr(information), ptr(rebootReasons));
|
||||
const returned = count.readUInt32LE(0);
|
||||
if (status !== 0 || returned > entries) return { available: false, reason: 'owner_list', status };
|
||||
const owners = [];
|
||||
stage = 'owner_identity';
|
||||
for (let index = 0; index < returned; index++) {
|
||||
const offset = index * 668;
|
||||
const pid = information.readUInt32LE(offset);
|
||||
const recordedStart = information.readBigUInt64LE(offset + 4);
|
||||
let image = 'unavailable';
|
||||
let creationMatched = false;
|
||||
const handle = kernel.symbols.OpenProcess(0x1000, 0, pid);
|
||||
if (handle) {
|
||||
try {
|
||||
const times = Buffer.alloc(32);
|
||||
const timeAddress = ptr(times);
|
||||
if (kernel.symbols.GetProcessTimes(handle, timeAddress, timeAddress + 8, timeAddress + 16, timeAddress + 24)) {
|
||||
creationMatched = times.readBigUInt64LE(0) === recordedStart;
|
||||
}
|
||||
if (creationMatched) {
|
||||
const imageBuffer = Buffer.alloc(65536);
|
||||
const imageLength = Buffer.alloc(4);
|
||||
imageLength.writeUInt32LE(32768);
|
||||
if (kernel.symbols.QueryFullProcessImageNameW(handle, 0, ptr(imageBuffer), ptr(imageLength))) {
|
||||
const chars = imageLength.readUInt32LE(0);
|
||||
const name = chars <= 32768 ? path.basename(imageBuffer.subarray(0, chars * 2).toString('utf16le')).toLowerCase() : '';
|
||||
image = ['bun.exe', 'node.exe', 'msedge.exe', 'msmpeng.exe', 'mssense.exe', 'dllhost.exe', 'explorer.exe', 'powershell.exe', 'pwsh.exe', 'svchost.exe', 'conhost.exe'].includes(name) ? name : 'other';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
kernel.symbols.CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
owners.push({ pid, image, creationMatched, isTestHost: creationMatched && pid === input.testPid, applicationType: information.readUInt32LE(offset + 652) });
|
||||
}
|
||||
return unchanged() ? { available: true, owners, rebootReasons: rebootReasons.readUInt32LE(0) }
|
||||
: { available: false, reason: 'failed_object_identity_changed' };
|
||||
} catch (error) {
|
||||
throw new FileOwnerProbeError(stage, error);
|
||||
} finally {
|
||||
stage = 'session_end';
|
||||
if (session !== undefined) restart.symbols.RmEndSession(session);
|
||||
stage = 'library_close';
|
||||
kernel.close(); restart.close();
|
||||
}
|
||||
}
|
||||
|
||||
let result: object;
|
||||
try { result = inspect(); }
|
||||
catch (error) {
|
||||
const failure = error instanceof FileOwnerProbeError ? error : new FileOwnerProbeError(stage, error);
|
||||
result = { available: false, reason: 'owner_query_failed', stage: failure.stage, errorCode: failure.errorCode };
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(0));
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
const fs = require('node:fs');
|
||||
const cp = require('node:child_process');
|
||||
const { createHash } = require('node:crypto');
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = ({ observation, playwrightEntry, mode = 'normal-close', inspectCommandLine = false, observerExecutable, seedCookie = { name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/' } }) => {
|
||||
if (inspectCommandLine && process.platform === 'win32' && typeof observerExecutable !== 'string') throw new Error('Native observer executable is required');
|
||||
const originalSpawn = cp.spawn;
|
||||
let inspected = Promise.resolve();
|
||||
let folderEvidence;
|
||||
const directoryState = (env, root) => ({
|
||||
requestedProfile: fs.existsSync(root),
|
||||
localEnvironment: fs.existsSync(env.LOCALAPPDATA || ''),
|
||||
roamingEnvironment: fs.existsSync(env.APPDATA || ''),
|
||||
localUnderProfile: fs.existsSync(path.join(env.USERPROFILE || '', 'AppData', 'Local')),
|
||||
roamingUnderProfile: fs.existsSync(path.join(env.USERPROFILE || '', 'AppData', 'Roaming')),
|
||||
});
|
||||
const safeFolders = value => Object.fromEntries(['local', 'roaming'].map(name => [name,
|
||||
Object.fromEntries(['verified', 'dontVerify'].map(kind => {
|
||||
const item = value?.[name]?.[kind];
|
||||
return [kind, {
|
||||
hresult: Number.isInteger(item?.hresult) ? item.hresult : null,
|
||||
pathHash: /^[a-f0-9]{64}$/.test(item?.pathHash) ? item.pathHash : null,
|
||||
exists: typeof item?.exists === 'boolean' ? item.exists : null,
|
||||
matchesEnvironment: typeof item?.matchesEnvironment === 'boolean' ? item.matchesEnvironment : null,
|
||||
underUserProfile: typeof item?.underUserProfile === 'boolean' ? item.underUserProfile : null,
|
||||
}];
|
||||
})),
|
||||
]));
|
||||
const runProbe = (input, env) => new Promise(resolve => {
|
||||
const probe = originalSpawn(observerExecutable, [
|
||||
'--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.join(__dirname, 'native-cookie-process-observer.ts'),
|
||||
Buffer.from(JSON.stringify(input)).toString('base64'),
|
||||
], { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
||||
let output = '';
|
||||
let stderrBytes = 0;
|
||||
let spawnFailed = false;
|
||||
const timer = setTimeout(() => probe.kill(), 5_000);
|
||||
probe.stdout.on('data', chunk => { output += chunk.toString('utf8'); if (output.length > 16384) probe.kill(); });
|
||||
probe.stderr.on('data', chunk => { stderrBytes += chunk.length; });
|
||||
probe.once('error', () => { spawnFailed = true; });
|
||||
probe.once('close', code => {
|
||||
clearTimeout(timer);
|
||||
try { resolve({ measured: JSON.parse(output), code, stderrBytes }); }
|
||||
catch { resolve({ measured: { available: false, reason: spawnFailed ? 'probe_spawn_failed' : 'probe_no_receipt' }, code, stderrBytes }); }
|
||||
});
|
||||
});
|
||||
cp.spawn = function(command, args, options) {
|
||||
if (args.some(arg => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))) throw new Error('Native fixture refuses a sandbox-disabled browser');
|
||||
const child = originalSpawn.call(this, command, args, options);
|
||||
const evidence = {
|
||||
command, args, pid: child.pid,
|
||||
argsHash: createHash('sha256').update(JSON.stringify(args)).digest('hex'),
|
||||
envHash: createHash('sha256').update(JSON.stringify(Object.entries(options.env || {}).sort(([a], [b]) => a.localeCompare(b)))).digest('hex'),
|
||||
stderrBytes: 0,
|
||||
reasons: [],
|
||||
runtime: { node: process.version, bun: process.versions.bun || null, architecture: process.arch },
|
||||
folderEvidence,
|
||||
};
|
||||
const publish = () => fs.writeFileSync(observation, JSON.stringify(evidence));
|
||||
publish();
|
||||
let tail = '';
|
||||
child.stderr?.on('data', chunk => {
|
||||
evidence.stderrBytes += chunk.length;
|
||||
if (evidence.stderrBytes > 65536) return;
|
||||
const text = tail + chunk.toString('utf8');
|
||||
const patterns = {
|
||||
job_assignment_failed: /AssignProcessToJobObject|failed to (?:assign|create).*job object/i,
|
||||
sandbox_failed: /sandbox.*(?:failed|error)|SBOX_FATAL/i,
|
||||
profile_locked: /ProcessSingleton|profile.*in use/i,
|
||||
default_profile_policy: /remote debugging requires a non-default data directory/i,
|
||||
permission_denied: /access is denied|ERROR_ACCESS_DENIED|permission denied/i,
|
||||
crashpad_failed: /crashpad.*(?:failed|error)/i,
|
||||
missing_dependency: /specified module could not be found|0xc0000135/i,
|
||||
};
|
||||
for (const [reason, pattern] of Object.entries(patterns)) {
|
||||
if (pattern.test(text) && !evidence.reasons.includes(reason)) evidence.reasons.push(reason);
|
||||
}
|
||||
tail = text.slice(-512);
|
||||
publish();
|
||||
});
|
||||
child.once('exit', (code, signal) => { evidence.exitCode = code; evidence.signal = signal; publish(); });
|
||||
child.once('error', error => {
|
||||
evidence.spawnError = ['ENOENT', 'EACCES', 'EPERM', 'EINVAL'].includes(error.code) ? error.code : 'spawn_failed';
|
||||
publish();
|
||||
});
|
||||
if (inspectCommandLine && process.platform === 'win32' && Number.isInteger(child.pid)) {
|
||||
inspected = runProbe({ pid: child.pid, owner: process.pid, image: command }, options.env).then(({ measured, code, stderrBytes }) => {
|
||||
const expectedHashes = args.map(arg => createHash('sha256').update(arg).digest('hex'));
|
||||
const dataDir = args.find(arg => arg.startsWith('--user-data-dir='))?.slice(16);
|
||||
evidence.observedCommandLine = {
|
||||
available: measured.available === true,
|
||||
parentMatched: measured.parentMatched === true,
|
||||
imageMatched: measured.imageMatched === true,
|
||||
reason: ['not_windows', 'invalid_input', 'process_open', 'process_identity', 'owned_process_unavailable', 'command_line', 'command_line_length', 'command_line_bounds', 'argument_parse', 'argument_bounds', 'job_query', 'observer_initialize', 'probe_spawn_failed', 'probe_no_receipt'].includes(measured.reason) ? measured.reason : undefined,
|
||||
win32Error: Number.isInteger(measured.win32Error) ? measured.win32Error : undefined,
|
||||
ntStatus: Number.isInteger(measured.ntStatus) ? measured.ntStatus : undefined,
|
||||
commandLineHash: /^[a-f0-9]{64}$/.test(measured.commandLineHash) ? measured.commandLineHash : undefined,
|
||||
argumentsMatchRequested: measured.available === true && JSON.stringify(measured.argumentHashes) === JSON.stringify(expectedHashes),
|
||||
userDataDirCount: Number.isInteger(measured.userDataDirCount) && measured.userDataDirCount >= 0 && measured.userDataDirCount <= 128 ? measured.userDataDirCount : undefined,
|
||||
userDataDirMatchesRequested: typeof dataDir === 'string' && measured.userDataDirHash === createHash('sha256').update(dataDir).digest('hex'),
|
||||
pipePresent: measured.pipePresent === true,
|
||||
browserInJob: typeof measured.browserInJob === 'boolean' ? measured.browserInJob : undefined,
|
||||
observerJobLimitFlags: Number.isInteger(measured.observerJobLimitFlags) ? measured.observerJobLimitFlags : undefined,
|
||||
observerJobQueryError: Number.isInteger(measured.observerJobQueryError) ? measured.observerJobQueryError : undefined,
|
||||
exitCode: code, stderrBytes,
|
||||
};
|
||||
evidence.folderEvidence.afterLaunch = safeFolders(measured.knownFolders);
|
||||
evidence.folderEvidence.directoriesAfterLaunch = directoryState(options.env, args.find(arg => arg.startsWith('--user-data-dir='))?.slice(16) || '');
|
||||
publish();
|
||||
});
|
||||
}
|
||||
return child;
|
||||
};
|
||||
const { chromium } = require(playwrightEntry);
|
||||
return { chromium: { async launchPersistentContext(root, options) {
|
||||
if (options.chromiumSandbox !== true) throw new Error('Native fixture requires the browser sandbox');
|
||||
const started = Date.now();
|
||||
if (inspectCommandLine && process.platform === 'win32') {
|
||||
const directoriesBefore = directoryState(options.env, root);
|
||||
const before = await runProbe({ mode: 'known-folders' }, options.env);
|
||||
folderEvidence = { beforeLaunch: safeFolders(before.measured.knownFolders), directoriesBefore, directoriesAfterProbe: directoryState(options.env, root) };
|
||||
}
|
||||
const context = await chromium.launchPersistentContext(root, inspectCommandLine && process.platform === 'win32'
|
||||
? { ...options, timeout: Math.max(1, options.timeout - (Date.now() - started)) } : options);
|
||||
await inspected;
|
||||
await context.addCookies([seedCookie]);
|
||||
if (mode === 'stalled-close') context.close = () => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); };
|
||||
return context;
|
||||
} } };
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { dlopen, FFIType, ptr, toArrayBuffer } from 'bun:ffi';
|
||||
|
||||
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
|
||||
|
||||
export function decodeNativeCommandLine(buffer: Buffer, address: number | bigint): string | null {
|
||||
if (buffer.length < 16) return null;
|
||||
const length = buffer.readUInt16LE(0);
|
||||
const offset = Number(buffer.readBigUInt64LE(8) - BigInt(address));
|
||||
if (!Number.isSafeInteger(offset) || offset < 16 || offset + length > buffer.length || length % 2 !== 0) return null;
|
||||
return buffer.subarray(offset, offset + length).toString('utf16le');
|
||||
}
|
||||
|
||||
function knownFolders() {
|
||||
const shell = dlopen('shell32.dll', {
|
||||
SHGetFolderPathW: { args: [FFIType.u64, FFIType.i32, FFIType.u64, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
});
|
||||
const normalized = (value: string) => path.win32.normalize(value).toLowerCase();
|
||||
try {
|
||||
return Object.fromEntries([['local', 0x1c, 'LOCALAPPDATA'], ['roaming', 0x1a, 'APPDATA']].map(([name, id, env]) => {
|
||||
const calls = Object.fromEntries([['verified', 0], ['dontVerify', 0x4000]].map(([kind, flag]) => {
|
||||
const output = Buffer.alloc(520);
|
||||
const status = shell.symbols.SHGetFolderPathW(0, Number(id) | Number(flag), 0, 0, ptr(output));
|
||||
let end = 0;
|
||||
while (end + 2 <= output.length && output.readUInt16LE(end) !== 0) end += 2;
|
||||
const folder = status >= 0 && end > 0 && end + 2 <= output.length ? output.subarray(0, end).toString('utf16le') : null;
|
||||
return [kind, {
|
||||
hresult: status,
|
||||
pathHash: folder ? hash(normalized(folder)) : null,
|
||||
exists: folder ? existsSync(folder) : null,
|
||||
matchesEnvironment: folder ? normalized(folder) === normalized(process.env[String(env)] || '') : null,
|
||||
underUserProfile: folder ? normalized(folder).startsWith(normalized(process.env.USERPROFILE || '') + '\\') : null,
|
||||
}];
|
||||
}));
|
||||
return [name, calls];
|
||||
}));
|
||||
} finally {
|
||||
shell.close();
|
||||
}
|
||||
}
|
||||
|
||||
function observe() {
|
||||
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) return { available: false, reason: 'not_windows' };
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
if (input.mode === 'known-folders') return { available: true, knownFolders: knownFolders() };
|
||||
if (!Number.isSafeInteger(input.pid) || input.pid <= 0 || input.pid > 0xffffffff || !Number.isSafeInteger(input.owner) || input.owner <= 0 || input.owner > 0xffffffff || typeof input.image !== 'string' || input.image.length > 32768) {
|
||||
return { available: false, reason: 'invalid_input' };
|
||||
}
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
QueryFullProcessImageNameW: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
QueryInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
IsProcessInJob: { args: [FFIType.u64, FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
|
||||
LocalFree: { args: [FFIType.ptr], returns: FFIType.ptr },
|
||||
LocalSize: { args: [FFIType.ptr], returns: FFIType.u64 },
|
||||
GetLastError: { args: [], returns: FFIType.u32 },
|
||||
});
|
||||
const nt = dlopen('ntdll.dll', {
|
||||
NtQueryInformationProcess: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
});
|
||||
const shell = dlopen('shell32.dll', {
|
||||
CommandLineToArgvW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.ptr },
|
||||
});
|
||||
let processHandle: number | bigint = 0;
|
||||
let argumentMemory: ReturnType<typeof shell.symbols.CommandLineToArgvW> = null;
|
||||
let stage = 'process_open';
|
||||
try {
|
||||
processHandle = kernel.symbols.OpenProcess(0x1000, 0, input.pid);
|
||||
if (!processHandle) return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
|
||||
stage = 'process_identity';
|
||||
const basic = Buffer.alloc(48);
|
||||
const returned = Buffer.alloc(4);
|
||||
let status = nt.symbols.NtQueryInformationProcess(processHandle, 0, ptr(basic), basic.length, ptr(returned));
|
||||
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
|
||||
const parentMatched = basic.readBigUInt64LE(40) === BigInt(input.owner);
|
||||
const pidMatched = basic.readBigUInt64LE(32) === BigInt(input.pid);
|
||||
const imageBuffer = Buffer.alloc(65536);
|
||||
const imageLength = Buffer.alloc(4);
|
||||
imageLength.writeUInt32LE(32768);
|
||||
if (!kernel.symbols.QueryFullProcessImageNameW(processHandle, 0, ptr(imageBuffer), ptr(imageLength))) {
|
||||
return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
|
||||
}
|
||||
const imageChars = imageLength.readUInt32LE(0);
|
||||
const imageMatched = imageChars <= 32768 && imageBuffer.subarray(0, imageChars * 2).toString('utf16le').toLowerCase() === input.image.toLowerCase();
|
||||
if (!parentMatched || !pidMatched || !imageMatched) return { available: false, reason: 'owned_process_unavailable', parentMatched, imageMatched };
|
||||
stage = 'command_line';
|
||||
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, null, 0, ptr(returned));
|
||||
const length = returned.readUInt32LE(0);
|
||||
if (length < 16 || length > 131072) return { available: false, reason: 'command_line_length', ntStatus: status };
|
||||
const commandBuffer = Buffer.alloc(length);
|
||||
const commandAddress = ptr(commandBuffer);
|
||||
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, commandAddress, length, ptr(returned));
|
||||
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
|
||||
const commandLine = decodeNativeCommandLine(commandBuffer, commandAddress);
|
||||
if (commandLine === null) return { available: false, reason: 'command_line_bounds' };
|
||||
stage = 'argument_parse';
|
||||
const wideCommand = Buffer.from(commandLine + '\0', 'utf16le');
|
||||
const count = Buffer.alloc(4);
|
||||
argumentMemory = shell.symbols.CommandLineToArgvW(ptr(wideCommand), ptr(count));
|
||||
const argumentCount = count.readInt32LE(0);
|
||||
if (!argumentMemory || argumentCount < 1 || argumentCount > 4096) return { available: false, reason: stage };
|
||||
const size = Number(kernel.symbols.LocalSize(argumentMemory));
|
||||
if (!Number.isSafeInteger(size) || size < argumentCount * 8 || size > 1048576) return { available: false, reason: 'argument_bounds' };
|
||||
const argumentsBuffer = Buffer.from(toArrayBuffer(argumentMemory, 0, size));
|
||||
const args: string[] = [];
|
||||
for (let index = 1; index < argumentCount; index++) {
|
||||
const start = Number(argumentsBuffer.readBigUInt64LE(index * 8) - BigInt(argumentMemory));
|
||||
if (!Number.isSafeInteger(start) || start < argumentCount * 8 || start % 2 !== 0 || start >= size) return { available: false, reason: 'argument_bounds' };
|
||||
let end = start;
|
||||
while (end + 2 <= size && argumentsBuffer.readUInt16LE(end) !== 0) end += 2;
|
||||
if (end + 2 > size) return { available: false, reason: 'argument_bounds' };
|
||||
args.push(argumentsBuffer.subarray(start, end).toString('utf16le'));
|
||||
}
|
||||
stage = 'job_query';
|
||||
const limits = Buffer.alloc(144);
|
||||
const jobKnown = kernel.symbols.QueryInformationJobObject(0, 9, ptr(limits), limits.length, ptr(returned));
|
||||
const jobError = jobKnown ? undefined : kernel.symbols.GetLastError();
|
||||
const inJob = Buffer.alloc(4);
|
||||
const membershipKnown = kernel.symbols.IsProcessInJob(processHandle, 0, ptr(inJob));
|
||||
const dataArgs = args.filter(arg => arg.startsWith('--user-data-dir='));
|
||||
return {
|
||||
available: true, parentMatched, imageMatched,
|
||||
commandLineHash: hash(commandLine), argumentHashes: args.map(hash),
|
||||
userDataDirCount: dataArgs.length,
|
||||
userDataDirHash: dataArgs.length === 1 ? hash(dataArgs[0].slice(16)) : null,
|
||||
pipePresent: args.includes('--remote-debugging-pipe'),
|
||||
browserInJob: membershipKnown ? inJob.readInt32LE(0) !== 0 : null,
|
||||
observerJobLimitFlags: jobKnown ? limits.readUInt32LE(16) : null,
|
||||
observerJobQueryError: jobError,
|
||||
knownFolders: knownFolders(),
|
||||
};
|
||||
} catch {
|
||||
return { available: false, reason: stage };
|
||||
} finally {
|
||||
if (argumentMemory) kernel.symbols.LocalFree(argumentMemory);
|
||||
if (processHandle) kernel.symbols.CloseHandle(processHandle);
|
||||
shell.close(); nt.close(); kernel.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
let result: object;
|
||||
let exitCode = 0;
|
||||
try { result = observe(); }
|
||||
catch { result = { available: false, reason: 'observer_initialize' }; exitCode = 1; }
|
||||
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(exitCode));
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
module.exports = ({ pidsFile, mode }) => ({
|
||||
chromium: {
|
||||
async launchPersistentContext() {
|
||||
const child = require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
require('node:fs').writeFileSync(pidsFile, JSON.stringify([process.pid, child.pid]));
|
||||
if (mode === 'worker-crash') setTimeout(() => process.exit(3), 100);
|
||||
await new Promise(() => {});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
let stage = 'input';
|
||||
let root;
|
||||
const samePath = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
try {
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
root = input.root;
|
||||
if (typeof root !== 'string' || typeof input.realpath !== 'string' || typeof input.dev !== 'string' || typeof input.ino !== 'string') throw new Error('invalid_input');
|
||||
stage = 'identity';
|
||||
const state = fs.lstatSync(root, { bigint: true });
|
||||
const identity = {
|
||||
directory: state.isDirectory() && !state.isSymbolicLink(),
|
||||
pathMatches: samePath(fs.realpathSync(root), input.realpath),
|
||||
deviceMatches: state.dev.toString() === input.dev,
|
||||
inodeMatches: state.ino.toString() === input.ino,
|
||||
};
|
||||
if (Object.values(identity).some(value => !value)) {
|
||||
console.log(JSON.stringify({ removed: false, reason: 'identity_mismatch', identity }));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
stage = 'remove';
|
||||
fs.rmSync(root, { recursive: true, force: true, maxRetries: 0 });
|
||||
console.log(JSON.stringify({ removed: !fs.existsSync(root), identity, retries: 0 }));
|
||||
}
|
||||
} catch (error) {
|
||||
const code = ['EPERM', 'EACCES', 'EBUSY', 'ENOENT', 'EINVAL', 'ENOTEMPTY', 'ENOTDIR'].includes(error.code) ? error.code : 'filesystem_error';
|
||||
let failingPath = null;
|
||||
let metadata = null;
|
||||
if (typeof root === 'string' && typeof error.path === 'string') {
|
||||
const relative = path.relative(root, error.path);
|
||||
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
||||
failingPath = relative || '.';
|
||||
try {
|
||||
const state = fs.lstatSync(error.path);
|
||||
metadata = { mode: state.mode, directory: state.isDirectory(), link: state.isSymbolicLink() };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ removed: false, stage, code, failingPath, metadata }));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* that could silently remove a fix without breaking compilation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -364,11 +364,38 @@ describe('cookie-import domain validation', () => {
|
||||
expect(block).toContain('does not match current page domain');
|
||||
});
|
||||
|
||||
it('cookie-import-browser handler validates --domain against page hostname', () => {
|
||||
const block = sliceBetween(WRITE_SRC, "case 'cookie-import-browser':", "case 'style':");
|
||||
expect(block).toContain('normalizedDomain');
|
||||
expect(block).toContain('pageHostname');
|
||||
expect(block).toContain('does not match current page domain');
|
||||
it('cookie-import-browser handler validates --domain against page hostname', async () => {
|
||||
const operation = await import('../src/cookie-import-operation');
|
||||
const { handleWriteCommand } = await import('../src/write-commands');
|
||||
const imported = spyOn(operation, 'runCookieImport').mockResolvedValue({
|
||||
browser: 'chromium', profile: 'Profile 2', imported: 2, failed: 0,
|
||||
domainCounts: { '.example.test': 2 }, failureReasons: {}, outcome: 'imported',
|
||||
reset: 'not_requested', verification: { verified: false, reason: 'not_requested' }, message: 'Cookie copy complete.',
|
||||
});
|
||||
let currentUrl = 'https://example.test';
|
||||
const page = { url: () => currentUrl, isClosed: () => false };
|
||||
const session = { getPage: () => page, getActiveFrameOrPage: () => page, getFrame: () => null } as any;
|
||||
const manager = { trackCookieImportDomains() {} } as any;
|
||||
try {
|
||||
for (const [target, domain] of [
|
||||
['https://example.test', 'unrelated.test'],
|
||||
['https://example.test.evil.invalid', 'example.test'],
|
||||
['https://badexample.test', 'example.test'],
|
||||
]) {
|
||||
currentUrl = target;
|
||||
await expect(handleWriteCommand('cookie-import-browser', ['chromium', '--domain', domain], session, manager))
|
||||
.rejects.toMatchObject({ code: 'target_mismatch' });
|
||||
}
|
||||
expect(imported).not.toHaveBeenCalled();
|
||||
currentUrl = 'https://sub.example.test/protected';
|
||||
const result = await handleWriteCommand('cookie-import-browser', ['chromium', '--domain', '.Example.Test.', '--profile', 'Profile 2'], session, manager);
|
||||
expect(imported).toHaveBeenCalledTimes(1);
|
||||
expect(imported.mock.calls[0][0]).toMatchObject({ browser: 'chromium', domains: ['example.test'], profile: 'Profile 2' });
|
||||
expect(imported.mock.calls[0][1]).toEqual({ page, url: currentUrl });
|
||||
expect(result).toContain('Imported 2 cookies from chromium (profile: Profile 2)');
|
||||
} finally {
|
||||
imported.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,10 +42,13 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => {
|
||||
// isProcessAlive no longer spawns anything (signal-0 on every platform,
|
||||
// #1952) — process-liveness-windows.test.ts pins that it stays
|
||||
// subprocess-free, which is stronger than hiding a window.
|
||||
// powershell DPAPI + tasklist in cookie import.
|
||||
const cookie = SRC('cookie-import-browser.ts');
|
||||
expectHideNearEvery(cookie, "'powershell'");
|
||||
expectHideNearEvery(cookie, "'tasklist'");
|
||||
expect(cookie).not.toContain("'tasklist'");
|
||||
expectHideNearEvery(SRC('cookie-import-native.ts'), 'spawn(bunExecutable');
|
||||
const worker = SRC('cookie-import-native-worker.ts');
|
||||
expectHideNearEvery(worker, 'spawn(process.execPath');
|
||||
expectHideNearEvery(worker, 'spawn(input.request.nodeExecutable');
|
||||
});
|
||||
|
||||
test('icacls calls in file-permissions.ts pass windowsHide', () => {
|
||||
|
||||
+14
-10
@@ -28,7 +28,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples.
|
||||
| [`/document-generate`](#document-generate) | **Technical Writer** | Generate Diataxis docs (tutorial / how-to / reference / explanation) for a feature from code. |
|
||||
| [`/retro`](#retro) | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. |
|
||||
| [`/browse`](#browse) | **QA Engineer** | Give the agent eyes. Drives your Aside browser first — real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts, and falls back to gstack's own Chromium (~100ms per command) when Aside isn't there. |
|
||||
| [`/setup-browser-cookies`](#setup-browser-cookies) | **Session Manager** | Fallback-browser skill: import cookies from your real browser (Chrome, Arc, Brave, Edge) into gstack's headless session to test authenticated pages. Unnecessary on Aside, which already has your sessions. |
|
||||
| [`/setup-browser-cookies`](#setup-browser-cookies) | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, or macOS-only Comet, Arc, and Dia into the fallback browser. Choose your profile and domains; check sign-in separately. Unnecessary on Aside, which already has your sessions. |
|
||||
| [`/autoplan`](#autoplan) | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → DX → eng review automatically (eng always last, so the shipping gate reviews the final amended plan) with encoded decision principles. Surfaces only taste decisions for your approval. |
|
||||
| [`/plan-devex-review`](#plan-devex-review) | **DX Reviewer** | Plan-stage DX review. TTHW (time-to-hello-world), magical moments, friction points, persona traces. Three modes: Expansion, Polish, Triage. |
|
||||
| [`/devex-review`](#devex-review) | **DX Reviewer (live)** | Live developer experience audit. Walks the actual onboarding flow, measures TTHW, catches the docs lies. |
|
||||
@@ -949,31 +949,35 @@ The browser preserves all state across the handoff, and after `resume` the agent
|
||||
|
||||
This is my **session manager mode** — for the fallback browser. With Aside open, `/qa` and `/browse` already run in your real sessions and this skill has nothing to do.
|
||||
|
||||
Before `/qa` or `/browse` can test authenticated pages on gstack's own browser, they need cookies. Instead of manually logging in through the headless browser every time, `/setup-browser-cookies` imports your real sessions directly from your daily browser.
|
||||
For authenticated testing on gstack's own browser, `/setup-browser-cookies` copies selected cookies from your daily browser. Sites may also need storage or a fresh login, so copying cookies is not proof that the session works.
|
||||
|
||||
It auto-detects installed Chromium browsers (Comet, Chrome, Arc, Brave, Edge), decrypts cookies via the macOS Keychain, and loads them into the Playwright session. An interactive picker UI lets you choose exactly which domains to import — no cookie values are ever displayed.
|
||||
The picker detects Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, and Dia. Choose the browser, account/profile, and domains. Profile labels use the current `Local State` name with a directory discriminator, so renamed profiles and duplicate names are distinguishable. No cookie values are displayed; source/profile labels are still sensitive.
|
||||
|
||||
```
|
||||
You: /setup-browser-cookies
|
||||
|
||||
Claude: Cookie picker opened — select the domains you want to import
|
||||
in your browser, then tell me when you're done.
|
||||
Claude: Cookie picker opened. Select your browser, profile, and domains,
|
||||
then tell me when you're done.
|
||||
|
||||
[You pick github.com, myapp.com in the browser UI]
|
||||
[You choose a browser/profile and pick github.com, myapp.com]
|
||||
|
||||
You: done
|
||||
|
||||
Claude: Imported 2 domains (47 cookies). Session is ready.
|
||||
Claude: Imported 2 domains (47 cookies). Sign-in has not been checked.
|
||||
```
|
||||
|
||||
Or skip the UI entirely:
|
||||
For direct import, select the browser and profile first and navigate to a matching target. Do not infer an account from the CLI's legacy Comet default:
|
||||
|
||||
```
|
||||
You: /setup-browser-cookies github.com
|
||||
You: /setup-browser-cookies github.com from Chrome, Profile 2
|
||||
|
||||
Claude: Imported 12 cookies for github.com from Comet.
|
||||
Claude: Imported 12 cookies; sign-in has not been checked.
|
||||
```
|
||||
|
||||
`--verify-auth` is explicit and requires a selector and expected identity configured privately in the daemon environment before startup. It checks one exact visible identity on the captured target, not just HTTP 200 or a cookie count. Missing configuration fails before mutation. `--clear-storage` is separate, opt-in recovery for Chromium targets: it clears only the captured origin's localStorage (shared across that origin's tabs) and the target tab's sessionStorage in an isolated world with a native deadline. Other target engines retain import/auth checks but reject reset. It is never automatic and cannot be combined with `--all`. Partial imports and unsuccessful checks remain visible rather than becoming a false "ready."
|
||||
|
||||
macOS may prompt for Keychain approval; Linux uses its supported keyring/fallback paths; Windows can import DPAPI-compatible cookies, but native App-Bound Encryption extraction remains disabled pending qualification. Closing Chrome does not bypass Chrome 136+ default-directory protection. Use manual sign-in in the headed fallback browser when needed and a display is available, never a TCP downgrade or real-profile copy. Full flags, configuration, and privacy guidance: [cookie import reference](../BROWSER.md#choosing-a-source-and-checking-sign-in).
|
||||
|
||||
---
|
||||
|
||||
## `/make-pdf`
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ Run with `browse <command> [args]`. Full reference: `browse/SKILL.md`.
|
||||
- `click <sel>`: Click element
|
||||
- `cookie <name>=<value>`: Set cookie on current page domain
|
||||
- `cookie-import <json>`: Import cookies from JSON file
|
||||
- `cookie-import-browser [browser] [--domain d] [--profile p] [--all]`: Import cookies from installed Chromium-family browsers.
|
||||
- `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]`: Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia.
|
||||
- `dialog-accept [text]`: Auto-accept next alert/confirm/prompt.
|
||||
- `dialog-dismiss`: Auto-dismiss next dialog
|
||||
- `fill <sel> <val>`: Fill input
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
export const CLAUDE_FRONTIER_EVAL_MODEL = "claude-fable-5-1";
|
||||
export const DEFAULT_JUDGE_MAX_TOKENS = 8192;
|
||||
|
||||
// `as const satisfies` keeps EvalModelKind the literal union
|
||||
// 'capture' | 'warmup' | 'distill' — a `Record<string, string>` annotation
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.89.1",
|
||||
"version": "1.90.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -43,7 +43,7 @@
|
||||
"start": "bun run browse/src/server.ts",
|
||||
"eval:bg": "bin/gstack-detach --label evals --lock gstack-evals --timeout 5400 -- bun run test:evals",
|
||||
"eval:bg:all": "bin/gstack-detach --label evals-all --lock gstack-evals --timeout 7200 -- bun run test:evals:all",
|
||||
"eval:bg:gate": "bin/gstack-detach --label evals-gate --lock gstack-evals --timeout 33600 -- bun run test:gate:sharded",
|
||||
"eval:bg:gate": "bin/gstack-detach --label evals-gate --lock gstack-evals --timeout 33800 -- bun run test:gate:sharded",
|
||||
"eval:bg:periodic": "bin/gstack-detach --label evals-periodic --lock gstack-evals --timeout 66000 -- bun run test:periodic:sharded",
|
||||
"eval:list": "bun run scripts/eval-list.ts",
|
||||
"eval:compare": "bun run scripts/eval-compare.ts",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getProjectEvalDir, isPartialEval, isFinalizedEvalResultFile, type EvalResult } from '../test/helpers/eval-store';
|
||||
import { evalEntryOutcome } from '../test/helpers/eval-store';
|
||||
import { flakeLedgerPath, type FlakeLedgerEntry } from './test-free-shards';
|
||||
|
||||
interface TestSeries {
|
||||
@@ -29,6 +30,7 @@ interface TestSeries {
|
||||
runs: number;
|
||||
passes: number;
|
||||
fails: number;
|
||||
manualAccepted: number;
|
||||
retriedPasses: number;
|
||||
totalAttempts: number;
|
||||
totalCostUsd: number;
|
||||
@@ -54,14 +56,18 @@ export function aggregate(evalFiles: string[]): Map<string, TestSeries> {
|
||||
}
|
||||
for (const [name, entries] of byName) {
|
||||
const s = series.get(name) ?? {
|
||||
name, runs: 0, passes: 0, fails: 0, retriedPasses: 0,
|
||||
name, runs: 0, passes: 0, fails: 0, manualAccepted: 0, retriedPasses: 0,
|
||||
totalAttempts: 0, totalCostUsd: 0, totalDurationMs: 0, lastSeen: '',
|
||||
};
|
||||
const final = entries[entries.length - 1];
|
||||
s.runs += 1;
|
||||
s.totalAttempts += entries.length;
|
||||
if (final.passed) s.passes += 1; else s.fails += 1;
|
||||
if (final.passed && entries.length > 1) s.retriedPasses += 1;
|
||||
const outcome = evalEntryOutcome(final);
|
||||
if (outcome === 'manual-review') s.manualAccepted += 1;
|
||||
else {
|
||||
s.runs += 1;
|
||||
if (outcome === 'passed') s.passes += 1; else s.fails += 1;
|
||||
if (outcome === 'passed' && entries.length > 1) s.retriedPasses += 1;
|
||||
}
|
||||
for (const e of entries) {
|
||||
s.totalCostUsd += e.cost_usd || 0;
|
||||
s.totalDurationMs += e.duration_ms || 0;
|
||||
@@ -115,21 +121,21 @@ if (import.meta.main) {
|
||||
|
||||
const files = collectEvalFiles(dir, sinceDays);
|
||||
const series = [...aggregate(files).values()]
|
||||
.sort((a, b) => b.retriedPasses - a.retriedPasses || (b.fails / b.runs) - (a.fails / a.runs));
|
||||
.sort((a, b) => b.retriedPasses - a.retriedPasses || (b.fails / Math.max(1, b.runs)) - (a.fails / Math.max(1, a.runs)));
|
||||
const ledger = readFreeLedger();
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ dir, runsScanned: files.length, tests: series, freeLedger: ledger }, null, 2));
|
||||
} else {
|
||||
console.log(`flake-rank: ${files.length} finalized run file(s) under ${dir}`);
|
||||
const flaky = series.filter((s) => s.retriedPasses > 0 || s.fails > 0);
|
||||
const flaky = series.filter((s) => s.retriedPasses > 0 || s.fails > 0 || s.manualAccepted > 0);
|
||||
if (flaky.length === 0) {
|
||||
console.log(' no retried passes and no failures recorded — clean series');
|
||||
} else {
|
||||
console.log(' retries fails/runs avg-dur test');
|
||||
console.log(' retries fails/runs manual avg-dur test');
|
||||
for (const s of flaky.slice(0, 30)) {
|
||||
console.log(` ${String(s.retriedPasses).padStart(7)} ${String(s.fails).padStart(5)}/${String(s.runs).padEnd(4)} `
|
||||
+ `${Math.round(s.totalDurationMs / s.totalAttempts / 1000).toString().padStart(5)}s ${s.name}`);
|
||||
console.log(` ${String(s.retriedPasses).padStart(7)} ${String(s.fails).padStart(5)}/${String(s.runs).padEnd(6)} `
|
||||
+ `${String(s.manualAccepted).padStart(6)} ${Math.round(s.totalDurationMs / s.totalAttempts / 1000).toString().padStart(5)}s ${s.name}`);
|
||||
}
|
||||
}
|
||||
if (ledger.length > 0) {
|
||||
|
||||
+11
-3
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { getProjectEvalDir, listEvalJsonFiles } from '../test/helpers/eval-store';
|
||||
import { evalEntryOutcome, getProjectEvalDir, listEvalJsonFiles } from '../test/helpers/eval-store';
|
||||
|
||||
const EVAL_DIR = getProjectEvalDir();
|
||||
|
||||
@@ -52,6 +52,7 @@ interface RunSummary {
|
||||
tier: string;
|
||||
version: string;
|
||||
passed: number;
|
||||
manual: Array<{ name: string; approvedBy: string; approvalUrl: string }>;
|
||||
total: number;
|
||||
cost: number;
|
||||
duration: number;
|
||||
@@ -65,13 +66,19 @@ for (const file of files) {
|
||||
if (filterBranch && data.branch !== filterBranch) continue;
|
||||
if (filterTier && data.tier !== filterTier) continue;
|
||||
const totalTurns = (data.tests || []).reduce((s: number, t: any) => s + (t.turns_used || 0), 0);
|
||||
const tests = Array.isArray(data.tests) ? data.tests : null;
|
||||
const final = tests ? [...new Map<string, any>(tests.map((t: any) => [t.name, t] as const)).values()] : [];
|
||||
const manual = final.filter((t: any) => evalEntryOutcome(t) === 'manual-review').map((t: any) => ({
|
||||
name: t.name, approvedBy: t.manual_review.approval.approved_by, approvalUrl: t.manual_review.approval.approval_url,
|
||||
}));
|
||||
runs.push({
|
||||
file,
|
||||
timestamp: data.timestamp || '',
|
||||
branch: data.branch || 'unknown',
|
||||
tier: data.tier || 'unknown',
|
||||
version: data.version || '?',
|
||||
passed: data.passed || 0,
|
||||
passed: tests ? tests.filter((t: any) => evalEntryOutcome(t) === 'passed').length : data.passed || 0,
|
||||
manual,
|
||||
total: data.total_tests || 0,
|
||||
cost: data.total_cost_usd || 0,
|
||||
duration: data.total_duration_ms || 0,
|
||||
@@ -110,7 +117,8 @@ for (const run of displayed) {
|
||||
const cost = `$${run.cost.toFixed(2)}`.padEnd(8);
|
||||
const turns = run.turns > 0 ? `${run.turns}t`.padEnd(7) : ''.padEnd(7);
|
||||
const dur = run.duration > 0 ? `${Math.round(run.duration / 1000)}s`.padEnd(10) : ''.padEnd(10);
|
||||
console.log(` ${date.padEnd(17)}${branch}${run.tier.padEnd(12)}${pass}${cost}${turns}${dur}v${run.version}`);
|
||||
const manual = run.manual.map(entry => `MANUAL/unscored ${entry.name}: approved by ${entry.approvedBy} (${entry.approvalUrl})`).join('; ');
|
||||
console.log(` ${date.padEnd(17)}${branch}${run.tier.padEnd(12)}${pass}${cost}${turns}${dur}v${run.version}${manual ? ` ${manual}` : ''}`);
|
||||
}
|
||||
|
||||
console.log('─'.repeat(105));
|
||||
|
||||
+16
-2
@@ -8,7 +8,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import type { EvalResult } from '../test/helpers/eval-store';
|
||||
import { getProjectEvalDir, listEvalJsonFiles } from '../test/helpers/eval-store';
|
||||
import { evalEntryOutcome, getProjectEvalDir, listEvalJsonFiles } from '../test/helpers/eval-store';
|
||||
|
||||
const EVAL_DIR = getProjectEvalDir();
|
||||
|
||||
@@ -77,11 +77,21 @@ const avgDetection = detectionRates.length > 0
|
||||
|
||||
// Flaky tests (passed in some runs, failed in others)
|
||||
const testResults = new Map<string, boolean[]>();
|
||||
const manualAccepted: Array<{ name: string; approvedBy: string; approvalUrl: string }> = [];
|
||||
for (const r of results) {
|
||||
const final = new Map(r.tests.map(t => [t.name, t]));
|
||||
const manuallyAcceptedNames = new Set([...final.values()].filter(t => evalEntryOutcome(t) === 'manual-review').map(t => t.name));
|
||||
for (const t of final.values()) {
|
||||
if (evalEntryOutcome(t) === 'manual-review') manualAccepted.push({ name: t.name,
|
||||
approvedBy: t.manual_review!.approval.approved_by, approvalUrl: t.manual_review!.approval.approval_url });
|
||||
}
|
||||
for (const t of r.tests) {
|
||||
if (manuallyAcceptedNames.has(t.name)) continue;
|
||||
const key = `${r.tier}:${t.name}`;
|
||||
const outcome = evalEntryOutcome(t);
|
||||
if (outcome === 'manual-review') continue;
|
||||
if (!testResults.has(key)) testResults.set(key, []);
|
||||
testResults.get(key)!.push(t.passed);
|
||||
testResults.get(key)!.push(outcome === 'passed');
|
||||
}
|
||||
}
|
||||
const flakyTests: string[] = [];
|
||||
@@ -119,6 +129,10 @@ console.log('Eval Summary');
|
||||
console.log('═'.repeat(70));
|
||||
console.log(` Total runs: ${results.length} (${e2eRuns.length} e2e, ${judgeRuns.length} llm-judge)`);
|
||||
console.log(` Total spend: $${totalCost.toFixed(2)}`);
|
||||
if (manualAccepted.length) {
|
||||
console.log(` Manual accepted: ${manualAccepted.length} unscored provider refusal(s)`);
|
||||
for (const entry of manualAccepted) console.log(` ${entry.name}: approved by ${entry.approvedBy} (${entry.approvalUrl})`);
|
||||
}
|
||||
console.log(` Avg cost/e2e: $${avgE2ECost.toFixed(2)}`);
|
||||
console.log(` Avg cost/judge: $${avgJudgeCost.toFixed(2)}`);
|
||||
if (avgE2EDuration > 0) {
|
||||
|
||||
+9
-13
@@ -11,7 +11,8 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { getProjectEvalDir } from '../test/helpers/eval-store';
|
||||
import { evalEntryOutcome, getProjectEvalDir } from '../test/helpers/eval-store';
|
||||
import type { EvalTestEntry } from '../test/helpers/eval-store';
|
||||
|
||||
const GSTACK_DEV_DIR = path.join(os.homedir(), '.gstack-dev');
|
||||
// Heartbeat + per-run progress logs are GLOBAL by design — session-runner.ts
|
||||
@@ -38,16 +39,7 @@ export interface HeartbeatData {
|
||||
}
|
||||
|
||||
export interface PartialData {
|
||||
tests: Array<{
|
||||
name: string;
|
||||
suite?: string;
|
||||
attempt?: number;
|
||||
passed: boolean;
|
||||
cost_usd: number;
|
||||
duration_ms: number;
|
||||
turns_used?: number;
|
||||
exit_reason?: string;
|
||||
}>;
|
||||
tests: Array<Partial<EvalTestEntry> & Pick<EvalTestEntry, 'name' | 'passed' | 'cost_usd' | 'duration_ms'>>;
|
||||
total_cost_usd: number;
|
||||
_partial?: boolean;
|
||||
}
|
||||
@@ -120,12 +112,14 @@ export function renderDashboard(heartbeat: HeartbeatData | null, partial: Partia
|
||||
// Completed tests from partial
|
||||
if (partial?.tests) {
|
||||
for (const t of partial.tests) {
|
||||
const icon = t.passed ? '\u2713' : '\u2717';
|
||||
const manual = evalEntryOutcome(t) === 'manual-review';
|
||||
const icon = manual ? 'M' : evalEntryOutcome(t) === 'passed' ? '\u2713' : '\u2717';
|
||||
const cost = `$${t.cost_usd.toFixed(2)}`;
|
||||
const dur = `${Math.round(t.duration_ms / 1000)}s`;
|
||||
const turns = t.turns_used !== undefined ? `${t.turns_used} turns` : '';
|
||||
const name = t.name.length > 30 ? t.name.slice(0, 27) + '...' : t.name.padEnd(30);
|
||||
lines.push(` ${icon} ${name} ${cost.padStart(6)} ${dur.padStart(5)} ${turns}`);
|
||||
const approval = manual ? ` MANUAL/unscored; approved by ${t.manual_review!.approval.approved_by} (${t.manual_review!.approval.approval_url})` : '';
|
||||
lines.push(` ${icon} ${name} ${cost.padStart(6)} ${dur.padStart(5)} ${turns}${approval}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +145,8 @@ export function renderDashboard(heartbeat: HeartbeatData | null, partial: Partia
|
||||
const totalCost = partial?.total_cost_usd || 0;
|
||||
const running = heartbeat?.status === 'running' ? 1 : 0;
|
||||
lines.push(` Completed: ${completedCount} Running: ${running} Cost: $${totalCost.toFixed(2)} Elapsed: ${formatDuration(elapsed)}`);
|
||||
const manualAccepted = partial?.tests?.filter(t => evalEntryOutcome(t) === 'manual-review').length ?? 0;
|
||||
if (manualAccepted) lines.push(` Manual accepted: ${manualAccepted} unscored provider refusal(s)`);
|
||||
|
||||
if (heartbeat?.runId) {
|
||||
const logPath = path.join(GSTACK_DEV_DIR, 'e2e-runs', heartbeat.runId, 'progress.log');
|
||||
|
||||
+65
-11
@@ -66,7 +66,8 @@ import {
|
||||
import { PAID_TEST_GLOBS, isPaidTestFile } from '../test/helpers/paid-test-set';
|
||||
import { PERIODIC_CI_EXCLUDE } from '../test/helpers/periodic-exclude-data';
|
||||
import { AUTOPLAN_CHAIN_BUDGET, FILE_RETRY_BUDGETS, STRICT_RETRY_CASE_BUDGETS } from '../test/helpers/eval-budgets';
|
||||
import { getProjectEvalDir, getClaudeCliVersion, isFinalizedEvalResultFile } from '../test/helpers/eval-store';
|
||||
import { getProjectEvalDir, getClaudeCliVersion, isFinalizedEvalResultFile, evalEntryOutcome } from '../test/helpers/eval-store';
|
||||
import { manualReviewProblem } from '../test/helpers/cookie-workflow-manual-review';
|
||||
import { preflightAnthropicApi } from '../test/helpers/anthropic-preflight';
|
||||
import { OVERLAY_MIN_FILE_WALL_MS } from '../test/helpers/overlay-case-policy';
|
||||
import { PR_PROFILE_CASE_IDS, PR_PROFILE_FILES, packageChangeOnlyVersion, selectPrProfile, type PrProfileSelection } from './test-pr-profile';
|
||||
@@ -1314,19 +1315,20 @@ export function formatProfileCoverage(manifest: PaidRunManifest): string[] {
|
||||
|
||||
/** Final outcomes use each case's last attempt; the attempt total stays visible. */
|
||||
export function collectorOutcomeCounts(results: Array<{ tests?: Array<{
|
||||
name: string; suite?: string; passed: boolean; execution?: string;
|
||||
}> }>): { executed: number; reused: number; passed: number; failed: number; attempts: number } {
|
||||
const counts = { executed: 0, reused: 0, passed: 0, failed: 0, attempts: 0 };
|
||||
name: string; suite?: string; passed: boolean; execution?: string; manual_review?: unknown;
|
||||
}> }>): { executed: number; reused: number; passed: number; failed: number; manual_accepted: number; attempts: number } {
|
||||
const counts = { executed: 0, reused: 0, passed: 0, failed: 0, manual_accepted: 0, attempts: 0 };
|
||||
for (const result of results) {
|
||||
const cases = new Map<string, NonNullable<typeof result.tests>[number]>();
|
||||
for (const entry of result.tests ?? []) {
|
||||
if (typeof entry.name !== 'string' || typeof entry.passed !== 'boolean') continue;
|
||||
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || typeof entry.passed !== 'boolean') continue;
|
||||
counts.attempts++;
|
||||
cases.set(`${entry.suite ?? ''}\0${entry.name}`, entry);
|
||||
}
|
||||
for (const entry of cases.values()) {
|
||||
counts[entry.execution === 'reused' ? 'reused' : 'executed']++;
|
||||
counts[entry.passed ? 'passed' : 'failed']++;
|
||||
const outcome = evalEntryOutcome(entry);
|
||||
counts[outcome === 'manual-review' ? 'manual_accepted' : outcome]++;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
@@ -1479,6 +1481,8 @@ async function main(): Promise<number> {
|
||||
// ── Report mode: reconcile slice artifacts against the manifest. Fail-closed:
|
||||
// a slice whose artifact never landed is a FAILURE, not an absence.
|
||||
if (options.reportDir) {
|
||||
const summaryPath = path.join(options.reportDir, 'collector-outcomes.json');
|
||||
fs.rmSync(summaryPath, { force: true });
|
||||
const manifest = parseRunManifest(fs.readFileSync(path.join(options.reportDir, 'manifest.json'), 'utf-8'));
|
||||
const results: SliceResult[] = fs.readdirSync(options.reportDir)
|
||||
.filter((name) => /^slice-\d+\.json$/.test(name))
|
||||
@@ -1498,16 +1502,54 @@ async function main(): Promise<number> {
|
||||
// Source: the finalized eval-store JSONs inside the slice artifacts.
|
||||
const flaky: Array<{ name: string; attempts: number; file: string }> = [];
|
||||
const collectors: Parameters<typeof collectorOutcomeCounts>[0] = [];
|
||||
const files: Array<{ file: string; tier: string; shard: string | number; cost: number;
|
||||
flaky: number; total: number; executed: number; reused: number; passed: number;
|
||||
failed: number; manual_accepted: number; attempts: number }> = [];
|
||||
const manualProblems: string[] = [];
|
||||
const manualClaims = new Map<string, string>();
|
||||
for (const name of fs.readdirSync(options.reportDir, { recursive: true }) as string[]) {
|
||||
if (!isFinalizedEvalResultFile(name)) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(path.join(options.reportDir, name), 'utf-8'));
|
||||
if (Array.isArray(parsed.tests)) collectors.push(parsed);
|
||||
if (!Array.isArray(parsed.tests)) {
|
||||
if (Object.hasOwn(parsed, 'tests') || parsed.total_tests !== undefined || parsed.manual_review !== undefined) {
|
||||
manualProblems.push(`${name}: malformed collector tests[]`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const seen = new Map<string, number>();
|
||||
for (const [index, entry] of parsed.tests.entries()) {
|
||||
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string' || !entry.name
|
||||
|| typeof entry.passed !== 'boolean') {
|
||||
manualProblems.push(`${name}: attempt ${index + 1}: malformed collector entry (name/passed required)`);
|
||||
continue;
|
||||
}
|
||||
const key = `${entry.suite ?? ''}\0${entry.name}`;
|
||||
const occurrence = (seen.get(key) ?? 0) + 1;
|
||||
seen.set(key, occurrence);
|
||||
if (Object.hasOwn(entry, 'manual_review') && occurrence !== 1) {
|
||||
manualProblems.push(`${name}: attempt ${index + 1}: manual review is only valid on the first case attempt`);
|
||||
}
|
||||
if (Object.hasOwn(entry, 'manual_review')) {
|
||||
const previous = manualClaims.get(key);
|
||||
if (previous && previous !== name) manualProblems.push(`${name}: duplicate manual-review claim for ${entry.name} (also in ${previous})`);
|
||||
else manualClaims.set(key, name);
|
||||
}
|
||||
const problem = manualReviewProblem(entry, ROOT);
|
||||
if (problem) manualProblems.push(`${name}: attempt ${index + 1}: ${problem}`);
|
||||
}
|
||||
collectors.push(parsed);
|
||||
const counts = collectorOutcomeCounts([parsed]);
|
||||
files.push({ file: name, tier: parsed.tier ?? 'unknown', shard: parsed.shard ?? '-',
|
||||
cost: parsed.total_cost_usd ?? 0, flaky: parsed.flaky_retries?.length ?? 0,
|
||||
total: counts.passed + counts.failed + counts.manual_accepted, ...counts });
|
||||
for (const f of parsed.flaky_retries ?? []) flaky.push({ ...f, file: name });
|
||||
} catch { /* non-eval JSON — not this report's business */ }
|
||||
} catch (error) {
|
||||
manualProblems.push(`${name}: malformed collector JSON (${error instanceof Error ? error.message : String(error)})`);
|
||||
}
|
||||
}
|
||||
const evidence = collectorOutcomeCounts(collectors);
|
||||
console.log(`[test:paid] collector final outcomes: ${evidence.executed} executed, ${evidence.reused} reused; ${evidence.passed} passed, ${evidence.failed} failed (${evidence.attempts} attempt records from ${collectors.length} collectors)`);
|
||||
console.log(`[test:paid] collector final outcomes: ${evidence.executed} executed, ${evidence.reused} reused; ${evidence.passed} passed, ${evidence.failed} failed, ${evidence.manual_accepted} manual accepted (unscored; no score-cache credit) (${evidence.attempts} attempt records from ${collectors.length} collectors)`);
|
||||
if (flaky.length > 0) {
|
||||
console.log(`[test:paid] report: ⚠ ${flaky.length} cases with multiple attempts this run:`);
|
||||
for (const f of flaky) console.log(` ⚠ ${f.name} (x${f.attempts}) — ${f.file}`);
|
||||
@@ -1525,12 +1567,24 @@ async function main(): Promise<number> {
|
||||
console.log(` ⚠ ${outcome.files.join(' ')} (${outcome.executedTests} skipped — external service missing or tier mismatch)`);
|
||||
}
|
||||
}
|
||||
if (!verdict.ok) {
|
||||
if (manualProblems.length) verdict.problems.push(...manualProblems);
|
||||
if (evidence.failed > 0) verdict.problems.push(`${evidence.failed} unapproved final collector failure(s)`);
|
||||
if (files.reduce((sum, file) => sum + file.total, 0) !== evidence.passed + evidence.failed + evidence.manual_accepted
|
||||
|| files.reduce((sum, file) => sum + file.executed + file.reused, 0) !== evidence.executed + evidence.reused) {
|
||||
verdict.problems.push('Collector summary totals are inconsistent');
|
||||
}
|
||||
if (!manualProblems.length) fs.writeFileSync(summaryPath, JSON.stringify({ version: 1, files, totals: {
|
||||
...evidence, total: evidence.passed + evidence.failed + evidence.manual_accepted,
|
||||
flaky: files.reduce((sum, file) => sum + file.flaky, 0),
|
||||
} }, null, 2) + '\n');
|
||||
if (verdict.problems.length) {
|
||||
console.error(`[test:paid] report: ${verdict.problems.length} problem(s):`);
|
||||
for (const problem of verdict.problems) console.error(` ✗ ${problem}`);
|
||||
return 1;
|
||||
}
|
||||
console.log('[test:paid] report: every planned shard accounted and passed');
|
||||
console.log(evidence.manual_accepted
|
||||
? `[test:paid] report: every planned shard accounted; ${evidence.manual_accepted} manual acceptance(s), no automated-score credit`
|
||||
: '[test:paid] report: every planned shard accounted and passed');
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -152,26 +152,9 @@ Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXI
|
||||
|
||||
# Setup Browser Cookies
|
||||
|
||||
Import logged-in sessions from your real Chromium browser into the headless browse session.
|
||||
## 1. Choose the browser
|
||||
|
||||
## CDP mode check
|
||||
|
||||
First, check if browse is already connected to the user's real browser:
|
||||
```bash
|
||||
$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false"
|
||||
```
|
||||
If `CDP_MODE=true`: tell the user "Not needed — you're connected to your real browser via CDP. Your cookies and sessions are already available." and stop. No cookie import needed.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Find the browse binary
|
||||
2. Run `cookie-import-browser` to detect installed browsers and open the picker UI
|
||||
3. User selects which cookie domains to import in their browser
|
||||
4. Cookies are decrypted and loaded into the Playwright session
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Find the browse binary
|
||||
Use this checkout as the gstack root if it contains `BROWSER.md` and `browse/SKILL.md`; otherwise use the installed root containing `bin/gstack-skill-start`, never a generated host stub. Read that root's `browse/SKILL.md` **BROWSER SETUP** section and run its probe first. On `READY`, stop importing: use Aside's sessions or ask the user to sign in there. Otherwise follow the probe's fallback handling, then continue below.
|
||||
|
||||
## SETUP (run this check BEFORE any browse command)
|
||||
|
||||
@@ -215,45 +198,38 @@ If `NEEDS_SETUP`:
|
||||
fi
|
||||
```
|
||||
|
||||
### 2. Open the cookie picker
|
||||
```bash
|
||||
$B status
|
||||
```
|
||||
If status says `Mode: cdp`, stop: the real browser already has sessions.
|
||||
|
||||
## 2. Confirm options before import
|
||||
|
||||
Open the known target and keep its tab unchanged. Both options default off and require explicit request:
|
||||
|
||||
- **`--verify-auth` / picker checkbox:** reloads the target. Have the user privately configure daemon `GSTACK_COOKIE_AUTH_SELECTOR` and `GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY` **before startup**. Never invent values or assume CLI env reconfigures an existing daemon. Missing config rejects before mutation. Require a successful same-origin response and exactly one visible element whose whitespace-normalized text exactly matches the expected identity.
|
||||
- **`--clear-storage`:** for suspected stale storage, obtain explicit approval. Chromium only: clears captured-origin localStorage (shared across same-origin context tabs) and target-tab sessionStorage. Other origins, other tabs' sessionStorage, IndexedDB, and service workers stay intact. It uses an isolated world/native deadline; other engines reject reset, not imports/auth checks. Never auto-approve or claim rollback after partial failure.
|
||||
|
||||
## 3. Select source and scope
|
||||
|
||||
```bash
|
||||
$B cookie-import-browser
|
||||
```
|
||||
|
||||
This auto-detects installed Chromium browsers and opens
|
||||
an interactive picker UI in your default browser where you can:
|
||||
- Switch between installed browsers
|
||||
- Search domains
|
||||
- Click "+" to import a domain's cookies
|
||||
- Click trash to remove imported cookies
|
||||
Ask the user to choose browser, account/profile, and domains, then say when done. Never guess accounts or treat default Comet as consent. Unreadable profiles are unknown, not empty. Rerun for an expired five-minute one-use link.
|
||||
|
||||
Tell the user: **"Cookie picker opened — select the domains you want to import in your browser, then tell me when you're done."**
|
||||
Direct import: pass the chosen browser and `--domain` after navigating to a matching target. `--profile` takes a directory, not a display name; omit only for an unambiguous relevant profile, otherwise use the picker. `--all` requires consent for all non-expired profile cookies; it cannot accompany `--domain` or `--clear-storage`.
|
||||
|
||||
### 3. Direct import (alternative)
|
||||
Read that same root's `BROWSER.md`, **Choosing a source and checking sign-in**, for examples, profile labels, supported sources and platform setup.
|
||||
|
||||
If the user specifies a domain directly (e.g., `/setup-browser-cookies github.com`), skip the UI:
|
||||
## 4. Report honestly
|
||||
|
||||
```bash
|
||||
$B cookie-import-browser comet --domain github.com
|
||||
```
|
||||
Report receipt/picker counts, partial/zero/error and reset outcomes, not raw `$B cookies`. Imports affect the context, not one tab. **Not checked** means no requested check; **not verified** means it failed; **verified** requires positive target evidence. Zero imports, counts, or HTTP 200 never prove login.
|
||||
|
||||
Replace `comet` with the appropriate browser if specified.
|
||||
Never request/publish cookie values, passwords, identity/profile text, session details, or raw errors in public logs.
|
||||
|
||||
### 4. Verify
|
||||
## Platform boundaries
|
||||
|
||||
After the user confirms they're done:
|
||||
Dia is macOS-only. Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts.
|
||||
|
||||
```bash
|
||||
$B cookies
|
||||
```
|
||||
|
||||
Show the user a summary of imported cookies (domain counts).
|
||||
|
||||
## Notes
|
||||
|
||||
- On macOS, the first import per browser may trigger a Keychain dialog — click "Allow" / "Always Allow"
|
||||
- On Linux, `v11` cookies may require `secret-tool`/libsecret access; `v10` cookies use Chromium's standard fallback key
|
||||
- Cookie picker is served on the same port as the browse server (no extra process)
|
||||
- Only domain names and cookie counts are shown in the UI — no cookie values are exposed
|
||||
- The browse session persists cookies between commands, so imported cookies work immediately
|
||||
Windows supports DPAPI-compatible cookies, not all App-Bound Encryption; native extraction stays disabled pending qualification. Closing Chrome cannot bypass Chrome 136+ default-directory protection, including numbered profiles. No TCP fallback or real-profile copies. Offer headed manual sign-in only with a display available.
|
||||
@@ -21,68 +21,44 @@ allowed-tools:
|
||||
|
||||
# Setup Browser Cookies
|
||||
|
||||
Import logged-in sessions from your real Chromium browser into the headless browse session.
|
||||
## 1. Choose the browser
|
||||
|
||||
## CDP mode check
|
||||
|
||||
First, check if browse is already connected to the user's real browser:
|
||||
```bash
|
||||
$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false"
|
||||
```
|
||||
If `CDP_MODE=true`: tell the user "Not needed — you're connected to your real browser via CDP. Your cookies and sessions are already available." and stop. No cookie import needed.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Find the browse binary
|
||||
2. Run `cookie-import-browser` to detect installed browsers and open the picker UI
|
||||
3. User selects which cookie domains to import in their browser
|
||||
4. Cookies are decrypted and loaded into the Playwright session
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Find the browse binary
|
||||
Use this checkout as the gstack root if it contains `BROWSER.md` and `browse/SKILL.md`; otherwise use the installed root containing `bin/gstack-skill-start`, never a generated host stub. Read that root's `browse/SKILL.md` **BROWSER SETUP** section and run its probe first. On `READY`, stop importing: use Aside's sessions or ask the user to sign in there. Otherwise follow the probe's fallback handling, then continue below.
|
||||
|
||||
{{BROWSE_SETUP}}
|
||||
|
||||
### 2. Open the cookie picker
|
||||
```bash
|
||||
$B status
|
||||
```
|
||||
If status says `Mode: cdp`, stop: the real browser already has sessions.
|
||||
|
||||
## 2. Confirm options before import
|
||||
|
||||
Open the known target and keep its tab unchanged. Both options default off and require explicit request:
|
||||
|
||||
- **`--verify-auth` / picker checkbox:** reloads the target. Have the user privately configure daemon `GSTACK_COOKIE_AUTH_SELECTOR` and `GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY` **before startup**. Never invent values or assume CLI env reconfigures an existing daemon. Missing config rejects before mutation. Require a successful same-origin response and exactly one visible element whose whitespace-normalized text exactly matches the expected identity.
|
||||
- **`--clear-storage`:** for suspected stale storage, obtain explicit approval. Chromium only: clears captured-origin localStorage (shared across same-origin context tabs) and target-tab sessionStorage. Other origins, other tabs' sessionStorage, IndexedDB, and service workers stay intact. It uses an isolated world/native deadline; other engines reject reset, not imports/auth checks. Never auto-approve or claim rollback after partial failure.
|
||||
|
||||
## 3. Select source and scope
|
||||
|
||||
```bash
|
||||
$B cookie-import-browser
|
||||
```
|
||||
|
||||
This auto-detects installed Chromium browsers and opens
|
||||
an interactive picker UI in your default browser where you can:
|
||||
- Switch between installed browsers
|
||||
- Search domains
|
||||
- Click "+" to import a domain's cookies
|
||||
- Click trash to remove imported cookies
|
||||
Ask the user to choose browser, account/profile, and domains, then say when done. Never guess accounts or treat default Comet as consent. Unreadable profiles are unknown, not empty. Rerun for an expired five-minute one-use link.
|
||||
|
||||
Tell the user: **"Cookie picker opened — select the domains you want to import in your browser, then tell me when you're done."**
|
||||
Direct import: pass the chosen browser and `--domain` after navigating to a matching target. `--profile` takes a directory, not a display name; omit only for an unambiguous relevant profile, otherwise use the picker. `--all` requires consent for all non-expired profile cookies; it cannot accompany `--domain` or `--clear-storage`.
|
||||
|
||||
### 3. Direct import (alternative)
|
||||
Read that same root's `BROWSER.md`, **Choosing a source and checking sign-in**, for examples, profile labels, supported sources and platform setup.
|
||||
|
||||
If the user specifies a domain directly (e.g., `/setup-browser-cookies github.com`), skip the UI:
|
||||
## 4. Report honestly
|
||||
|
||||
```bash
|
||||
$B cookie-import-browser comet --domain github.com
|
||||
```
|
||||
Report receipt/picker counts, partial/zero/error and reset outcomes, not raw `$B cookies`. Imports affect the context, not one tab. **Not checked** means no requested check; **not verified** means it failed; **verified** requires positive target evidence. Zero imports, counts, or HTTP 200 never prove login.
|
||||
|
||||
Replace `comet` with the appropriate browser if specified.
|
||||
Never request/publish cookie values, passwords, identity/profile text, session details, or raw errors in public logs.
|
||||
|
||||
### 4. Verify
|
||||
## Platform boundaries
|
||||
|
||||
After the user confirms they're done:
|
||||
Dia is macOS-only. Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts.
|
||||
|
||||
```bash
|
||||
$B cookies
|
||||
```
|
||||
|
||||
Show the user a summary of imported cookies (domain counts).
|
||||
|
||||
## Notes
|
||||
|
||||
- On macOS, the first import per browser may trigger a Keychain dialog — click "Allow" / "Always Allow"
|
||||
- On Linux, `v11` cookies may require `secret-tool`/libsecret access; `v10` cookies use Chromium's standard fallback key
|
||||
- Cookie picker is served on the same port as the browse server (no extra process)
|
||||
- Only domain names and cookie counts are shown in the UI — no cookie values are exposed
|
||||
- The browse session persists cookies between commands, so imported cookies work immediately
|
||||
Windows supports DPAPI-compatible cookies, not all App-Bound Encryption; native extraction stays disabled pending qualification. Closing Chrome cannot bypass Chrome 136+ default-directory protection, including numbered profiles. No TCP fallback or real-profile copies. Offer headed manual sign-in only with a display available.
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(import.meta.dir, '..');
|
||||
const workflow = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/free-tests.yml'), 'utf8')) as any;
|
||||
const steps = workflow.jobs['free-suite'].steps;
|
||||
const index = steps.findIndex((step: any) => step.name === 'Configure the bundled Chromium sandbox helper');
|
||||
const command = steps[index]?.run;
|
||||
const fixtures: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function run(options: { outside?: boolean; link?: boolean; mode?: string; corrupt?: boolean } = {}) {
|
||||
const fixture = mkdtempSync(path.join(tmpdir(), 'chromium-ci-'));
|
||||
fixtures.push(fixture);
|
||||
const bin = path.join(fixture, 'bin');
|
||||
const home = path.join(fixture, 'home');
|
||||
const directory = options.outside ? path.join(fixture, 'outside') : path.join(home, '.cache/ms-playwright/chromium-1234/chrome-linux64');
|
||||
mkdirSync(bin, { recursive: true });
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const executable = path.join(directory, 'chrome');
|
||||
const helper = path.join(directory, 'chrome_sandbox');
|
||||
const installed = path.join(directory, 'chrome-sandbox');
|
||||
const receipt = path.join(fixture, 'install.json');
|
||||
writeFileSync(executable, 'synthetic browser');
|
||||
if (options.link) symlinkSync(executable, helper);
|
||||
else writeFileSync(helper, 'synthetic matching helper');
|
||||
writeFileSync(path.join(bin, 'bun'), '#!/bin/sh\nprintf "%s\\n" "$FIXTURE_CHROME"\n', { mode: 0o755 });
|
||||
writeFileSync(path.join(bin, 'stat'), '#!/bin/sh\nprintf "%s\\n" "$FIXTURE_STAT"\n', { mode: 0o755 });
|
||||
writeFileSync(path.join(bin, 'sudo'), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(2);
|
||||
fs.writeFileSync(process.env.FIXTURE_RECEIPT, JSON.stringify(args));
|
||||
if (JSON.stringify(args.slice(0, 8)) !== JSON.stringify(['install', '-T', '-o', 'root', '-g', 'root', '-m', '4755']) || args.length !== 10) process.exit(2);
|
||||
fs.copyFileSync(args[8], args[9]);
|
||||
if (process.env.FIXTURE_CORRUPT === '1') fs.appendFileSync(args[9], 'changed');
|
||||
`, { mode: 0o755 });
|
||||
const result = spawnSync('/bin/bash', ['-c', command], {
|
||||
cwd: root, encoding: 'utf8', timeout: 10_000,
|
||||
env: { ...process.env, HOME: home, PATH: bin + path.delimiter + process.env.PATH,
|
||||
FIXTURE_CHROME: executable, FIXTURE_STAT: options.mode ?? '0:4755',
|
||||
FIXTURE_RECEIPT: receipt, FIXTURE_CORRUPT: options.corrupt ? '1' : '0' },
|
||||
});
|
||||
return { result, helper, installed, calls: existsSync(receipt) ? JSON.parse(readFileSync(receipt, 'utf8')) : null };
|
||||
}
|
||||
|
||||
test('the actual CI sandbox setup uses the resolved bundled helper before tests without disabling protections', () => {
|
||||
expect(typeof command).toBe('string');
|
||||
expect(index).toBeGreaterThan(steps.findIndex((step: any) => step.name === 'Install Playwright Chromium'));
|
||||
expect(index).toBeLessThan(steps.findIndex((step: any) => step.name === 'Run free suite'));
|
||||
expect(command).not.toMatch(/--no-sandbox|chromiumSandbox:\s*false|sysctl|apparmor_restrict/);
|
||||
const { result, helper, installed, calls } = run();
|
||||
expect(result.status).toBe(0);
|
||||
expect(calls).toEqual(['install', '-T', '-o', 'root', '-g', 'root', '-m', '4755', helper, installed]);
|
||||
expect(readFileSync(installed)).toEqual(readFileSync(helper));
|
||||
});
|
||||
|
||||
test('unexpected paths and linked helpers are refused before privileged installation', () => {
|
||||
for (const options of [{ outside: true }, { link: true }]) {
|
||||
const { result, calls } = run(options);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(calls).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('the actual CI setup requires root ownership, setuid mode, and unchanged helper bytes', () => {
|
||||
for (const options of [{ mode: '1000:4755' }, { mode: '0:755' }, { corrupt: true }]) {
|
||||
const { result, calls } = run(options);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(calls).not.toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
@@ -102,3 +102,66 @@ test.skipIf(!Bun.which('jq'))('the actual comment separates reused evidence, ret
|
||||
expect(text).toContain('receive no PR-pass credit');
|
||||
expect(comment).not.toContain('diff-selected gate census');
|
||||
});
|
||||
|
||||
test.skipIf(!Bun.which('jq') || !Bun.which('bash'))('comment consumes verified final counts without running repository code and fails closed without them', () => {
|
||||
const job = paid.jobs['slices-comment'];
|
||||
expect(job.permissions).toMatchObject({ 'pull-requests': 'write' });
|
||||
expect(JSON.stringify(job.steps)).not.toMatch(/actions\/checkout|setup-bun|bun run|npm |node /);
|
||||
const upload = paid.jobs['slices-report'].steps.find((step: any) => step.with?.name === 'report-verdict');
|
||||
expect(upload.with.path.trim().split('\n')).toEqual(['/tmp/report.txt', '/tmp/paid-report/collector-outcomes.json']);
|
||||
expect(job.steps.find((step: any) => step.with?.name === 'report-verdict').with.path).toBe('/tmp/verdict');
|
||||
const root = mkdtempSync(join(tmpdir(), 'ci-comment-'));
|
||||
const paidDir = join(root, 'paid-report');
|
||||
const verdictDir = join(root, 'verdict');
|
||||
const binDir = join(root, 'bin');
|
||||
mkdirSync(paidDir); mkdirSync(verdictDir); mkdirSync(binDir);
|
||||
writeFileSync(join(binDir, 'gh'), '#!/bin/sh\ncase "$*" in *--jq*) exit 0;; esac\nfor arg do case "$arg" in body=*) printf "%s\\n" "${arg#body=}";; esac; done\n');
|
||||
chmodSync(join(binDir, 'gh'), 0o755);
|
||||
writeFileSync(join(binDir, 'bc'), '#!/bin/sh\nread -r expression\n[ "$expression" = "0 + 0" ] && printf "0\\n"\n');
|
||||
chmodSync(join(binDir, 'bc'), 0o755);
|
||||
writeFileSync(join(paidDir, 'manifest.json'), JSON.stringify({ profile: 'pr', selection: { e2e: [], judges: [] } }));
|
||||
writeFileSync(join(paidDir, 'judge.json'), JSON.stringify({ total_tests: 2, tier: 'llm-judge', shard: 1,
|
||||
tests: [{ name: 'manual', passed: false, manual_review: { unverified: true } },
|
||||
{ name: 'reused', passed: true, execution: 'reused' }], flaky_retries: [] }));
|
||||
const summary = { version: 1, files: [{ file: 'judge.json', tier: 'llm-judge', shard: 1, cost: 0,
|
||||
total: 2, passed: 1, failed: 0, manual_accepted: 1, executed: 1, reused: 1, attempts: 2, flaky: 0 }],
|
||||
totals: { total: 2, passed: 1, failed: 0, manual_accepted: 1, executed: 1, reused: 1, attempts: 2, flaky: 0 } };
|
||||
mkdirSync(join(verdictDir, 'paid-report'));
|
||||
const summaryPath = join(verdictDir, 'paid-report/collector-outcomes.json');
|
||||
const script = (job.steps.find((step: any) => step.name === 'Post PR comment').run as string)
|
||||
.replaceAll('/tmp/paid-report', paidDir).replaceAll('/tmp/verdict', verdictDir)
|
||||
.replaceAll('${{ github.repository }}', 'garrytan/gstack')
|
||||
.replaceAll('${{ github.event.pull_request.number }}', '123');
|
||||
const check = spawnSync('bash', ['-n', '-c', script], { cwd: root, encoding: 'utf8', timeout: 5000 });
|
||||
expect(check.status, check.stderr).toBe(0);
|
||||
const run = () => spawnSync('bash', ['-e', '-c', script], { cwd: root,
|
||||
env: { ...process.env, PATH: `${binDir}:${process.env.PATH}`, RECONCILE_EXIT: '0' },
|
||||
encoding: 'utf8', timeout: 5000 });
|
||||
try {
|
||||
writeFileSync(summaryPath, JSON.stringify(summary));
|
||||
const verified = run();
|
||||
expect(verified.status, verified.stderr).toBe(0);
|
||||
expect(verified.stdout).toContain('⚠ MANUAL ACCEPTED (unscored)');
|
||||
expect(verified.stdout).toContain('1 automated passed / 2 final results');
|
||||
expect(verified.stdout).toContain('0 failed, 1 manual accepted');
|
||||
|
||||
const unrelatedFailure = { ...summary, files: [{ ...summary.files[0], total: 3, failed: 1,
|
||||
executed: 2, attempts: 3 }], totals: { ...summary.totals, total: 3, failed: 1,
|
||||
executed: 2, attempts: 3 } };
|
||||
writeFileSync(summaryPath, JSON.stringify(unrelatedFailure));
|
||||
const red = run();
|
||||
expect(red.status, red.stderr).toBe(0);
|
||||
expect(red.stdout).toContain('❌ FAIL');
|
||||
expect(red.stdout).toContain('1 failed, 1 manual accepted');
|
||||
|
||||
writeFileSync(summaryPath, JSON.stringify({ ...summary, totals: { ...summary.totals, manual_accepted: 2 } }));
|
||||
const tampered = run();
|
||||
expect(tampered.status, tampered.stderr).toBe(0);
|
||||
expect(tampered.stdout).toContain('manual acceptance unavailable/unverified');
|
||||
expect(tampered.stdout).not.toContain('⚠ MANUAL ACCEPTED (unscored)');
|
||||
rmSync(summaryPath);
|
||||
const absent = run();
|
||||
expect(absent.status, absent.stderr).toBe(0);
|
||||
expect(absent.stdout).toContain('manual acceptance unavailable/unverified');
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { buildRunManifest, collectPaidTestFiles, type PaidRunManifest, type SliceResult } from '../scripts/test-paid-shards';
|
||||
import { STRICT_RETRY_CASE_BUDGETS } from './helpers/eval-budgets';
|
||||
import { manualReviewFixture } from './helpers/manual-judge-review-fixture';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
type Step = { uses?: string; run?: string; if?: string; with?: Record<string, unknown> };
|
||||
@@ -23,6 +24,48 @@ const workflows = ['evals.yml', 'evals-periodic.yml'].map(name => ({
|
||||
}));
|
||||
|
||||
describe('paid CI coordination stays off the eval image', () => {
|
||||
test('the actual planner and reporter load from a checkout without installed packages', () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'paid-offline-'));
|
||||
const listed = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', timeout: 5_000 });
|
||||
expect(listed.status, listed.stderr).toBe(0);
|
||||
try {
|
||||
for (const relative of listed.stdout.split('\0').filter(Boolean)) {
|
||||
const source = path.join(ROOT, relative);
|
||||
const destination = path.join(directory, relative);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
expect(fs.realpathSync(path.dirname(destination)).startsWith(fs.realpathSync(directory) + path.sep)
|
||||
|| fs.realpathSync(path.dirname(destination)) === fs.realpathSync(directory)).toBe(true);
|
||||
if (fs.lstatSync(source).isSymbolicLink()) fs.symlinkSync(fs.readlinkSync(source), destination);
|
||||
else fs.copyFileSync(source, destination);
|
||||
}
|
||||
const env = { ...process.env, ANTHROPIC_API_KEY: undefined, EVALS: undefined, EVALS_ALL: undefined,
|
||||
EVALS_PROFILE: 'pr', EVALS_TIER: 'gate' };
|
||||
for (const args of [['init', '-b', 'main'], ['add', '-A'], ['commit', '-m', 'Seed offline planner fixture'], ['checkout', '-b', 'fixture-head']]) {
|
||||
const git = spawnSync('git', args, { cwd: directory, env, encoding: 'utf8', timeout: 15_000 });
|
||||
expect(git.status, git.stderr).toBe(0);
|
||||
}
|
||||
fs.appendFileSync(path.join(directory, '.github/workflows/evals.yml'), '\n');
|
||||
const report = path.join(directory, 'report');
|
||||
const plan = spawnSync(process.execPath, ['--no-install', 'run', 'scripts/test-paid-shards.ts', '--tier', 'gate',
|
||||
'--emit-plan', path.join(report, 'manifest.json'), '--slices', '6'], { cwd: directory, env, encoding: 'utf8', timeout: 15_000 });
|
||||
expect(plan.status, plan.stdout + plan.stderr).toBe(0);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(report, 'manifest.json'), 'utf8'));
|
||||
expect(manifest.sliceCount).toBe(6);
|
||||
expect(manifest.entries.some((entry: any) => entry.status === 'planned')).toBe(true);
|
||||
const reconcile = spawnSync(process.execPath, ['--no-install', 'run', 'scripts/test-paid-shards.ts', '--tier', 'gate',
|
||||
'--report', report], { cwd: directory, env, encoding: 'utf8', timeout: 15_000 });
|
||||
expect(reconcile.status, reconcile.stdout + reconcile.stderr).toBe(1);
|
||||
expect(reconcile.stdout).toContain('report: 0/6 slices');
|
||||
expect(reconcile.stderr.match(/slice \d\/6 reported NO result/g)).toHaveLength(6);
|
||||
expect(reconcile.stderr).not.toContain('Cannot find module');
|
||||
expect(fs.existsSync(path.join(directory, 'node_modules'))).toBe(false);
|
||||
const provider = spawnSync(process.execPath, ['--no-install', '-e', 'import "./test/helpers/llm-judge.ts"'],
|
||||
{ cwd: directory, env, encoding: 'utf8', timeout: 15_000 });
|
||||
expect(provider.status).toBe(1);
|
||||
expect(provider.stderr).toContain("Cannot find module '@anthropic-ai/sdk'");
|
||||
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
|
||||
for (const { name, jobs } of workflows) {
|
||||
test(`${name}: planning is independent of image startup and has no dependency install`, () => {
|
||||
const planner = jobs['plan-slices'];
|
||||
@@ -88,8 +131,18 @@ describe('dependency-free CI planner and report execution', () => {
|
||||
|
||||
beforeAll(() => {
|
||||
fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-paid-coordination-'));
|
||||
fs.cpSync(path.join(ROOT, 'scripts'), path.join(fixture, 'scripts'), { recursive: true });
|
||||
fs.cpSync(path.join(ROOT, 'test/helpers'), path.join(fixture, 'test/helpers'), { recursive: true });
|
||||
const sourceOnly = { recursive: true, filter: (file: string) => path.basename(file) !== 'node_modules' };
|
||||
fs.cpSync(path.join(ROOT, 'scripts'), path.join(fixture, 'scripts'), sourceOnly);
|
||||
fs.cpSync(path.join(ROOT, 'test/helpers'), path.join(fixture, 'test/helpers'), sourceOnly);
|
||||
expect(fs.readFileSync(path.join(fixture, 'test/helpers/llm-judge.ts'), 'utf8'))
|
||||
.toBe(fs.readFileSync(path.join(ROOT, 'test/helpers/llm-judge.ts'), 'utf8'));
|
||||
fs.cpSync(path.join(ROOT, 'lib'), path.join(fixture, 'lib'), sourceOnly);
|
||||
expect(fs.existsSync(path.join(fixture, 'lib/diagram-render/node_modules'))).toBe(false);
|
||||
fs.mkdirSync(path.join(fixture, '.github'), { recursive: true });
|
||||
for (const file of ['.github/cookie-workflow-manual-review.json', 'setup-browser-cookies/SKILL.md', 'BROWSER.md']) {
|
||||
fs.mkdirSync(path.dirname(path.join(fixture, file)), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, file), path.join(fixture, file));
|
||||
}
|
||||
for (const file of collectPaidTestFiles()) {
|
||||
fs.copyFileSync(path.join(ROOT, file), path.join(fixture, file));
|
||||
}
|
||||
@@ -183,7 +236,7 @@ describe('dependency-free CI planner and report execution', () => {
|
||||
const red = run(['--report', reportDir], tier);
|
||||
expect(red.status).toBe(1);
|
||||
expect(red.stderr).toContain(`${failed.outcomes[0].files[0]}: failed`);
|
||||
expect(red.stdout).toContain('3 executed, 0 reused; 1 passed, 2 failed (6 attempt records from 1 collectors)');
|
||||
expect(red.stdout).toContain('3 executed, 0 reused; 1 passed, 2 failed, 0 manual accepted (unscored; no score-cache credit) (6 attempt records from 1 collectors)');
|
||||
expect(red.stdout).toContain('3 cases with multiple attempts this run:');
|
||||
expect(red.stdout).not.toMatch(/passed only on retry|not blocking/);
|
||||
|
||||
@@ -192,4 +245,116 @@ describe('dependency-free CI planner and report execution', () => {
|
||||
expect(corrupt.status).toBe(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('report verifies every manual claim against current source, preserves attempts, and never masks a failed shard', () => {
|
||||
const reportDir = path.join(fixture, 'manual-report');
|
||||
const manifestPath = path.join(reportDir, 'manifest.json');
|
||||
const planned = run(['--emit-plan', manifestPath, '--slices', '1'], 'gate');
|
||||
expect(planned.status, planned.stderr).toBe(0);
|
||||
const manifest: PaidRunManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const slice: SliceResult = {
|
||||
version: 1, tier: 'gate', sliceIndex: 1, sliceCount: 1,
|
||||
outcomes: manifest.entries.filter(entry => entry.status === 'planned').map(entry => ({
|
||||
files: [entry.file], status: 'passed', exitCode: 0, elapsedMs: 1,
|
||||
executedTests: STRICT_RETRY_CASE_BUDGETS.find(budget => budget.file === entry.file)?.cases ?? 1,
|
||||
skippedTests: 0, ...(entry.budget ? { budget: entry.budget } : {}),
|
||||
})),
|
||||
};
|
||||
const slicePath = path.join(reportDir, 'slice-1.json');
|
||||
const collectorPath = path.join(reportDir, 'judge-results.json');
|
||||
const summaryPath = path.join(reportDir, 'collector-outcomes.json');
|
||||
const receipt = manualReviewFixture(ROOT);
|
||||
const write = (tests: unknown[]) => fs.writeFileSync(collectorPath, JSON.stringify({
|
||||
total_tests: tests.length, tier: 'llm-judge', shard: 1, total_cost_usd: 0,
|
||||
tests, flaky_retries: [{ name: receipt.name, attempts: tests.length }],
|
||||
}));
|
||||
fs.writeFileSync(slicePath, JSON.stringify(slice));
|
||||
write([{ ...receipt, passed: true }, receipt]);
|
||||
const historical = run(['--report', reportDir], 'gate');
|
||||
expect(historical.status).toBe(1);
|
||||
expect(historical.stderr).toContain('attempt 1: Malformed manual-review claim');
|
||||
expect(fs.existsSync(summaryPath)).toBe(false);
|
||||
|
||||
write([{ ...receipt, manual_review: { ...receipt.manual_review, refusal: {
|
||||
...receipt.manual_review!.refusal, response_id: '',
|
||||
} } }, receipt]);
|
||||
const malformed = run(['--report', reportDir], 'gate');
|
||||
expect(malformed.status).toBe(1);
|
||||
expect(malformed.stderr).toContain('attempt 1: Malformed manual-review claim');
|
||||
|
||||
write([{ ...receipt, manual_review: undefined, passed: false }, receipt]);
|
||||
const forgedFirstAttempt = run(['--report', reportDir], 'gate');
|
||||
expect(forgedFirstAttempt.status).toBe(1);
|
||||
expect(forgedFirstAttempt.stderr).toContain('attempt 2: manual review is only valid on the first case attempt');
|
||||
expect(fs.existsSync(summaryPath)).toBe(false);
|
||||
write([receipt, { ...receipt, attempt: 2 }]);
|
||||
const retriedManual = run(['--report', reportDir], 'gate');
|
||||
expect(retriedManual.status).toBe(1);
|
||||
expect(retriedManual.stderr).toContain('attempt 2: Malformed manual-review claim');
|
||||
expect(fs.existsSync(summaryPath)).toBe(false);
|
||||
|
||||
write([receipt]);
|
||||
const secondCollector = path.join(reportDir, 'other-results.json');
|
||||
fs.writeFileSync(secondCollector, JSON.stringify({ total_tests: 1, tests: [receipt] }));
|
||||
const duplicateCollector = run(['--report', reportDir], 'gate');
|
||||
expect(duplicateCollector.status).toBe(1);
|
||||
expect(duplicateCollector.stderr).toContain('duplicate manual-review claim');
|
||||
expect(fs.existsSync(summaryPath)).toBe(false);
|
||||
fs.rmSync(secondCollector);
|
||||
|
||||
write([receipt, { name: 'automated', suite: 'other', passed: true, execution: 'reused' }]);
|
||||
const clean = run(['--report', reportDir], 'gate');
|
||||
expect(clean.status, clean.stderr).toBe(0);
|
||||
expect(clean.stdout).toContain('1 passed, 0 failed, 1 manual accepted (unscored; no score-cache credit) (2 attempt records');
|
||||
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
|
||||
expect(summary.totals).toEqual({ executed: 1, reused: 1, passed: 1, failed: 0,
|
||||
manual_accepted: 1, attempts: 2, total: 2, flaky: 1 });
|
||||
expect(summary.files[0]).toMatchObject({ file: 'judge-results.json', total: 2, manual_accepted: 1, passed: 1 });
|
||||
expect(JSON.parse(fs.readFileSync(collectorPath, 'utf8')).tests[0]).toEqual(receipt);
|
||||
|
||||
const browserPath = path.join(fixture, 'BROWSER.md');
|
||||
const browserSource = fs.readFileSync(browserPath, 'utf8');
|
||||
fs.writeFileSync(browserPath, browserSource.replace('#### Choosing a source and checking sign-in',
|
||||
'#### Choosing a source and checking sign-in\nchanged approved source'));
|
||||
const sourceDrift = run(['--report', reportDir], 'gate');
|
||||
expect(sourceDrift.status).toBe(1);
|
||||
expect(sourceDrift.stderr).toContain('does not match current source and approval');
|
||||
expect(fs.existsSync(summaryPath)).toBe(false);
|
||||
fs.writeFileSync(browserPath, browserSource);
|
||||
|
||||
write([receipt, { name: 'unapproved', passed: false, execution: 'executed' }]);
|
||||
const failedCollector = run(['--report', reportDir], 'gate');
|
||||
expect(failedCollector.status).toBe(1);
|
||||
expect(failedCollector.stderr).toContain('1 unapproved final collector failure(s)');
|
||||
expect(JSON.parse(fs.readFileSync(summaryPath, 'utf8')).totals).toMatchObject({ failed: 1, manual_accepted: 1 });
|
||||
|
||||
write([receipt, { passed: true }]);
|
||||
const missingName = run(['--report', reportDir], 'gate');
|
||||
expect(missingName.status).toBe(1);
|
||||
expect(missingName.stderr).toContain('attempt 2: malformed collector entry (name/passed required)');
|
||||
expect(fs.existsSync(summaryPath)).toBe(false);
|
||||
write([receipt, { name: 'malformed', passed: 'true' }]);
|
||||
const malformedPassed = run(['--report', reportDir], 'gate');
|
||||
expect(malformedPassed.status).toBe(1);
|
||||
expect(malformedPassed.stderr).toContain('attempt 2: malformed collector entry (name/passed required)');
|
||||
expect(fs.existsSync(summaryPath)).toBe(false);
|
||||
|
||||
write([receipt, { name: 'automated', suite: 'other', passed: true, execution: 'reused' }]);
|
||||
|
||||
slice.outcomes[0].status = 'failed';
|
||||
slice.outcomes[0].exitCode = 1;
|
||||
fs.writeFileSync(slicePath, JSON.stringify(slice));
|
||||
const failedShard = run(['--report', reportDir], 'gate');
|
||||
expect(failedShard.status).toBe(1);
|
||||
expect(failedShard.stderr).toContain(`${slice.outcomes[0].files[0]}: failed`);
|
||||
expect(JSON.parse(fs.readFileSync(summaryPath, 'utf8')).totals.manual_accepted).toBe(1);
|
||||
|
||||
const stale = structuredClone(receipt);
|
||||
stale.manual_review!.approval.prompt_sha256 = '0'.repeat(64);
|
||||
write([stale]);
|
||||
const mismatched = run(['--report', reportDir], 'gate');
|
||||
expect(mismatched.status).toBe(1);
|
||||
expect(mismatched.stderr).toContain('attempt 1: Malformed manual-review claim');
|
||||
expect(fs.existsSync(summaryPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterAll, describe, expect, spyOn, test } from 'bun:test';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
@@ -11,22 +11,42 @@ const FAKE = path.join(DIR, 'fake claude.ts');
|
||||
const DESCENDANT = path.join(DIR, 'pipe holder.ts');
|
||||
const CAPTURE = path.join(DIR, 'capture.json');
|
||||
const PID = path.join(DIR, 'descendant.pid');
|
||||
const ACTOR_STAGES = path.join(DIR, 'actor-stages.jsonl');
|
||||
const DESCENDANT_STAGES = path.join(DIR, 'descendant-stages.jsonl');
|
||||
const DIR_IDENTITY = lstatSync(DIR, { bigint: true });
|
||||
const RESOLVED_DIR = realpathSync(DIR);
|
||||
|
||||
// Publish readiness only after the grandchild has initialized and flushed both
|
||||
// inherited pipes; a PID returned by spawn alone does not establish that state.
|
||||
writeFileSync(DESCENDANT, `
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { appendFileSync, writeFileSync } from 'node:fs';
|
||||
const record = stage => {
|
||||
if (process.env.FAKE_MODE === 'timeout') {
|
||||
try { appendFileSync(${JSON.stringify(DESCENDANT_STAGES)}, JSON.stringify({ stage, atUnixMs: Date.now(), processNs: process.hrtime.bigint().toString(), pid: process.pid }) + '\\n'); } catch {}
|
||||
}
|
||||
};
|
||||
record('descendant_start');
|
||||
await Bun.sleep(Number(process.env.DESCENDANT_DELAY_MS || 0));
|
||||
setInterval(() => {}, 1000);
|
||||
await new Promise(resolve => process.stdout.write(' ', resolve));
|
||||
record('stdout_flushed');
|
||||
await new Promise(resolve => process.stderr.write(' ', resolve));
|
||||
record('stderr_flushed');
|
||||
writeFileSync(process.env.PID_FILE!, String(process.pid));
|
||||
record('pid_published');
|
||||
`);
|
||||
|
||||
writeFileSync(FAKE, `
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { appendFileSync, existsSync, rmSync, writeFileSync } from 'node:fs';
|
||||
const record = (stage, pid = process.pid) => {
|
||||
if (process.env.FAKE_MODE === 'timeout') {
|
||||
try { appendFileSync(${JSON.stringify(ACTOR_STAGES)}, JSON.stringify({ stage, atUnixMs: Date.now(), processNs: process.hrtime.bigint().toString(), pid }) + '\\n'); } catch {}
|
||||
}
|
||||
};
|
||||
record('actor_start');
|
||||
const prompt = await Bun.stdin.text();
|
||||
record('stdin_read');
|
||||
writeFileSync(process.env.CAPTURE!, JSON.stringify({args:process.argv.slice(2),prompt,cwd:process.cwd(),model:process.env.ANTHROPIC_MODEL,auth:process.env.ANTHROPIC_API_KEY}));
|
||||
const mode = process.env.FAKE_MODE;
|
||||
if (mode === 'startup-timeout') {
|
||||
@@ -39,11 +59,18 @@ if (mode === 'timeout' || mode === 'descendant' || mode === 'escaped') {
|
||||
// drain fixture must survive that exit so its inherited pipes remain open.
|
||||
const child = spawn(process.execPath, [process.env.DESCENDANT!], {stdio:['ignore','inherit','inherit'],
|
||||
detached:mode === 'escaped' || (process.platform === 'win32' && mode === 'descendant')});
|
||||
record('descendant_spawn_requested');
|
||||
child.once('spawn', () => record('descendant_spawned', child.pid));
|
||||
child.once('exit', () => record('descendant_exited', child.pid));
|
||||
const readyBy = Date.now() + 2000;
|
||||
while (!existsSync(process.env.PID_FILE!)) {
|
||||
if (child.exitCode !== null || Date.now() >= readyBy) throw new Error('Descendant did not initialize its inherited pipes');
|
||||
if (child.exitCode !== null || Date.now() >= readyBy) {
|
||||
record('readiness_failed');
|
||||
throw new Error('Descendant did not initialize its inherited pipes');
|
||||
}
|
||||
await Bun.sleep(5);
|
||||
}
|
||||
record('readiness_observed');
|
||||
if (mode === 'timeout') await new Promise(() => {});
|
||||
}
|
||||
if (mode === 'auth') { process.stderr.write('Not logged in. Please run claude /login.'); process.exit(1); }
|
||||
@@ -79,6 +106,27 @@ function run(mode = 'success', extra: Partial<Parameters<typeof runClaudeCode>[0
|
||||
|
||||
function capture() { return JSON.parse(readFileSync(CAPTURE, 'utf8')); }
|
||||
|
||||
function timeoutStageReceipt(file: string): object {
|
||||
try {
|
||||
const directory = lstatSync(DIR, { bigint: true });
|
||||
if (directory.isSymbolicLink() || directory.dev !== DIR_IDENTITY.dev || directory.ino !== DIR_IDENTITY.ino || realpathSync(DIR) !== RESOLVED_DIR || ![ACTOR_STAGES, DESCENDANT_STAGES].includes(file)) return { available: false, reason: 'identity_mismatch' };
|
||||
const state = lstatSync(file);
|
||||
if (!state.isFile() || state.isSymbolicLink() || state.size > 4096) return { available: false, reason: 'invalid_receipt' };
|
||||
const lines = readFileSync(file, 'utf8').trim().split('\n');
|
||||
if (lines.length > 16) return { available: false, reason: 'invalid_receipt' };
|
||||
const stages = [];
|
||||
for (const line of lines) {
|
||||
let event;
|
||||
try { event = JSON.parse(line); } catch { return { available: false, reason: 'incomplete_receipt', stages }; }
|
||||
if (!event || !['actor_start', 'stdin_read', 'descendant_spawn_requested', 'descendant_spawned', 'descendant_exited', 'readiness_failed', 'readiness_observed', 'descendant_start', 'stdout_flushed', 'stderr_flushed', 'pid_published'].includes(event.stage) || !Number.isSafeInteger(event.atUnixMs) || event.atUnixMs <= 0 || typeof event.processNs !== 'string' || !/^\d{1,24}$/.test(event.processNs) || !Number.isSafeInteger(event.pid) || event.pid <= 0) return { available: false, reason: 'invalid_receipt', stages };
|
||||
stages.push({ stage: event.stage, atUnixMs: event.atUnixMs, processNs: event.processNs, pid: event.pid });
|
||||
}
|
||||
return { available: true, stages };
|
||||
} catch (error) {
|
||||
return { available: false, reason: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'not_published' : 'inspection_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
function running(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
@@ -107,6 +155,22 @@ function cleanupDescendant() {
|
||||
}
|
||||
|
||||
describe('Claude Code restricted execution', () => {
|
||||
test('timeout stage diagnostics retain only bounded safe fixture events', () => {
|
||||
const event = { stage: 'actor_start', atUnixMs: 123, processNs: '123', pid: process.pid };
|
||||
try {
|
||||
expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: false, reason: 'not_published' });
|
||||
writeFileSync(ACTOR_STAGES, JSON.stringify({ ...event, secret: 'sensitive-sentinel' }) + '\n');
|
||||
expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: true, stages: [event] });
|
||||
writeFileSync(ACTOR_STAGES, JSON.stringify(event) + '\n{"stage":');
|
||||
expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: false, reason: 'incomplete_receipt', stages: [event] });
|
||||
writeFileSync(ACTOR_STAGES, JSON.stringify({ ...event, stage: 'sensitive-sentinel' }));
|
||||
expect(JSON.stringify(timeoutStageReceipt(ACTOR_STAGES))).not.toContain('sensitive-sentinel');
|
||||
writeFileSync(ACTOR_STAGES, 'x'.repeat(4097));
|
||||
expect(timeoutStageReceipt(ACTOR_STAGES)).toEqual({ available: false, reason: 'invalid_receipt' });
|
||||
expect(timeoutStageReceipt(CAPTURE)).toEqual({ available: false, reason: 'identity_mismatch' });
|
||||
} finally { rmSync(ACTOR_STAGES, { force: true }); }
|
||||
});
|
||||
|
||||
test('explicit model override stays one literal argument across access modes and resume', async () => {
|
||||
const model = 'custom-model "quoted" $(touch /never)';
|
||||
for (const access of ['none', 'read-only'] as const) {
|
||||
@@ -211,7 +275,12 @@ describe('Claude Code restricted execution', () => {
|
||||
|
||||
test('timeout kills its descendants and clears process signal listeners', async () => {
|
||||
rmSync(PID, { force: true });
|
||||
rmSync(ACTOR_STAGES, { force: true });
|
||||
rmSync(DESCENDANT_STAGES, { force: true });
|
||||
const before = ['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name));
|
||||
const startedAtUnixMs = Date.now();
|
||||
const startedProcessNs = process.hrtime.bigint().toString();
|
||||
let returned: { atUnixMs: number; processNs: string; timedOut: boolean } | undefined;
|
||||
const schedule = globalThis.setTimeout;
|
||||
let fireTimeout: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback, delay, ...args) => {
|
||||
@@ -233,12 +302,14 @@ describe('Claude Code restricted execution', () => {
|
||||
fireTimeout = undefined;
|
||||
expire();
|
||||
const result = await invocation;
|
||||
returned = { atUnixMs: Date.now(), processNs: process.hrtime.bigint().toString(), timedOut: result.error?.code === 'timeout' };
|
||||
expect(result.status).toBe('unavailable');
|
||||
expect(result.error?.code).toBe('timeout');
|
||||
expect(Date.now() - start).toBeLessThan(2000);
|
||||
expect(['SIGINT','SIGTERM','exit'].map(name => process.listenerCount(name))).toEqual(before);
|
||||
await expectDescendantDead();
|
||||
} finally {
|
||||
console.error(JSON.stringify({ claudeTimeoutStages: { startedAtUnixMs, startedProcessNs, timeoutMs: 500, readinessMs: 2000, returned, pidPublished: existsSync(PID), actor: timeoutStageReceipt(ACTOR_STAGES), descendant: timeoutStageReceipt(DESCENDANT_STAGES) } }));
|
||||
fireTimeout?.();
|
||||
await invocation;
|
||||
cleanupDescendant();
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterAll, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseRunManifest } from '../scripts/test-paid-shards';
|
||||
|
||||
const root = path.resolve(import.meta.dir, '..');
|
||||
const scratch = realpathSync(mkdtempSync(path.join(tmpdir(), 'cookie-phase-')));
|
||||
const workflow = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/evals.yml'), 'utf8')) as any;
|
||||
const command = workflow.jobs['plan-slices'].steps.find((step: any) => step.name === 'Emit validation-phase manifest').run;
|
||||
const body = command.match(/^bun --no-install -e '\n([\s\S]*)\n'\s*$/)?.[1];
|
||||
if (!body) throw new Error('Validation planner script was not found');
|
||||
|
||||
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
|
||||
|
||||
function manifestFor(phase: string) {
|
||||
const output = path.join(scratch, phase);
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', body!.replaceAll('/tmp/paid-plan', output.replaceAll('\\', '/'))], {
|
||||
cwd: root, env: { ...process.env, VALIDATION_PHASE: phase, EVALS_TIER: 'gate', EVALS_ALL: '1' }, encoding: 'utf8', timeout: 30_000,
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
return parseRunManifest(readFileSync(path.join(output, 'manifest.json'), 'utf8'));
|
||||
}
|
||||
|
||||
test('the actual CI cookie repair planner executes only eight dependent cases without judges or run-all', () => {
|
||||
const manifest = manifestFor('cookie-behavior');
|
||||
expect(manifest.profile).toBe('full');
|
||||
expect(manifest.evalsAll).toBe(false);
|
||||
expect(manifest.selection).toEqual({ e2e: ['browse-basic', 'browse-snapshot', 'qa-quick', 'qa-only-no-fix', 'design-review-detector-shim-dom', 'diagram-triplet', 'canary-workflow', 'benchmark-workflow'], judges: [] });
|
||||
expect(manifest.entries.filter(entry => entry.status === 'planned').map(entry => entry.file).sort()).toEqual([
|
||||
'test/skill-e2e-bws.test.ts', 'test/skill-e2e-deploy.test.ts', 'test/skill-e2e-design.test.ts', 'test/skill-e2e-diagram.test.ts', 'test/skill-e2e-qa-workflow.test.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the existing quality and behavior phases retain their complete separate shard census', () => {
|
||||
const quality = manifestFor('quality');
|
||||
const behavior = manifestFor('behavior');
|
||||
const qualityFiles = quality.entries.filter(entry => entry.status === 'planned').map(entry => entry.file);
|
||||
const behaviorFiles = behavior.entries.filter(entry => entry.status === 'planned').map(entry => entry.file);
|
||||
expect(quality.evalsAll).toBe(true);
|
||||
expect(behavior.evalsAll).toBe(true);
|
||||
expect(qualityFiles).toHaveLength(2);
|
||||
expect(behaviorFiles).toHaveLength(52);
|
||||
expect(qualityFiles.every(file => file.startsWith('test/skill-llm-eval'))).toBe(true);
|
||||
expect(behaviorFiles.every(file => !qualityFiles.includes(file))).toBe(true);
|
||||
});
|
||||
|
||||
test('curated Windows and native qualification use the same pinned Node runtime', () => {
|
||||
const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any;
|
||||
for (const job of ['windows-free-tests', 'cookie-native-qualification']) {
|
||||
const setup = windows.jobs[job].steps.find((step: any) => step.uses?.startsWith('actions/setup-node@'));
|
||||
expect(setup.with['node-version']).toBe('24.18.0');
|
||||
expect(setup.if).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows retains complete shard logs on successful and failed runs', () => {
|
||||
const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any;
|
||||
const upload = windows.jobs['windows-free-tests'].steps.find((step: any) => step.with?.name === 'windows-free-test-shard-logs');
|
||||
expect(upload.if).toBe('always()');
|
||||
expect(upload.with.path).toBe('${{ runner.temp }}/gstack-free-test-*.log');
|
||||
});
|
||||
|
||||
test('focused Windows diagnostics include the repaired lock and close cases without default-profile qualification', () => {
|
||||
const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any;
|
||||
const run = windows.jobs['windows-free-tests'].steps.find((step: any) => step.name === 'Run focused native launch and credential diagnostics').run;
|
||||
const pattern = run.match(/--test-name-pattern '([^']+)'/)?.[1];
|
||||
expect(pattern).toBeDefined();
|
||||
const selected = new RegExp(pattern);
|
||||
for (const name of ['native Windows launch diagnostics > observer', 'native Windows process qualification > a locked real Edge profile leaves its existing owner alive',
|
||||
'native Windows process qualification > real Edge synthetic profile: normal-close', 'native Windows process qualification > real Edge synthetic profile: stalled-close']) expect(selected.test(name)).toBe(true);
|
||||
expect(selected.test('native Windows process qualification > an exclusively created default Edge profile persists v20')).toBe(false);
|
||||
});
|
||||
|
||||
test('Dia comparison uses separate pinned runtime jobs and retains diagnostic failures', () => {
|
||||
const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any;
|
||||
const job = windows.jobs['dia-native-qualification'];
|
||||
expect(job['runs-on']).toBe('macos-15');
|
||||
expect(job.strategy['fail-fast']).toBe(false);
|
||||
expect(job.strategy.matrix.runtime).toContain('["bun","node"]');
|
||||
expect(job.strategy.matrix.runtime).toContain('inputs.dia_launch_comparison');
|
||||
const node = job.steps.find((step: any) => step.uses?.startsWith('actions/setup-node@'));
|
||||
expect(node.with).toEqual({ 'node-version': '24.18.0', architecture: 'arm64' });
|
||||
const comparison = job.steps.find((step: any) => step.name === 'Compare protected native Dia launch without qualification credit');
|
||||
expect(comparison.run).toContain('--launch-comparison "$COMPARISON_RUNTIME"');
|
||||
expect(comparison['continue-on-error']).toBeUndefined();
|
||||
const upload = job.steps.find((step: any) => step.uses?.startsWith('actions/upload-artifact@'));
|
||||
expect(upload.if).toBe('always()');
|
||||
expect(upload.with.name).toContain("format('dia-launch-comparison-{0}', matrix.runtime)");
|
||||
expect(windows.jobs['windows-free-tests'].if).toContain('!inputs.dia_launch_comparison');
|
||||
expect(windows.jobs['cookie-native-qualification'].if).toContain('!inputs.dia_launch_comparison');
|
||||
});
|
||||
|
||||
test('Dia GUI readiness is an exclusive, single-job diagnostic without browser installation or launch', () => {
|
||||
const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any;
|
||||
const job = windows.jobs['dia-native-qualification'];
|
||||
const input = windows.on.workflow_dispatch.inputs.dia_gui_readiness;
|
||||
expect(input).toMatchObject({ type: 'boolean', default: false });
|
||||
expect(job.if).toContain('inputs.dia_gui_readiness');
|
||||
expect(job.strategy.matrix.runtime).toContain('inputs.dia_launch_comparison && !inputs.dia_gui_readiness');
|
||||
for (const name of ['windows-free-tests', 'cookie-native-qualification']) {
|
||||
expect(windows.jobs[name].if).toContain('!inputs.dia_gui_readiness');
|
||||
}
|
||||
const probe = job.steps.find((step: any) => step.name === 'Inspect GUI readiness without browser or Keychain access');
|
||||
expect(probe.if).toBe('inputs.dia_gui_readiness');
|
||||
expect(probe.run).toEndWith('.github/scripts/run-dia-native-qualification.ts --gui-readiness-only');
|
||||
expect(probe.env).toEqual({ GSTACK_DIA_NATIVE_QUALIFY: '1' });
|
||||
const excluded = job.steps.filter((step: any) => step.uses?.startsWith('actions/setup-node@')
|
||||
|| ['Install pinned dependencies', 'Install the synthetic destination browser', 'Qualify native Dia discovery, decryption, and import',
|
||||
'Compare protected native Dia launch without qualification credit'].includes(step.name));
|
||||
expect(excluded).toHaveLength(5);
|
||||
for (const step of excluded) expect(step.if).toContain('!inputs.dia_gui_readiness');
|
||||
const upload = job.steps.find((step: any) => step.uses?.startsWith('actions/upload-artifact@'));
|
||||
expect(upload.if).toBe('always()');
|
||||
expect(upload.with.name).toContain("inputs.dia_gui_readiness && 'dia-gui-readiness'");
|
||||
expect(upload.with.path).toBe('${{ runner.temp }}/dia-native-qualification.json');
|
||||
});
|
||||
|
||||
test('the actual GUI readiness selection guard refuses conflicting or malformed mode inputs', () => {
|
||||
const windows = Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows/windows-free-tests.yml'), 'utf8')) as any;
|
||||
const steps = windows.jobs['dia-native-qualification'].steps;
|
||||
const guard = steps.find((step: any) => step.name === 'Validate GUI readiness selection');
|
||||
expect(guard.if).toBe('inputs.dia_gui_readiness');
|
||||
expect(guard.env.OTHER_DIA_MODES).toBe('${{ inputs.dia_native_only || inputs.dia_launch_comparison || inputs.native_diagnostics_only }}');
|
||||
expect(steps.indexOf(guard)).toBeLessThan(steps.findIndex((step: any) => step.name === 'Install pinned dependencies'));
|
||||
const script = guard.run.match(/ -e '\n([\s\S]*)\n'\s*$/)?.[1];
|
||||
expect(script).toBeDefined();
|
||||
for (const input of ['false', 'true', '', 'unknown']) {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script!], {
|
||||
cwd: root, env: { ...process.env, OTHER_DIA_MODES: input }, encoding: 'utf8', timeout: 5000,
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(input === 'false' ? 0 : 1);
|
||||
expect(result.stderr.includes('must be selected alone')).toBe(input !== 'false');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { buildCookieWorkflowJudgeInput, COOKIE_WORKFLOW_JUDGE } from './helpers/cookie-workflow-judge-input';
|
||||
import { buildWorkflowJudgePrompt, readWorkflowJudgeInput } from './helpers/workflow-judge-input';
|
||||
import { prepareWorkflowJudgeCache, type WorkflowCacheOptions } from './helpers/workflow-judge-cache';
|
||||
import type { EvalTestEntry } from './helpers/eval-store';
|
||||
import { selectTests } from './helpers/test-selection';
|
||||
import { E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles-data';
|
||||
import { selectPrProfile } from '../scripts/test-pr-profile';
|
||||
import { JUDGE_MS } from './helpers/eval-budgets';
|
||||
import { JudgeRefusalError, DEFAULT_JUDGE_MAX_TOKENS } from './helpers/llm-judge';
|
||||
import { COOKIE_MANUAL_REVIEW_FILE, getCookieWorkflowManualReview, isManualReviewEntry } from './helpers/cookie-workflow-manual-review';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const NAME = 'setup-browser-cookies/SKILL.md workflow';
|
||||
const scratch: string[] = [];
|
||||
const skill = 'excluded preamble\n# Setup Browser Cookies\nRead the gstack root\'s `BROWSER.md`, **Choosing a source and checking sign-in**.\nExact body.\n';
|
||||
const browser = 'excluded browser introduction\n#### Choosing a source and checking sign-in\nExact reference.\n\n### Tabs + frames\nexcluded tab commands\n';
|
||||
|
||||
function fixture(entry: string | null = skill, reference: string | null = browser): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'gstack-cookie-judge-'));
|
||||
scratch.push(root);
|
||||
mkdirSync(join(root, 'setup-browser-cookies'));
|
||||
if (entry !== null) writeFileSync(join(root, 'setup-browser-cookies/SKILL.md'), entry);
|
||||
if (reference !== null) writeFileSync(join(root, 'BROWSER.md'), reference);
|
||||
return root;
|
||||
}
|
||||
|
||||
function approveFixture(root: string) {
|
||||
const input = buildCookieWorkflowJudgeInput(root);
|
||||
const approval = { ...JSON.parse(readFileSync(join(ROOT, COOKIE_MANUAL_REVIEW_FILE), 'utf8')),
|
||||
prompt_sha256: input.sha256, prompt_bytes: Buffer.byteLength(input.prompt), model: 'fixture-model',
|
||||
reason: 'Synthetic approval fixture, not live review evidence' };
|
||||
mkdirSync(join(root, '.github'), { recursive: true });
|
||||
writeFileSync(join(root, COOKIE_MANUAL_REVIEW_FILE), JSON.stringify(approval));
|
||||
return approval;
|
||||
}
|
||||
|
||||
const refusal = () => new JudgeRefusalError({ id: 'msg_synthetic', _request_id: 'req_synthetic',
|
||||
model: 'fixture-model', usage: { input_tokens: 1, output_tokens: 0 }, content: [] });
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of scratch.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
type FixtureScore = { clarity: number; completeness: number; actionability: number; reasoning: string };
|
||||
const passingScore: FixtureScore = { ...COOKIE_WORKFLOW_JUDGE.thresholds, reasoning: 'Synthetic fixture score' };
|
||||
|
||||
function actualCookieCallback(root: string, overrides: {
|
||||
judge?: () => Promise<FixtureScore>;
|
||||
clock?: () => number;
|
||||
budget?: number;
|
||||
setTimer?: typeof setTimeout;
|
||||
clearTimer?: typeof clearTimeout;
|
||||
} = {}) {
|
||||
const source = readFileSync(join(ROOT, 'test/skill-llm-eval.test.ts'), 'utf8');
|
||||
const managedStart = source.indexOf('async function runWorkflowJudge');
|
||||
const managedEnd = source.indexOf('// Block 1:', managedStart);
|
||||
const start = source.indexOf("describeIfSelected('Cookie setup workflow quality'");
|
||||
const end = source.indexOf('// Module-level afterAll', start);
|
||||
expect(managedStart).toBeGreaterThan(-1);
|
||||
expect(managedEnd).toBeGreaterThan(managedStart);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
const registration = new Bun.Transpiler({ loader: 'ts' }).transformSync(source.slice(managedStart, managedEnd) + source.slice(start, end));
|
||||
const requests: Array<{ prompt: string; model: undefined; signal: AbortSignal }> = [];
|
||||
const records: EvalTestEntry[] = [];
|
||||
const attempts = new Map<string, { attempt: number }>();
|
||||
let callback: () => Promise<void> = async () => { throw new Error('Judge callback was not registered'); };
|
||||
new Function('describeIfSelected', 'testIfSelected', 'ROOT', 'buildCookieWorkflowJudgeInput', 'resolveEvalModel', 'callJudge', 'COOKIE_WORKFLOW_JUDGE', 'JUDGE_MS', 'WORKFLOW_JUDGE_TEST_MS', 'WORKFLOW_JUDGE_RECORD_MS', 'evalCollector', 'expect', 'console', 'readWorkflowJudgeInput', 'buildWorkflowJudgePrompt', 'prepareWorkflowJudgeCache', 'workflowJudgeAttempts', 'performance', 'setTimeout', 'clearTimeout', 'JudgeRefusalError', 'getCookieWorkflowManualReview', 'DEFAULT_JUDGE_MAX_TOKENS', registration)(
|
||||
(_suite: string, names: string[], run: () => void) => { expect(names).toEqual([NAME]); run(); },
|
||||
(name: string, run: () => Promise<void>, budget: number) => { expect(name).toBe(NAME); expect(budget).toBe(JUDGE_MS + 10_000); callback = run; },
|
||||
root, buildCookieWorkflowJudgeInput, () => 'fixture-model',
|
||||
async (prompt: string, model: undefined, options: { signal: AbortSignal }) => { requests.push({ prompt, model, signal: options.signal }); return overrides.judge ? overrides.judge() : passingScore; },
|
||||
COOKIE_WORKFLOW_JUDGE, overrides.budget ?? JUDGE_MS, JUDGE_MS + 10_000, 5_000,
|
||||
{ addTest: (record: EvalTestEntry) => records.push(record) }, expect, { log() {} },
|
||||
readWorkflowJudgeInput, buildWorkflowJudgePrompt,
|
||||
(options: WorkflowCacheOptions) => prepareWorkflowJudgeCache({ ...options, env: {
|
||||
EVALS_CACHE_DIR: join(root, 'cache'), EVALS_CACHE_REPOSITORY: 'fixture/cookie', EVALS_CACHE_PR: '1',
|
||||
EVALS_CACHE_RUNTIME_ID: 'a'.repeat(64), EVALS_TIER: 'gate', EVALS_RUN_ID: 'cookie-fixture',
|
||||
} }),
|
||||
attempts, overrides.clock ? { now: overrides.clock } : performance,
|
||||
overrides.setTimer ?? setTimeout, overrides.clearTimer ?? clearTimeout,
|
||||
JudgeRefusalError, getCookieWorkflowManualReview, DEFAULT_JUDGE_MAX_TOKENS,
|
||||
);
|
||||
return { run: () => callback(), requests, records, attempts };
|
||||
}
|
||||
|
||||
describe('cookie workflow judge input', () => {
|
||||
test('the registered callback accepts only an approved empty provider refusal, with no score or cache credit', async () => {
|
||||
const root = fixture();
|
||||
approveFixture(root);
|
||||
const h = actualCookieCallback(root, { judge: async () => { throw refusal(); } });
|
||||
await h.run();
|
||||
expect(h.requests).toHaveLength(1);
|
||||
expect(h.records).toHaveLength(1);
|
||||
expect(h.records[0]).toMatchObject({ passed: false, execution: 'executed', exit_reason: 'provider_refusal' });
|
||||
expect(isManualReviewEntry(h.records[0])).toBe(true);
|
||||
expect(h.records[0]).not.toHaveProperty('judge_scores');
|
||||
expect(h.records[0]).not.toHaveProperty('judge_reasoning');
|
||||
expect(h.records[0]).not.toHaveProperty('reused_from');
|
||||
expect(existsSync(join(root, 'cache'))).toBe(false);
|
||||
});
|
||||
|
||||
test('an approval cannot turn low scores, malformed output, or a refusal-named ordinary error into acceptance', async () => {
|
||||
const root = fixture(); approveFixture(root);
|
||||
for (const result of [
|
||||
{ ...passingScore, clarity: 1 },
|
||||
new SyntaxError('Malformed provider JSON'),
|
||||
Object.assign(new Error('Synthetic refusal text'), { name: 'JudgeRefusalError' }),
|
||||
]) {
|
||||
const h = actualCookieCallback(root, { judge: async () => { if (result instanceof Error) throw result; return result; } });
|
||||
await expect(h.run()).rejects.toThrow();
|
||||
expect(h.records).toHaveLength(1);
|
||||
expect(h.records[0].passed).toBe(false);
|
||||
expect(h.records[0]).not.toHaveProperty('manual_review');
|
||||
expect(isManualReviewEntry(h.records[0])).toBe(false);
|
||||
}
|
||||
const scored = actualCookieCallback(root);
|
||||
await scored.run();
|
||||
expect(scored.records[0]).toMatchObject({ passed: true, judge_scores: COOKIE_WORKFLOW_JUDGE.thresholds });
|
||||
expect(scored.records[0]).not.toHaveProperty('manual_review');
|
||||
});
|
||||
|
||||
test.each(['prompt', 'model', 'budget', 'thresholds', 'malformed', 'missing', 'evidence', 'response-evidence'])(
|
||||
'the actual callback rejects an unapproved refusal: %s', async mismatch => {
|
||||
const root = fixture(); const approval = approveFixture(root);
|
||||
const error = refusal();
|
||||
if (mismatch === 'prompt') writeFileSync(join(root, 'BROWSER.md'), browser.replace('Exact reference.', 'Changed reference.'));
|
||||
if (mismatch === 'model') approval.model = 'different-model';
|
||||
if (mismatch === 'budget') approval.max_tokens++;
|
||||
if (mismatch === 'thresholds') approval.thresholds.clarity++;
|
||||
if (mismatch === 'evidence') error.refusal.request_id = null;
|
||||
if (mismatch === 'response-evidence') error.refusal.response_id = null;
|
||||
writeFileSync(join(root, COOKIE_MANUAL_REVIEW_FILE), mismatch === 'malformed' ? '{}' : JSON.stringify(approval));
|
||||
if (mismatch === 'missing') rmSync(join(root, COOKIE_MANUAL_REVIEW_FILE));
|
||||
const h = actualCookieCallback(root, { judge: async () => { throw error; } });
|
||||
await expect(h.run()).rejects.toThrow();
|
||||
expect(h.records).toHaveLength(1);
|
||||
expect(h.records[0].passed).toBe(false);
|
||||
expect(h.records[0]).not.toHaveProperty('manual_review');
|
||||
expect(existsSync(join(root, 'cache'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a late refusal stays a timeout despite exact approval', async () => {
|
||||
const root = fixture(); approveFixture(root);
|
||||
let now = 0;
|
||||
const h = actualCookieCallback(root, { budget: 20, clock: () => now,
|
||||
judge: async () => { now = 20; throw refusal(); } });
|
||||
await expect(h.run()).rejects.toThrow('deadline');
|
||||
expect(h.records).toHaveLength(1);
|
||||
expect(h.records[0]).toMatchObject({ passed: false, exit_reason: 'timeout' });
|
||||
expect(h.records[0]).not.toHaveProperty('manual_review');
|
||||
});
|
||||
|
||||
test('a retry refusal cannot erase an earlier scored failure with manual acceptance', async () => {
|
||||
const root = fixture(); approveFixture(root);
|
||||
let calls = 0;
|
||||
const h = actualCookieCallback(root, { judge: async () => {
|
||||
if (++calls === 1) return { ...passingScore, clarity: 1 };
|
||||
throw refusal();
|
||||
} });
|
||||
await expect(h.run()).rejects.toThrow();
|
||||
await expect(h.run()).rejects.toThrow('provider refused');
|
||||
expect(h.records.map(record => [record.attempt, record.passed, record.exit_reason]))
|
||||
.toEqual([[1, false, 'validation_failed'], [2, false, 'provider_refusal']]);
|
||||
for (const record of h.records) expect(record).not.toHaveProperty('manual_review');
|
||||
});
|
||||
|
||||
test('preserves exact source bytes, line ranges, and the immutable rubric', () => {
|
||||
const input = buildCookieWorkflowJudgeInput(fixture());
|
||||
expect(input.files).toEqual([
|
||||
{ path: 'setup-browser-cookies/SKILL.md', kind: 'entrypoint', startLine: 2, endLine: 4, content: skill.slice(skill.indexOf('# Setup')) },
|
||||
{ path: 'BROWSER.md', kind: 'section', startLine: 2, endLine: 4, content: browser.slice(browser.indexOf('#### Choosing'), browser.indexOf('### Tabs')) },
|
||||
]);
|
||||
for (const file of input.files) {
|
||||
expect(input.text).toContain(`--- BEGIN FILE ${JSON.stringify(file.path)} (lines ${file.startLine}-${file.endLine}; ${file.kind}) ---\n${file.content}\n--- END FILE ${JSON.stringify(file.path)} ---`);
|
||||
expect(input.text.split(file.content)).toHaveLength(2);
|
||||
}
|
||||
expect(input.prompt).toBe(buildWorkflowJudgePrompt(COOKIE_WORKFLOW_JUDGE, input));
|
||||
expect(input.sha256).toBe(createHash('sha256').update(input.prompt).digest('hex'));
|
||||
expect(input.prompt).not.toContain('excluded');
|
||||
expect(COOKIE_WORKFLOW_JUDGE.thresholds).toEqual({ clarity: 4, completeness: 3, actionability: 4 });
|
||||
});
|
||||
|
||||
test('preserves CRLF instead of rewriting excerpts', () => {
|
||||
const input = buildCookieWorkflowJudgeInput(fixture(skill.replaceAll('\n', '\r\n'), browser.replaceAll('\n', '\r\n')));
|
||||
expect(input.files[0].content).toBe(skill.slice(skill.indexOf('# Setup')).replaceAll('\n', '\r\n'));
|
||||
expect(input.files[1].content).toBe(browser.slice(browser.indexOf('#### Choosing'), browser.indexOf('### Tabs')).replaceAll('\n', '\r\n'));
|
||||
expect(input.files.map(file => [file.startLine, file.endLine])).toEqual([[2, 4], [2, 4]]);
|
||||
});
|
||||
|
||||
test('fingerprints consumed bytes, not excluded source text', () => {
|
||||
const original = buildCookieWorkflowJudgeInput(fixture());
|
||||
expect(buildCookieWorkflowJudgeInput(fixture(skill.replace('Exact body.', 'Changed body.'))).sha256).not.toBe(original.sha256);
|
||||
expect(buildCookieWorkflowJudgeInput(fixture(skill, browser.replace('Exact reference.', 'Changed reference.'))).sha256).not.toBe(original.sha256);
|
||||
expect(buildCookieWorkflowJudgeInput(fixture(skill.replace('excluded preamble', 'different preamble'), browser.replace('excluded tab commands', 'different tab commands'))).sha256).toBe(original.sha256);
|
||||
});
|
||||
|
||||
test('reads the actual generated workflow and only its referenced cookie section', () => {
|
||||
const input = buildCookieWorkflowJudgeInput(ROOT);
|
||||
const entry = readFileSync(join(ROOT, 'setup-browser-cookies/SKILL.md'), 'utf8');
|
||||
const reference = readFileSync(join(ROOT, 'BROWSER.md'), 'utf8');
|
||||
expect(input.files[0].content).toBe(entry.slice(entry.indexOf('# Setup Browser Cookies')));
|
||||
expect(input.files[1].content).toBe(reference.slice(reference.indexOf('#### Choosing a source and checking sign-in'), reference.indexOf('### Tabs + frames')));
|
||||
expect(input.files[0].content.length).toBeGreaterThan(500);
|
||||
expect(input.files[1].content.length).toBeGreaterThan(500);
|
||||
expect(input.prompt).toContain('GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY');
|
||||
expect(input.prompt).toContain('isolated world');
|
||||
expect(input.prompt).not.toContain('### Tabs + frames');
|
||||
});
|
||||
|
||||
test('fails closed when either source file is absent', () => {
|
||||
expect(() => buildCookieWorkflowJudgeInput(fixture(null))).toThrow();
|
||||
expect(() => buildCookieWorkflowJudgeInput(fixture(skill, null))).toThrow();
|
||||
});
|
||||
|
||||
test('rejects missing, duplicate, or non-heading markers', () => {
|
||||
for (const broken of [skill.replace('# Setup Browser Cookies', '# Missing'), skill + '# Setup Browser Cookies\nDuplicate\n', skill.replace('# Setup Browser Cookies', 'Quoted # Setup Browser Cookies')]) {
|
||||
expect(() => buildCookieWorkflowJudgeInput(fixture(broken))).toThrow('expected exactly one marker');
|
||||
}
|
||||
for (const marker of ['#### Choosing a source and checking sign-in', '### Tabs + frames']) {
|
||||
expect(() => buildCookieWorkflowJudgeInput(fixture(skill, browser.replace(marker, 'missing')))).toThrow('expected exactly one marker');
|
||||
expect(() => buildCookieWorkflowJudgeInput(fixture(skill, browser + marker + '\n'))).toThrow('expected exactly one marker');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects empty, reversed, or unreferenced excerpts', () => {
|
||||
expect(() => buildCookieWorkflowJudgeInput(fixture('# Setup Browser Cookies\n'))).toThrow('empty or reversed');
|
||||
expect(() => buildCookieWorkflowJudgeInput(fixture(skill, '#### Choosing a source and checking sign-in\n\n### Tabs + frames\n'))).toThrow('empty or reversed');
|
||||
expect(() => buildCookieWorkflowJudgeInput(fixture(skill, '### Tabs + frames\n#### Choosing a source and checking sign-in\nContent\n'))).toThrow('empty or reversed');
|
||||
expect(() => buildCookieWorkflowJudgeInput(fixture(skill.replace('`BROWSER.md`', '`other.md`')))).toThrow('missing cookie reference link');
|
||||
expect(() => buildCookieWorkflowJudgeInput(fixture(skill.replace('**Choosing a source and checking sign-in**', '**Other section**')))).toThrow('missing cookie reference link');
|
||||
});
|
||||
|
||||
test('generated host workflows resolve shared references from the installation root and check Aside first', () => {
|
||||
for (const file of ['setup-browser-cookies/SKILL.md', '.agents/skills/gstack-setup-browser-cookies/SKILL.md']) {
|
||||
const source = readFileSync(join(ROOT, file), 'utf8');
|
||||
const body = source.slice(source.indexOf('# Setup Browser Cookies'));
|
||||
expect(body).toContain('Use this checkout as the gstack root if it contains `BROWSER.md` and `browse/SKILL.md`');
|
||||
expect(body).toContain('the installed root containing `bin/gstack-skill-start`, never a generated host stub');
|
||||
expect(body).toContain("Read that root's `browse/SKILL.md`");
|
||||
expect(body).toContain('On `READY`, stop importing');
|
||||
expect(body.indexOf('**BROWSER SETUP**')).toBeLessThan(body.indexOf('## SETUP'));
|
||||
expect(body).toContain("Read that same root's `BROWSER.md`, **Choosing a source and checking sign-in**");
|
||||
expect(body).not.toContain('../BROWSER.md');
|
||||
}
|
||||
expect(readFileSync(join(ROOT, 'browse/SKILL.md'), 'utf8')).toContain('## BROWSER SETUP (Aside');
|
||||
expect(readFileSync(join(ROOT, 'BROWSER.md'), 'utf8')).toContain('#### Choosing a source and checking sign-in');
|
||||
});
|
||||
|
||||
test('each owned dependency selects this judge in the fast PR profile', () => {
|
||||
for (const file of ['setup-browser-cookies/SKILL.md.tmpl', 'setup-browser-cookies/SKILL.md', 'BROWSER.md', 'test/helpers/cookie-workflow-judge-input.ts', 'test/cookie-workflow-judge-input.test.ts', 'test/helpers/cookie-workflow-manual-review.ts', 'test/cookie-workflow-manual-review.test.ts', 'test/helpers/manual-judge-review-fixture.ts', '.github/cookie-workflow-manual-review.json']) {
|
||||
const selectedJudges = selectTests([file], LLM_JUDGE_TOUCHFILES).selected;
|
||||
expect(selectedJudges).toEqual([NAME]);
|
||||
const selectedE2E = selectTests([file], E2E_TOUCHFILES).selected;
|
||||
const profile = selectPrProfile({ changedFiles: [file], selectedJudges, selectedE2E });
|
||||
expect(profile.judges).toEqual([NAME]);
|
||||
expect(profile.deferredPromptFiles).toEqual([]);
|
||||
expect(profile.missingCoverage).toEqual([]);
|
||||
expect(profile.needsFullValidation).toBe(false);
|
||||
}
|
||||
expect(selectTests(['README.md'], LLM_JUDGE_TOUCHFILES).selected).not.toContain(NAME);
|
||||
});
|
||||
|
||||
test('the managed callback sends and records exact custom input without borrowing a normal cache identity', async () => {
|
||||
const root = fixture();
|
||||
const input = buildCookieWorkflowJudgeInput(root);
|
||||
let scores = passingScore;
|
||||
const h = actualCookieCallback(root, { judge: async () => scores });
|
||||
await h.run();
|
||||
expect(h.requests).toHaveLength(1);
|
||||
expect(h.requests[0].prompt).toBe(input.prompt);
|
||||
expect(h.requests[0].model).toBeUndefined();
|
||||
expect(h.requests[0].signal).toBeInstanceOf(AbortSignal);
|
||||
expect(h.records[0]).toMatchObject({ name: NAME, prompt: input.prompt, model: 'fixture-model', execution: 'executed', passed: true });
|
||||
expect(existsSync(join(root, 'cache'))).toBe(false);
|
||||
const fresh = actualCookieCallback(root);
|
||||
await fresh.run();
|
||||
expect(fresh.requests).toHaveLength(1);
|
||||
for (const dimension of ['clarity', 'completeness', 'actionability'] as const) {
|
||||
scores = { ...COOKIE_WORKFLOW_JUDGE.thresholds, [dimension]: COOKIE_WORKFLOW_JUDGE.thresholds[dimension] - 1, reasoning: 'Synthetic failing fixture score' };
|
||||
await expect(h.run()).rejects.toThrow();
|
||||
expect(h.records.at(-1)).toMatchObject({ passed: false, exit_reason: 'validation_failed', prompt: input.prompt });
|
||||
}
|
||||
expect(existsSync(join(root, 'cache'))).toBe(false);
|
||||
});
|
||||
|
||||
test('custom input timeout aborts once and a late response cannot overwrite its successful retry', async () => {
|
||||
const root = fixture();
|
||||
let completeLate!: (value: FixtureScore) => void;
|
||||
let calls = 0, now = 0, timerId = 0;
|
||||
const timers = new Map<number, () => void>();
|
||||
const h = actualCookieCallback(root, {
|
||||
budget: 20, clock: () => now,
|
||||
setTimer: ((callback: () => void) => { timers.set(++timerId, callback); return timerId; }) as any,
|
||||
clearTimer: ((id: number) => { timers.delete(id); }) as any,
|
||||
judge: async () => ++calls === 1 ? new Promise(resolve => { completeLate = resolve; }) : passingScore,
|
||||
});
|
||||
const expired = h.run().catch((error: Error) => error);
|
||||
now = 20;
|
||||
for (const callback of [...timers.values()]) callback();
|
||||
expect((await expired as Error).message).toContain('deadline');
|
||||
expect(h.records).toHaveLength(1);
|
||||
expect(h.records[0]).toMatchObject({ passed: false, exit_reason: 'timeout', prompt: buildCookieWorkflowJudgeInput(root).prompt });
|
||||
expect(h.requests[0].signal.aborted).toBe(true);
|
||||
await h.run();
|
||||
expect(h.records.map(record => record.passed)).toEqual([false, true]);
|
||||
expect(h.attempts.get(NAME)?.attempt).toBe(2);
|
||||
completeLate(passingScore);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(h.records).toHaveLength(2);
|
||||
expect(existsSync(join(root, 'cache'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a superseding custom-input attempt cancels its predecessor without stale recording', async () => {
|
||||
let completeLate!: (value: FixtureScore) => void;
|
||||
let calls = 0;
|
||||
const h = actualCookieCallback(fixture(), {
|
||||
judge: async () => ++calls === 1 ? new Promise(resolve => { completeLate = resolve; }) : passingScore,
|
||||
});
|
||||
const old = h.run().catch((error: Error) => error);
|
||||
await h.run();
|
||||
expect((await old as Error).name).toBe('WorkflowJudgeSuperseded');
|
||||
expect(h.requests[0].signal.aborted).toBe(true);
|
||||
completeLate(passingScore);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(h.records.map(record => [record.passed, record.exit_reason])).toEqual([[false, 'cancelled'], [true, undefined]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { buildCookieWorkflowJudgeInput, COOKIE_WORKFLOW_JUDGE } from './helpers/cookie-workflow-judge-input';
|
||||
import { COOKIE_MANUAL_REVIEW_FILE, getCookieWorkflowManualReview, isManualReviewEntry, manualReviewProblem } from './helpers/cookie-workflow-manual-review';
|
||||
import { manualReviewFixture } from './helpers/manual-judge-review-fixture';
|
||||
import { validWorkflowJudgeScore } from './helpers/workflow-judge-cache';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cookie-policy-')); roots.push(root);
|
||||
for (const file of [COOKIE_MANUAL_REVIEW_FILE, 'setup-browser-cookies/SKILL.md', 'BROWSER.md']) {
|
||||
const target = join(root, file); mkdirSync(resolve(target, '..'), { recursive: true });
|
||||
writeFileSync(target, readFileSync(join(ROOT, file)));
|
||||
}
|
||||
const entry = manualReviewFixture(root);
|
||||
const approval = entry.manual_review!.approval;
|
||||
const request = { testName: entry.name, prompt: entry.prompt!, model: entry.model!,
|
||||
maxTokens: approval.max_tokens, thresholds: { ...approval.thresholds }, attempt: 1 };
|
||||
return { root, entry, approval, request, refusal: entry.manual_review!.refusal };
|
||||
}
|
||||
|
||||
test('the committed approval names precisely the complete reviewed request, not a numerical score', () => {
|
||||
const f = fixture();
|
||||
expect(buildCookieWorkflowJudgeInput(ROOT).sha256).toBe(f.approval.prompt_sha256);
|
||||
expect(f.approval.thresholds).toEqual(COOKIE_WORKFLOW_JUDGE.thresholds);
|
||||
expect(isManualReviewEntry(f.entry)).toBe(true);
|
||||
expect(manualReviewProblem(f.entry, ROOT)).toBeNull();
|
||||
expect(getCookieWorkflowManualReview(f.root, f.request, f.refusal)).toEqual(f.entry.manual_review);
|
||||
expect(validWorkflowJudgeScore(f.entry as any, f.request.thresholds)).toBe(false);
|
||||
expect(validWorkflowJudgeScore(f.entry.manual_review as any, f.request.thresholds)).toBe(false);
|
||||
});
|
||||
|
||||
test.each(['case', 'prompt', 'model', 'budget', 'thresholds', 'source', 'approval', 'retry'])(
|
||||
'admission rejects mismatched %s without changing the approval', mismatch => {
|
||||
const f = fixture(); const original = readFileSync(join(f.root, COOKIE_MANUAL_REVIEW_FILE), 'utf8');
|
||||
if (mismatch === 'case') f.request.testName = 'ship/SKILL.md workflow';
|
||||
if (mismatch === 'prompt') f.request.prompt += '\n';
|
||||
if (mismatch === 'model') f.request.model += '-other';
|
||||
if (mismatch === 'budget') f.request.maxTokens++;
|
||||
if (mismatch === 'thresholds') f.request.thresholds.clarity++;
|
||||
if (mismatch === 'retry') f.request.attempt++;
|
||||
if (mismatch === 'source') writeFileSync(join(f.root, 'BROWSER.md'), readFileSync(join(f.root, 'BROWSER.md'), 'utf8').replace('Storage stays intact', 'Changed storage stays intact'));
|
||||
if (mismatch === 'approval') writeFileSync(join(f.root, COOKIE_MANUAL_REVIEW_FILE), JSON.stringify({ ...f.approval, prompt_sha256: 'a'.repeat(64) }));
|
||||
expect(getCookieWorkflowManualReview(f.root, f.request, f.refusal)).toBeNull();
|
||||
if (mismatch !== 'approval') expect(readFileSync(join(f.root, COOKIE_MANUAL_REVIEW_FILE), 'utf8')).toBe(original);
|
||||
});
|
||||
|
||||
test.each(['passed', 'scores', 'reused', 'timeout', 'case', 'suite', 'prompt', 'model', 'refusal', 'missing-id', 'missing-response-id', 'output', 'text', 'tokens', 'schema', 'retry'])(
|
||||
'malformed manual receipt %s cannot count as acceptance', invalid => {
|
||||
const f = fixture(); const entry: any = f.entry;
|
||||
if (invalid === 'passed') entry.passed = true;
|
||||
if (invalid === 'retry') entry.attempt++;
|
||||
if (invalid === 'scores') entry.judge_scores = { clarity: 1 };
|
||||
if (invalid === 'reused') entry.execution = 'reused';
|
||||
if (invalid === 'timeout') entry.exit_reason = 'timeout';
|
||||
if (invalid === 'case') entry.name = 'ship/SKILL.md workflow';
|
||||
if (invalid === 'suite') entry.suite = 'Unrelated suite';
|
||||
if (invalid === 'prompt') entry.prompt += '\n';
|
||||
if (invalid === 'model') entry.model = 'another-model';
|
||||
if (invalid === 'refusal') entry.manual_review.refusal.stop_reason = 'end_turn';
|
||||
if (invalid === 'missing-id') entry.manual_review.refusal.request_id = null;
|
||||
if (invalid === 'missing-response-id') entry.manual_review.refusal.response_id = null;
|
||||
if (invalid === 'output') entry.manual_review.refusal.output_tokens = 1;
|
||||
if (invalid === 'text') entry.manual_review.refusal.text_blocks = 1;
|
||||
if (invalid === 'tokens') entry.manual_review.refusal.input_tokens = -1;
|
||||
if (invalid === 'schema') entry.manual_review.approval.schema_version = 2;
|
||||
expect(isManualReviewEntry(entry)).toBe(false);
|
||||
expect(manualReviewProblem(entry, f.root)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('reconciliation rejects changed source, approval provenance, missing approval and corrupt JSON', () => {
|
||||
const f = fixture();
|
||||
const modified = structuredClone(f.entry);
|
||||
modified.manual_review!.approval.approved_by = 'unapproved-reviewer';
|
||||
expect(manualReviewProblem(modified, f.root)).not.toBeNull();
|
||||
const file = join(f.root, COOKIE_MANUAL_REVIEW_FILE);
|
||||
writeFileSync(file, '{');
|
||||
expect(manualReviewProblem(f.entry, f.root)).not.toBeNull();
|
||||
rmSync(file);
|
||||
expect(manualReviewProblem(f.entry, f.root)).not.toBeNull();
|
||||
writeFileSync(file, JSON.stringify(f.approval));
|
||||
writeFileSync(join(f.root, 'BROWSER.md'), readFileSync(join(f.root, 'BROWSER.md'), 'utf8').replace('Storage stays intact', 'Changed storage stays intact'));
|
||||
expect(manualReviewProblem(f.entry, f.root)).not.toBeNull();
|
||||
expect(isManualReviewEntry(f.entry)).toBe(true);
|
||||
});
|
||||
|
||||
test('a current judge-model override cannot reuse the different approved model', () => {
|
||||
const f = fixture();
|
||||
const original = process.env.GSTACK_EVAL_MODEL_JUDGE;
|
||||
try {
|
||||
process.env.GSTACK_EVAL_MODEL_JUDGE = 'synthetic-unapproved-model';
|
||||
expect(manualReviewProblem(f.entry, f.root)).toBe('Manual review model is not the current judge model');
|
||||
expect(isManualReviewEntry(f.entry)).toBe(true);
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.GSTACK_EVAL_MODEL_JUDGE;
|
||||
else process.env.GSTACK_EVAL_MODEL_JUDGE = original;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createPrecisionLossCandidate } from './helpers/cso-ntfs-fixture';
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ntfs-fixture-'));
|
||||
roots.push(root);
|
||||
return { root, target: path.join(root, 'candidate.json') };
|
||||
}
|
||||
|
||||
test('the actual candidate builder reaches a precision-losing generation in a fresh allocator model', () => {
|
||||
const { root, target } = fixture();
|
||||
const generations = new Map<number, bigint>();
|
||||
let reads = 0;
|
||||
let selectedIdentity: bigint | undefined;
|
||||
const inode = createPrecisionLossCandidate(target, 'owned candidate', file => {
|
||||
reads++;
|
||||
const slot = Number(path.basename(file).split('-').at(-1));
|
||||
const generation = (generations.get(slot) ?? 0n) + 1n;
|
||||
generations.set(slot, generation);
|
||||
const result = (generation << 48n) + BigInt(101 + slot);
|
||||
if (result > BigInt(Number.MAX_SAFE_INTEGER) && String(Number(result)) !== String(result)) {
|
||||
selectedIdentity = fs.lstatSync(file, { bigint: true }).ino;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
expect(inode).toBeGreaterThan(BigInt(Number.MAX_SAFE_INTEGER));
|
||||
expect(String(Number(inode))).not.toBe(String(inode));
|
||||
expect(reads).toBeLessThanOrEqual(1024);
|
||||
expect(fs.lstatSync(target, { bigint: true }).ino).toBe(selectedIdentity);
|
||||
expect(fs.readFileSync(target, 'utf8')).toBe('owned candidate');
|
||||
expect(fs.readdirSync(root)).toEqual(['candidate.json']);
|
||||
});
|
||||
|
||||
test('an unavailable precision-losing ID fails at the original creation bound and leaves no candidate', () => {
|
||||
const { root, target } = fixture();
|
||||
for (const inode of [1n, 2n ** 54n]) {
|
||||
let reads = 0;
|
||||
expect(() => createPrecisionLossCandidate(target, 'owned candidate', () => { reads++; return inode; }))
|
||||
.toThrow('within 1024 file creations');
|
||||
expect(reads).toBe(1024);
|
||||
expect(fs.readdirSync(root)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('existing targets and candidate-inspection failures never overwrite unrelated fixture state', () => {
|
||||
const { root, target } = fixture();
|
||||
fs.writeFileSync(target, 'preserved');
|
||||
expect(() => createPrecisionLossCandidate(target, 'replacement')).toThrow('already exists');
|
||||
expect(fs.readFileSync(target, 'utf8')).toBe('preserved');
|
||||
const next = path.join(root, 'next.json');
|
||||
expect(() => createPrecisionLossCandidate(next, 'owned candidate', () => { throw new Error('inspection failed'); }))
|
||||
.toThrow('inspection failed');
|
||||
expect(fs.readdirSync(root)).toEqual(['candidate.json']);
|
||||
});
|
||||
|
||||
test('a concurrent target and a linked parent are preserved rather than written through', () => {
|
||||
const { root, target } = fixture();
|
||||
expect(() => createPrecisionLossCandidate(target, 'replacement', () => {
|
||||
fs.writeFileSync(target, 'concurrent fixture');
|
||||
return (1n << 54n) + 1n;
|
||||
})).toThrow();
|
||||
expect(fs.readFileSync(target, 'utf8')).toBe('concurrent fixture');
|
||||
const owned = path.join(root, 'owned');
|
||||
const link = path.join(root, 'linked');
|
||||
fs.mkdirSync(owned);
|
||||
fs.symlinkSync(owned, link, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
expect(() => createPrecisionLossCandidate(path.join(link, 'candidate.json'), 'replacement'))
|
||||
.toThrow('must not be linked');
|
||||
expect(fs.readdirSync(owned)).toEqual([]);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createPrecisionLossCandidate } from './helpers/cso-ntfs-fixture';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const windows = process.platform === 'win32';
|
||||
@@ -347,19 +348,11 @@ describe('CSO native Windows build contract', () => {
|
||||
if(i!==2)continue;
|
||||
const token='d'.repeat(32),candidate=path.join(leases,`${token}.json`),decision=path.join(leases,`${token}.decision`);
|
||||
for(const rounded of [false,true]){
|
||||
let selected:string|undefined;
|
||||
for(let batch=0;batch<16&&!selected;batch++){
|
||||
const paths:string[]=[];
|
||||
for(let index=0;index<64;index++){
|
||||
const file=path.join(leases,`fixture-${batch}-${index}`);
|
||||
fs.writeFileSync(file,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n');paths.push(file);
|
||||
const inode=fs.lstatSync(file,{bigint:true}).ino;
|
||||
if(!selected&&inode>BigInt(Number.MAX_SAFE_INTEGER)&&String(Number(inode))!==String(inode))selected=file;
|
||||
}
|
||||
for(const file of paths){if(file===selected)fs.renameSync(file,candidate);else fs.unlinkSync(file);}
|
||||
}
|
||||
expect(selected).toBeDefined();
|
||||
const selected=createPrecisionLossCandidate(candidate,JSON.stringify({pid:2147483647,token,createdAt:0})+'\n');
|
||||
const stat=fs.lstatSync(candidate,{bigint:true}),record={schemaVersion:1,token,kind:'ticket',ticket:'0000000000000001',candidateDev:String(stat.dev),candidateIno:rounded?String(Number(stat.ino)):String(stat.ino),ownerPid:2147483647,ownerCreatedAt:0,publisherPid:2147483647,createdAt:0};
|
||||
expect(stat.ino).toBe(selected);
|
||||
expect(stat.ino).toBeGreaterThan(BigInt(Number.MAX_SAFE_INTEGER));
|
||||
expect(String(Number(stat.ino))).not.toBe(String(stat.ino));
|
||||
fs.writeFileSync(decision,JSON.stringify(record)+'\n');
|
||||
const result=command(['resume',run.runId]);expect(result.status).not.toBe(0);
|
||||
if(rounded){
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('AO completed manual DX handoff preserves report freshness',()=>{
|
||||
expect(E2E_TOUCHFILES[owner]).toContain('test/fixtures/dx-manual-handoff-ao.json');
|
||||
}
|
||||
const arrays=[...Object.values(E2E_TOUCHFILES),...Object.values(LLM_JUDGE_TOUCHFILES),GLOBAL_TOUCHFILES];
|
||||
expect(arrays).toHaveLength(233); // Upstream owners plus eleven shared-code audit/review evals.
|
||||
expect(arrays).toHaveLength(234);
|
||||
for(const values of arrays)for(let i=0;i<values.length;i++)expect(typeof values[i]).toBe('string');
|
||||
});
|
||||
test('exact owned report precedes navigation only, with the current Exit gate recognized',()=>{
|
||||
|
||||
@@ -176,9 +176,9 @@ test('current detach supervision covers the live-census floor', () => {
|
||||
const floor = Math.ceil((Math.ceil(files.length / DEFAULT_JOBS) * DEFAULT_SHARD_TIMEOUT_MS + excess) / 1000 * 1.05);
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(import.meta.dir, '../package.json'), 'utf8'));
|
||||
const configured = Number(pkg.scripts['eval:bg:periodic'].match(/--timeout\s+(\d+)/)[1]);
|
||||
expect(floor).toBe(64995);
|
||||
expect(floor).toBe(65268);
|
||||
expect(configured).toBeGreaterThanOrEqual(floor);
|
||||
expect(pkg.scripts['eval:bg:gate']).toContain('--timeout 33600');
|
||||
expect(pkg.scripts['eval:bg:gate']).toContain('--timeout 33800');
|
||||
});
|
||||
|
||||
for (const jobs of [1, 2, 3]) test(`FIFO bound covers partial durations with ${jobs} workers`, () => {
|
||||
|
||||
@@ -127,7 +127,7 @@ async function mockedObservation(frames: string[], verdict: 'waiting' | 'working
|
||||
expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start);
|
||||
const executable = source.slice(start, end).replace('export async function', 'async function') + '\nreturn runPlanSkillObservation;';
|
||||
const js = new Bun.Transpiler({ loader: 'ts' }).transformSync(executable);
|
||||
let clock = 0, tick = -1, closed = 0, judged = 0;
|
||||
let clock = 0, tick = -1, closed = 0, judged = 0, seedSubmittedAt: number | null = null;
|
||||
const current = () => frames[Math.min(Math.max(tick, 0), frames.length - 1)]!;
|
||||
const args: Record<string, unknown> = {
|
||||
path, process: { cwd: () => '/synthetic-owned' }, Date: { now: () => clock }, randomUUID: () => 'owned',
|
||||
@@ -136,7 +136,7 @@ async function mockedObservation(frames: string[], verdict: 'waiting' | 'working
|
||||
visibleSince: current, rawOutput: current, currentScreen: async () => current(), hermeticConfigDir: null,
|
||||
close: async () => { closed++; } }),
|
||||
createPlanCountSnapshotWriter: () => () => ({}), logPtySnapshot: () => {},
|
||||
submitPlanSeed: async () => {}, PlanSeedTimeout: class extends Error {},
|
||||
submitPlanSeed: async () => { seedSubmittedAt = clock; }, PlanSeedTimeout: class extends Error {},
|
||||
isRejectedSlashCommand: predicates.isRejectedSlashCommand,
|
||||
isProseAUQVisible: predicates.isProseAUQVisible, isPlanReadyVisible: predicates.isPlanReadyVisible,
|
||||
isUnknownSlashCommandVisible: predicates.isUnknownSlashCommandVisible,
|
||||
@@ -149,9 +149,18 @@ async function mockedObservation(frames: string[], verdict: 'waiting' | 'working
|
||||
const obs = await run({ skillName: 'plan-eng-review', timeoutMs: 70000,
|
||||
...(seeded ? { initialPlanContent: '# Plan: Required draft' } : {}) });
|
||||
expect(closed).toBe(1);
|
||||
return { obs, judged };
|
||||
return { obs, judged, seedSubmittedAt };
|
||||
}
|
||||
|
||||
test('seeded preflight checks owned readiness without spending eight seconds before submission', async () => {
|
||||
const seeded = await mockedObservation([gate], 'working');
|
||||
expect(seeded.seedSubmittedAt).toBe(0);
|
||||
expect(seeded.obs.outcome).toBe('plan_ready');
|
||||
const unseeded = await mockedObservation([gate], 'working', false);
|
||||
expect(unseeded.seedSubmittedAt).toBeNull();
|
||||
expect(unseeded.obs.outcome).toBe('plan_ready');
|
||||
});
|
||||
|
||||
for (const [name, current] of [
|
||||
['cursorless approval', gate.replace('❯ ', '')],
|
||||
['partial approval', gate.split('\n').slice(0, -1).join('\n')],
|
||||
|
||||
@@ -29,11 +29,22 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runBin } from './helpers/run-bin';
|
||||
import { selectTests, E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES, GLOBAL_TOUCHFILES } from './helpers/touchfiles';
|
||||
import { manualReviewFixture } from './helpers/manual-judge-review-fixture';
|
||||
import { renderDashboard } from '../scripts/eval-watch';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SCRIPT = (name: string) => path.join(ROOT, 'scripts', name);
|
||||
const SLUG = 'eval-cli-fixture';
|
||||
|
||||
test('eval:watch distinguishes unscored manual acceptance from malformed claims', () => {
|
||||
const manual = manualReviewFixture();
|
||||
const output = renderDashboard(null, { tests: [manual, { ...manual, passed: true }], total_cost_usd: 0 });
|
||||
expect(output).toContain('MANUAL/unscored');
|
||||
expect(output).toContain(manual.manual_review!.approval.approval_url);
|
||||
expect(output).toContain('Manual accepted: 1');
|
||||
expect(output).toContain('✗');
|
||||
});
|
||||
|
||||
let tmpHome: string;
|
||||
let evalDir: string;
|
||||
|
||||
@@ -256,6 +267,22 @@ describe('eval:list CLI (scripts/eval-list.ts)', () => {
|
||||
// ── eval-compare ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('eval:compare CLI (scripts/eval-compare.ts)', () => {
|
||||
test('shows a manual transition without calling it a scored regression', () => {
|
||||
const manual = manualReviewFixture();
|
||||
const prior = writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-01T01:00:00Z',
|
||||
tests: [{ name: manual.name, passed: true }] });
|
||||
const after = writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-02T01:00:00Z',
|
||||
tests: [{ name: manual.name, passed: false }] });
|
||||
const body = JSON.parse(fs.readFileSync(after, 'utf8'));
|
||||
body.tests = [manual]; body.passed = 0; body.failed = 0; body.manual_accepted_tests = 1;
|
||||
fs.writeFileSync(after, JSON.stringify(body));
|
||||
const result = runEvalCli('eval-compare.ts', prior, after);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('PASS → MANUAL');
|
||||
expect(result.stdout).toContain('unscored manual review');
|
||||
expect(result.stdout).not.toContain('REGRESSION:');
|
||||
});
|
||||
|
||||
test('empty eval dir prints the getting-started hint and exits 0', () => {
|
||||
const result = runEvalCli('eval-compare.ts');
|
||||
expect(result.status).toBe(0);
|
||||
@@ -337,6 +364,40 @@ describe('eval:compare CLI (scripts/eval-compare.ts)', () => {
|
||||
// ── eval-summary ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('eval:summary CLI (scripts/eval-summary.ts)', () => {
|
||||
test('reports manual provenance without inventing a scored flake', () => {
|
||||
const manual = manualReviewFixture();
|
||||
writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-01T01:00:00Z',
|
||||
tests: [{ name: manual.name, passed: true }] });
|
||||
const accepted = writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-02T01:00:00Z',
|
||||
tests: [{ name: manual.name, passed: false }] });
|
||||
const body = JSON.parse(fs.readFileSync(accepted, 'utf8'));
|
||||
body.tests = [manual]; body.passed = 0; body.failed = 0; body.manual_accepted_tests = 1;
|
||||
fs.writeFileSync(accepted, JSON.stringify(body));
|
||||
const result = runEvalCli('eval-summary.ts');
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Manual accepted: 1 unscored provider refusal');
|
||||
expect(result.stdout).toContain(manual.manual_review!.approval.approval_url);
|
||||
expect(result.stdout).not.toContain('Flaky tests');
|
||||
});
|
||||
|
||||
test('retried manual claims remain failed in summary history', () => {
|
||||
const manual = manualReviewFixture();
|
||||
writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-01T01:00:00Z',
|
||||
tests: [{ name: manual.name, passed: true }] });
|
||||
const attempted = writeRun(evalDir, { tier: 'llm-judge', timestamp: '2026-01-02T01:00:00Z',
|
||||
tests: [{ name: manual.name, passed: false }] });
|
||||
const body = JSON.parse(fs.readFileSync(attempted, 'utf8'));
|
||||
const { manual_review: _receipt, ...ordinary } = manual;
|
||||
body.tests = [{ ...ordinary, attempt: 1 }, { ...manual, attempt: 2 }];
|
||||
body.total_tests = 2; body.passed = 0; body.failed = 2;
|
||||
fs.writeFileSync(attempted, JSON.stringify(body));
|
||||
const result = runEvalCli('eval-summary.ts');
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).not.toContain('Manual accepted:');
|
||||
expect(result.stdout).toContain('Flaky tests (1):');
|
||||
expect(result.stdout).toContain(`llm-judge:${manual.name}`);
|
||||
});
|
||||
|
||||
test('empty eval dir prints the getting-started hint and exits 0', () => {
|
||||
const result = runEvalCli('eval-summary.ts');
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
@@ -10,7 +10,9 @@ import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { aggregate, collectEvalFiles } from '../scripts/eval-flake-rank';
|
||||
import { manualReviewFixture } from './helpers/manual-judge-review-fixture';
|
||||
|
||||
const entry = (name: string, passed: boolean, attempt: number) => ({
|
||||
name, suite: 's', tier: 'e2e', passed, attempt, duration_ms: 1000, cost_usd: 0.1,
|
||||
@@ -24,6 +26,33 @@ const run = (tests: object[], extra: object = {}) => JSON.stringify({
|
||||
});
|
||||
|
||||
describe('eval-flake-rank aggregate', () => {
|
||||
test('manual acceptance is visible but not a scored failure or retried pass', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flakerank-manual-'));
|
||||
const manual = manualReviewFixture();
|
||||
const { manual_review: _receipt, ...ordinary } = manual;
|
||||
fs.writeFileSync(path.join(dir, 'prior.json'), run([{ ...ordinary, passed: true, exit_reason: 'success',
|
||||
judge_scores: { clarity: 4, completeness: 3, actionability: 4 } }]));
|
||||
fs.writeFileSync(path.join(dir, 'accepted.json'), run([manual]));
|
||||
const series = aggregate(collectEvalFiles(dir)).get(manual.name);
|
||||
expect(series).toMatchObject({ runs: 1, passes: 1, fails: 0, manualAccepted: 1,
|
||||
retriedPasses: 0, totalAttempts: 2 });
|
||||
const display = spawnSync(process.execPath, [path.resolve(import.meta.dir, '../scripts/eval-flake-rank.ts'), '--dir', dir],
|
||||
{ encoding: 'utf8', timeout: 10_000 });
|
||||
expect(display.status, display.stderr).toBe(0);
|
||||
expect(display.stdout).toContain('fails/runs manual');
|
||||
expect(display.stdout).toContain('0/1');
|
||||
expect(display.stdout).toContain(manual.name);
|
||||
fs.writeFileSync(path.join(dir, 'invalid-retry.json'), run([
|
||||
{ ...ordinary, attempt: 1 }, { ...manual, attempt: 2 },
|
||||
]));
|
||||
expect(aggregate(collectEvalFiles(dir)).get(manual.name)).toMatchObject({ runs: 2, passes: 1,
|
||||
fails: 1, manualAccepted: 1, retriedPasses: 0 });
|
||||
fs.writeFileSync(path.join(dir, 'invalid-pass.json'), run([{ ...manual, passed: true }]));
|
||||
expect(aggregate(collectEvalFiles(dir)).get(manual.name)).toMatchObject({ runs: 3, passes: 1,
|
||||
fails: 2, manualAccepted: 1, retriedPasses: 0 });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('final attempt decides; retried pass counts as retriedPass, not a fail', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flakerank-'));
|
||||
fs.writeFileSync(path.join(dir, 'run1.json'), run([
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { manualReviewFixture } from './helpers/manual-judge-review-fixture';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
@@ -75,6 +76,25 @@ function runEvalList(...args: string[]): { stdout: string; stderr: string; statu
|
||||
}
|
||||
|
||||
describe('eval:list CLI', () => {
|
||||
test('labels validated manual acceptance as unscored and rejects malformed pass claims', () => {
|
||||
const manual = manualReviewFixture();
|
||||
const dir = path.join(tmpHome, '.gstack-dev', 'evals');
|
||||
const body = (entry: typeof manual, timestamp: string) => ({
|
||||
schema_version: 2, version: '1.90.0', branch: 'main', git_sha: 'fixture', timestamp,
|
||||
tier: 'llm-judge', total_tests: 1, passed: 0, failed: 0, total_cost_usd: 0,
|
||||
total_duration_ms: 1, tests: [entry],
|
||||
});
|
||||
fs.writeFileSync(path.join(dir, 'manual.json'), JSON.stringify(body(manual, '2026-05-24T03:00:00Z')));
|
||||
fs.writeFileSync(path.join(dir, 'invalid.json'), JSON.stringify(body({ ...manual, passed: true }, '2026-05-24T04:00:00Z')));
|
||||
const result = runEvalList('--limit', '2');
|
||||
expect(result.status).toBe(0);
|
||||
const lines = result.stdout.split('\n');
|
||||
expect(lines.find(line => line.includes('03:00'))).toContain('MANUAL/unscored');
|
||||
expect(lines.find(line => line.includes('03:00'))).toContain(manual.manual_review!.approval.approval_url);
|
||||
expect(lines.find(line => line.includes('04:00'))).toContain('0/1');
|
||||
expect(lines.find(line => line.includes('04:00'))).not.toContain('MANUAL');
|
||||
});
|
||||
|
||||
test('limits displayed eval runs with a valid positive integer', () => {
|
||||
const result = runEvalList('--limit', '1');
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"sourceRun": 36036582724,
|
||||
"sourceRevision": "0d455d385272fee9fcd873951605e2cf46d8757c",
|
||||
"cases": [
|
||||
{
|
||||
"attempt": 1,
|
||||
"input": {
|
||||
"questions": [
|
||||
{
|
||||
"question": "Core checklist pass: no defects. 1 optional advisory needs your input (prior Skip could NOT be reused — see note).\n\n1. [ADVISORY] src/retry-worker.ts:2 — retrySeconds is re-implemented byte-for-byte identical to lib/retry-after.ts:2–15 (a tested helper already used by src/scheduler.ts:1). src/retry-route.ts:2–15 carries the same copy. Three copies of the 3600s-ceiling / date-parse / fallback contract will drift.\n Fix: worker → `export { retrySeconds } from '../lib/retry-after';` (this is exactly what HEAD had). Same one-liner for the route. Existing test/retry-after.test.ts covers the contract; no new tests needed. Est. impl: −28 / +2 = −26 lines; total −30 / +2 = −28 (comment lines included). Blast radius: a helper bug now hits worker + route + scheduler together — but they already run identical logic.\n\nNOTE: src/retry-route.ts is flagged skip-worktree in the index and its on-disk bytes differ from the tracked blob (an extra trailing comment). git diff/status hide this. Any edit to that file will also be invisible to Git until the flag is cleared.\n\nRECOMMENDATION: A — reuse the helper in both files; the worker change is inside this diff and the route copy is the same duplication.",
|
||||
"header": "Advisory #1",
|
||||
"options": [
|
||||
{ "label": "A) Fix both (Recommended)", "description": "Replace the duplicated function in src/retry-worker.ts AND src/retry-route.ts with a re-export from lib/retry-after.ts." },
|
||||
{ "label": "A') Fix worker only", "description": "Re-export in src/retry-worker.ts only (the changed file in this diff); leave src/retry-route.ts as-is." },
|
||||
{ "label": "B) Skip", "description": "Keep the duplicated implementation. Recorded as an explicit skipped advisory (route excluded from reusable coverage)." }
|
||||
],
|
||||
"multiSelect": false
|
||||
},
|
||||
{
|
||||
"question": "Separately: should I clear the skip-worktree flag on src/retry-route.ts (`git update-index --no-skip-worktree`) so its hidden local change becomes visible to git status/diff? This changes only the local index flag, not file contents.",
|
||||
"header": "skip-worktree",
|
||||
"options": [
|
||||
{ "label": "Clear the flag (Recommended)", "description": "Make the hidden modification to src/retry-route.ts visible so it can be reviewed/committed normally." },
|
||||
{ "label": "Leave it", "description": "Keep the flag; the route change stays hidden from Git and is reported only in this review summary." }
|
||||
],
|
||||
"multiSelect": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"attempt": 2,
|
||||
"input": {
|
||||
"questions": [
|
||||
{
|
||||
"question": "1. [ADVISORY] src/retry-worker.ts:2 — This diff replaces the worker's `export { retrySeconds } from '../lib/retry-after'` (HEAD) with a verbatim 14-line copy of the helper. Verified callers: src/retry-worker.ts:2-15 (changed anchor) and src/retry-route.ts:2-15 (authored, first-party) both duplicate lib/retry-after.ts:2-15 `retrySeconds` byte-for-byte — same signature, fallback=5 default, 3600s cap, negative→0, unsafe-int→fallback. The helper is already tested (test/retry-after.test.ts 'retry parser contract') and in established use (src/scheduler.ts:1-2). No preserved differences exist; same language/repo/relative-import layout. Smallest adoption: re-export from lib in both files. Estimated implementation: removed 30, added 2, saved 28 (worker −15/+1, route −15/+1). Total incl. tests: same — existing test already covers the contract; grows by ~5-8 lines only if you want an export-identity integration test. Shared-failure blast radius: unchanged (all three already run identical logic; a bug would just become fixable in one place). Note: src/retry-route.ts is flagged assume-unchanged in your index, so any edit to it is invisible to `git diff`/`git commit -a` until the flag is cleared. RECOMMENDATION: A — reuse the proven helper in both callers. Which do you want?",
|
||||
"header": "Shared-libs",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{ "label": "A) Fix both callers (Recommended)", "description": "Restore the one-line re-export in src/retry-worker.ts and replace the duplicate body in src/retry-route.ts with the same re-export. Net −28 lines." },
|
||||
{ "label": "B) Fix worker only", "description": "Restore the re-export in src/retry-worker.ts (the file this diff actually changes). Leave src/retry-route.ts's copy alone. Net −14 lines." },
|
||||
{ "label": "C) Skip", "description": "Keep the duplicated implementation in the worker. Recorded as an explicit skip for this advisory." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "2. Git state note (not a code defect): src/retry-route.ts has the assume-unchanged bit set (`git ls-files -v` shows `h`), and its working-tree bytes differ from the index — a trailing comment was appended that git diff/status hide. Should I clear the flag with `git update-index --no-assume-unchanged src/retry-route.ts` so the file's real state is visible to Git (and to any edit from question 1)?",
|
||||
"header": "Index flag",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{ "label": "Yes, clear the flag (Recommended)", "description": "Run `git update-index --no-assume-unchanged src/retry-route.ts`. The hidden trailing-comment change will then appear as a normal modification." },
|
||||
{ "label": "No, leave it", "description": "Keep the local index flag as is. I will still report the hidden change and exclude the route from reusable snapshot coverage." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"regressions": [
|
||||
{
|
||||
"sourceRun": 36044212977,
|
||||
"sourceRevision": "70b023b3424453207c95e5b05298955f3094b50f",
|
||||
"attempt": 1,
|
||||
"toolUseId": "toolu_01DLWrTd85Uf3PA4gagJCFrW",
|
||||
"input": {
|
||||
"questions": [
|
||||
{
|
||||
"question": "Pre-Landing Review: 0 issues (0 critical, 0 informational). 1 [ADVISORY] needs your input — the prior 'skipped' decision could not be reused because src/retry-route.ts is skip-worktree with hidden raw changes.\n\n1. [ADVISORY] src/retry-worker.ts:2 — The diff replaces `export { retrySeconds } from '../lib/retry-after'` with a verbatim 15-line copy of the tested helper; src/retry-route.ts:2-15 carries the same copy. Helper: lib/retry-after.ts `retrySeconds` (already used by src/scheduler.ts, covered by test/retry-after.test.ts). Migration: worker → one-line re-export (revert to HEAD form); route → same one-liner. Estimated implementation: −30 / +2 = 28 lines saved; total change same (existing test already covers the contract, no new tests needed). Shared-failure blast radius: scheduler, worker, route — all three copies are identical today, so consolidation removes drift risk without new behavior. Caveat: src/retry-route.ts has the skip-worktree index flag, so an edit there stays invisible to git until the flag is cleared (`git update-index --no-skip-worktree`).\n\nRECOMMENDATION: A — reuse the existing tested helper in both files.\n\nHow should I handle this advisory?",
|
||||
"header": "Advisory",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "A) Fix both (Recommended)",
|
||||
"description": "Replace the copies in src/retry-worker.ts and src/retry-route.ts with `export { retrySeconds } from '../lib/retry-after';`. I will NOT change the skip-worktree flag; you decide how to stage the route file."
|
||||
},
|
||||
{
|
||||
"label": "A2) Fix worker only",
|
||||
"description": "Only revert the changed file in this diff (src/retry-worker.ts) to the one-line re-export. Leave src/retry-route.ts untouched."
|
||||
},
|
||||
{
|
||||
"label": "B) Skip",
|
||||
"description": "Keep the duplicated copies. Recorded as an explicit skip; reuse of this skip will still require revalidation while src/retry-route.ts is skip-worktree."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"sourceRun": 36080890009,
|
||||
"sourceRevision": "5460ce0847574aadfa6cbdd0b9545d2caf9935da",
|
||||
"attempt": 1,
|
||||
"toolUseId": "toolu_01Up8B1FR4bhkmRxyhbcAqhY",
|
||||
"input": {
|
||||
"questions": [
|
||||
{
|
||||
"question": "Pre-Landing Review: 0 issues (0 critical, 0 informational) + 1 advisory.\n\n1. [ADVISORY] (confidence 10/10, core + maintainability, MULTI-SPECIALIST CONFIRMED) src/retry-worker.ts:2 — This diff replaces the re-export of the tested helper with a verbatim copy of retrySeconds; src/retry-route.ts:2-15 carries the same copy. Both bodies are byte-identical to lib/retry-after.ts:2-15 (already used by src/scheduler.ts, covered by test/retry-after.test.ts, 5 assertions passing).\n Fix: `export { retrySeconds } from '../lib/retry-after';` in each caller. Implementation: worker -15/+1, route -15/+1 → ~28 lines saved; no new tests needed (existing contract test covers the shared helper); shared-failure blast radius = scheduler, worker, route all depend on one parser (they already share the contract by copy).\n\nNote: your prior review skipped this same advisory, but src/retry-route.ts has changed on disk since then behind an assume-unchanged index flag, so that decision could not be reused.\n\nRECOMMENDATION: Fix both callers — the copies are identical, the helper is proven, and the diff itself undoes prior reuse.\n\nHow do you want to handle the advisory?",
|
||||
"header": "Advisory",
|
||||
"options": [
|
||||
{
|
||||
"label": "Fix both callers (Recommended)",
|
||||
"description": "Restore the re-export in src/retry-worker.ts and replace the copy in src/retry-route.ts with the same re-export (keeping its trailing comment)."
|
||||
},
|
||||
{
|
||||
"label": "Fix worker only",
|
||||
"description": "Restore the re-export in src/retry-worker.ts (the file changed in this diff); leave src/retry-route.ts as-is."
|
||||
},
|
||||
{
|
||||
"label": "Skip",
|
||||
"description": "Keep the duplicated implementation; record the advisory as skipped."
|
||||
}
|
||||
],
|
||||
"multiSelect": false
|
||||
},
|
||||
{
|
||||
"question": "src/retry-route.ts is marked assume-unchanged in the index (`git ls-files -v` → `h`), so its uncommitted on-disk edit (trailing comment, line 17) is invisible to `git status`/`git diff` and to the review snapshot fingerprint. Should I clear that flag so the change becomes visible and reviewable?",
|
||||
"header": "Git index",
|
||||
"options": [
|
||||
{
|
||||
"label": "Clear the flag (Recommended)",
|
||||
"description": "Run `git update-index --no-assume-unchanged src/retry-route.ts` so Git tracks the working-tree change normally."
|
||||
},
|
||||
{
|
||||
"label": "Leave it",
|
||||
"description": "Keep the assume-unchanged bit; the path will be excluded from snapshot coverage in the persisted record."
|
||||
}
|
||||
],
|
||||
"multiSelect": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4605,7 +4605,7 @@ export async function runPlanSkillObservation(opts: {
|
||||
};
|
||||
// Entry deadline → boot → owned paste/receipt/ack → slash → observation.
|
||||
// Setup consumes the existing case budget; cleanup has its separate grace.
|
||||
await Bun.sleep(Math.min(8000, Math.max(0, deadlineAt - Date.now())));
|
||||
if (!opts.initialPlanContent) await Bun.sleep(Math.min(8000, Math.max(0, deadlineAt - Date.now())));
|
||||
if (opts.initialPlanContent) {
|
||||
const seed = `Keep this draft plan as context. Briefly acknowledge receipt, then wait for my next message containing a slash command. Do not start the review or call tools yet.\n\n${opts.initialPlanContent}`;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { buildWorkflowJudgePrompt, type WorkflowJudgeFile, type WorkflowJudgeInput } from './workflow-judge-input';
|
||||
|
||||
export const COOKIE_WORKFLOW_JUDGE = {
|
||||
judgeContext: 'a fallback-browser cookie import workflow',
|
||||
judgeGoal: 'how to select an authorized source browser, profile, and domain without guessing an account; configure optional authentication verification before mutation; obtain explicit consent for precisely scoped storage reset; distinguish copied cookies from positive sign-in evidence; and recover within the documented platform and privacy boundaries',
|
||||
thresholds: { clarity: 4, completeness: 3, actionability: 4 },
|
||||
} as const;
|
||||
|
||||
export function buildCookieWorkflowJudgeInput(root: string): WorkflowJudgeInput & { prompt: string; sha256: string } {
|
||||
const files: WorkflowJudgeFile[] = [
|
||||
{ path: 'setup-browser-cookies/SKILL.md', kind: 'entrypoint', start: '# Setup Browser Cookies', end: null },
|
||||
{ path: 'BROWSER.md', kind: 'section', start: '#### Choosing a source and checking sign-in', end: '### Tabs + frames' },
|
||||
].map(spec => {
|
||||
const source = readFileSync(join(root, spec.path), 'utf8');
|
||||
const locate = (marker: string): number => {
|
||||
const matches = [...source.matchAll(new RegExp(`^${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\r?$`, 'gm'))];
|
||||
if (matches.length !== 1) throw new Error(`${spec.path}: expected exactly one marker ${JSON.stringify(marker)}, found ${matches.length}`);
|
||||
return matches[0].index!;
|
||||
};
|
||||
const start = locate(spec.start);
|
||||
const end = spec.end === null ? source.length : locate(spec.end);
|
||||
if (end <= start || !source.slice(start + spec.start.length, end).trim()) {
|
||||
throw new Error(`${spec.path}: empty or reversed cookie workflow excerpt`);
|
||||
}
|
||||
return {
|
||||
path: spec.path,
|
||||
kind: spec.kind as WorkflowJudgeFile['kind'],
|
||||
content: source.slice(start, end),
|
||||
startLine: source.slice(0, start).split('\n').length,
|
||||
endLine: source.slice(0, end - 1).split('\n').length,
|
||||
};
|
||||
});
|
||||
if (!files[0].content.includes('`BROWSER.md`') || !files[0].content.includes('**Choosing a source and checking sign-in**')) {
|
||||
throw new Error('setup-browser-cookies/SKILL.md: missing cookie reference link');
|
||||
}
|
||||
const text = [
|
||||
'SKILL.md is the entry point. BROWSER.md supplies the exact referenced cookie section, not an additional execution step.',
|
||||
...files.map(file => [
|
||||
`--- BEGIN FILE ${JSON.stringify(file.path)} (lines ${file.startLine}-${file.endLine}; ${file.kind}) ---`,
|
||||
file.content,
|
||||
`--- END FILE ${JSON.stringify(file.path)} ---`,
|
||||
].join('\n')),
|
||||
].join('\n\n');
|
||||
const input = { files, text };
|
||||
const prompt = buildWorkflowJudgePrompt(COOKIE_WORKFLOW_JUDGE, input);
|
||||
return { ...input, prompt, sha256: createHash('sha256').update(prompt).digest('hex') };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { buildCookieWorkflowJudgeInput, COOKIE_WORKFLOW_JUDGE } from './cookie-workflow-judge-input';
|
||||
import type { JudgeRefusalEvidence } from './llm-judge';
|
||||
import { DEFAULT_JUDGE_MAX_TOKENS, resolveEvalModel } from '../../lib/eval-model';
|
||||
|
||||
export const COOKIE_MANUAL_REVIEW_FILE = '.github/cookie-workflow-manual-review.json';
|
||||
const CASE = 'setup-browser-cookies/SKILL.md workflow';
|
||||
type Thresholds = { clarity: number; completeness: number; actionability: number };
|
||||
|
||||
export interface CookieManualApproval {
|
||||
schema_version: 1;
|
||||
test_name: typeof CASE;
|
||||
prompt_sha256: string;
|
||||
prompt_bytes: number;
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
thresholds: Thresholds;
|
||||
approved_by: string;
|
||||
approved_at: string;
|
||||
approval_url: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ManualJudgeReview {
|
||||
approval: CookieManualApproval;
|
||||
refusal: JudgeRefusalEvidence;
|
||||
}
|
||||
|
||||
const object = (value: unknown): value is Record<string, any> => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const nonempty = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
|
||||
const dimensions = ['clarity', 'completeness', 'actionability'] as const;
|
||||
const sameThresholds = (a: Thresholds, b: Thresholds) => dimensions.every(key => a[key] === b[key]);
|
||||
|
||||
function validApproval(value: unknown): value is CookieManualApproval {
|
||||
return object(value) && value.schema_version === 1 && value.test_name === CASE
|
||||
&& typeof value.prompt_sha256 === 'string' && /^[a-f0-9]{64}$/.test(value.prompt_sha256)
|
||||
&& Number.isSafeInteger(value.prompt_bytes) && value.prompt_bytes > 0
|
||||
&& nonempty(value.model) && Number.isSafeInteger(value.max_tokens) && value.max_tokens > 0
|
||||
&& object(value.thresholds) && dimensions.every(key => Number.isInteger(value.thresholds[key]) && value.thresholds[key] >= 1 && value.thresholds[key] <= 5)
|
||||
&& nonempty(value.approved_by) && typeof value.approved_at === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value.approved_at)
|
||||
&& typeof value.approval_url === 'string' && /^https:\/\/github\.com\/garrytan\/gstack\/pull\/2964#issuecomment-\d+$/.test(value.approval_url)
|
||||
&& nonempty(value.reason);
|
||||
}
|
||||
|
||||
function validRefusal(value: unknown, model: string): value is JudgeRefusalEvidence {
|
||||
return object(value) && value.stop_reason === 'refusal' && value.model === model
|
||||
&& nonempty(value.response_id) && nonempty(value.request_id)
|
||||
&& Number.isSafeInteger(value.input_tokens) && value.input_tokens >= 0
|
||||
&& value.output_tokens === 0 && value.text_blocks === 0;
|
||||
}
|
||||
|
||||
export function isManualReviewEntry(entry: unknown): boolean {
|
||||
if (!object(entry) || entry.name !== CASE || entry.suite !== 'Cookie setup workflow quality'
|
||||
|| entry.tier !== 'llm-judge' || entry.passed !== false
|
||||
|| entry.attempt !== 1
|
||||
|| entry.execution !== 'executed' || entry.exit_reason !== 'provider_refusal'
|
||||
|| entry.judge_scores !== undefined || entry.judge_reasoning !== undefined || entry.reused_from !== undefined
|
||||
|| typeof entry.prompt !== 'string' || !object(entry.manual_review)) return false;
|
||||
const { approval, refusal } = entry.manual_review;
|
||||
return validApproval(approval) && entry.model === approval.model && validRefusal(refusal, approval.model)
|
||||
&& Buffer.byteLength(entry.prompt) === approval.prompt_bytes
|
||||
&& createHash('sha256').update(entry.prompt).digest('hex') === approval.prompt_sha256;
|
||||
}
|
||||
|
||||
export function getCookieWorkflowManualReview(root: string, request: {
|
||||
testName: string; prompt: string; model: string; maxTokens: number; thresholds: Thresholds; attempt: number;
|
||||
}, refusal: JudgeRefusalEvidence): ManualJudgeReview | null {
|
||||
if (request.testName !== CASE || request.attempt !== 1 || !validRefusal(refusal, request.model)) return null;
|
||||
let approval: unknown;
|
||||
try { approval = JSON.parse(readFileSync(join(root, COOKIE_MANUAL_REVIEW_FILE), 'utf8')); }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
if (!validApproval(approval)) throw new Error('Invalid cookie workflow manual-review approval');
|
||||
const current = buildCookieWorkflowJudgeInput(root);
|
||||
if (request.prompt !== current.prompt || current.sha256 !== approval.prompt_sha256
|
||||
|| Buffer.byteLength(request.prompt) !== approval.prompt_bytes || request.model !== approval.model
|
||||
|| request.maxTokens !== approval.max_tokens || request.maxTokens !== DEFAULT_JUDGE_MAX_TOKENS
|
||||
|| !sameThresholds(request.thresholds, approval.thresholds)
|
||||
|| !sameThresholds(request.thresholds, COOKIE_WORKFLOW_JUDGE.thresholds)) return null;
|
||||
return { approval, refusal };
|
||||
}
|
||||
|
||||
export function manualReviewProblem(entry: unknown, root: string): string | null {
|
||||
if (!object(entry) || !Object.hasOwn(entry, 'manual_review')) return null;
|
||||
if (!isManualReviewEntry(entry)) return 'Malformed manual-review claim';
|
||||
if (entry.model !== resolveEvalModel('judge')) return 'Manual review model is not the current judge model';
|
||||
try {
|
||||
const claimed = entry.manual_review as ManualJudgeReview;
|
||||
const verified = getCookieWorkflowManualReview(root, { testName: entry.name, prompt: entry.prompt,
|
||||
model: entry.model, maxTokens: claimed.approval.max_tokens, thresholds: claimed.approval.thresholds,
|
||||
attempt: entry.attempt }, claimed.refusal);
|
||||
if (!verified || Object.entries(verified.approval).some(([key, value]) => key === 'thresholds'
|
||||
? !sameThresholds(value as Thresholds, claimed.approval.thresholds)
|
||||
: value !== claimed.approval[key as keyof CookieManualApproval])) return 'Manual review does not match current source and approval';
|
||||
return null;
|
||||
} catch { return 'Manual review approval or current input is unavailable or invalid'; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export function createPrecisionLossCandidate(
|
||||
target: string,
|
||||
contents: string,
|
||||
readFileId: (file: string) => bigint = file => fs.lstatSync(file, { bigint: true }).ino,
|
||||
): bigint {
|
||||
if (fs.existsSync(target)) throw new Error('Precision fixture target already exists');
|
||||
const directory = path.dirname(target);
|
||||
if (fs.lstatSync(directory).isSymbolicLink()) throw new Error('Precision fixture directory must not be linked');
|
||||
const pool = fs.mkdtempSync(path.join(directory, 'ntfs-id-'));
|
||||
let aboveSafe = 0;
|
||||
try {
|
||||
for (let batch = 0; batch < 512; batch++) {
|
||||
const files: string[] = [];
|
||||
for (let slot = 0; slot < 2; slot++) {
|
||||
const file = path.join(pool, `candidate-${slot}`);
|
||||
fs.writeFileSync(file, contents, { flag: 'wx' });
|
||||
files.push(file);
|
||||
const inode = readFileId(file);
|
||||
if (inode > BigInt(Number.MAX_SAFE_INTEGER)) aboveSafe++;
|
||||
if (inode > BigInt(Number.MAX_SAFE_INTEGER) && String(Number(inode)) !== String(inode)) {
|
||||
fs.linkSync(file, target);
|
||||
return inode;
|
||||
}
|
||||
}
|
||||
for (const file of files) fs.unlinkSync(file);
|
||||
}
|
||||
throw new Error(`No precision-losing NTFS file ID within 1024 file creations (${aboveSafe} above-safe observations)`);
|
||||
} finally {
|
||||
fs.rmSync(pool, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export const FILE_RETRY_BUDGETS = [
|
||||
...[
|
||||
// Fourteen workflow judges include their 10s recording grace; the other
|
||||
// eleven judges retain 120s. Supervise all 25 and the existing one retry.
|
||||
{ file: 'test/skill-llm-eval.test.ts', attemptMs: 14 * (JUDGE_MS + 10_000) + 11 * JUDGE_MS, retries: 1 },
|
||||
{ file: 'test/skill-llm-eval.test.ts', attemptMs: 15 * (JUDGE_MS + 10_000) + 11 * JUDGE_MS, retries: 1 },
|
||||
{ file: 'test/codex-e2e-plan-format.test.ts', attemptMs: 4 * (CAPTURE_LONG_MS + 10_000), retries: 1 },
|
||||
{ file: 'test/skill-e2e-auq-matrix.test.ts', attemptMs: 6 * CAPTURE_MS, retries: 1 },
|
||||
{ file: 'test/skill-e2e-plan-format.test.ts', attemptMs: 4 * (CAPTURE_MS + 10_000), retries: 1 },
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
isPartialEval,
|
||||
listEvalJsonFiles,
|
||||
compareEvalResults,
|
||||
evalEntryOutcome,
|
||||
formatComparison,
|
||||
generateCommentary,
|
||||
judgePassed,
|
||||
} from './eval-store';
|
||||
import type { EvalResult, EvalTestEntry, ComparisonResult } from './eval-store';
|
||||
import { manualReviewFixture } from './manual-judge-review-fixture';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
@@ -77,6 +79,36 @@ async function captureStderr(fn: () => Promise<void>): Promise<string> {
|
||||
// --- EvalCollector tests ---
|
||||
|
||||
describe('EvalCollector', () => {
|
||||
test('manual provider refusal stays unscored and executed; malformed claims stay failed', async () => {
|
||||
const manual = manualReviewFixture();
|
||||
const invalidPass = { ...manual, passed: true };
|
||||
const invalidScore = { ...manual, judge_scores: { clarity: 5 } };
|
||||
expect(evalEntryOutcome(manual)).toBe('manual-review');
|
||||
expect(evalEntryOutcome(invalidPass)).toBe('failed');
|
||||
expect(evalEntryOutcome(invalidScore)).toBe('failed');
|
||||
expect(evalEntryOutcome({ ...manual, manual_review: { ...manual.manual_review, approval: { ...manual.manual_review!.approval,
|
||||
prompt_sha256: '0'.repeat(64) } } })).toBe('failed');
|
||||
const collector = new EvalCollector('llm-judge', tmpDir);
|
||||
collector.addTest(makeEntry({ name: 'ordinary', tier: 'llm-judge' }));
|
||||
collector.addTest(manual);
|
||||
collector.addTest(invalidPass);
|
||||
collector.addTest(invalidScore);
|
||||
const partial: EvalResult = JSON.parse(fs.readFileSync(path.join(tmpDir, '_partial-e2e.json'), 'utf8'));
|
||||
expect(partial).toMatchObject({ passed: 1, failed: 2, manual_accepted_tests: 1,
|
||||
executed_tests: 4, reused_tests: 0 });
|
||||
const output = await captureStderr(async () => { await collector.finalize(); });
|
||||
const result: EvalResult = JSON.parse(fs.readFileSync(fs.readdirSync(tmpDir).map(name => path.join(tmpDir, name))
|
||||
.find(name => name.endsWith('.json') && !path.basename(name).startsWith('_'))!, 'utf8'));
|
||||
expect(result).toMatchObject({ passed: 1, failed: 2, manual_accepted_tests: 1, executed_tests: 4 });
|
||||
expect(result.tests[1]).toMatchObject({ passed: false, execution: 'executed', exit_reason: 'provider_refusal' });
|
||||
expect(result.tests[1].judge_scores).toBeUndefined();
|
||||
expect(output).toContain('MANUAL');
|
||||
expect(output).toContain('1 unscored provider refusal');
|
||||
expect(output).toContain(manual.manual_review!.approval.approval_url);
|
||||
expect(result.tests[2].passed).toBe(true);
|
||||
expect(output).toContain(' FAIL ');
|
||||
});
|
||||
|
||||
test('reused passing evidence preserves origin and remains separate from newly executed attempts', async () => {
|
||||
const collector = new EvalCollector('llm-judge', tmpDir);
|
||||
const reused_from = { input_key: 'a'.repeat(64), run_id: '1234/1', revision: 'b'.repeat(40),
|
||||
@@ -590,6 +622,45 @@ describe('findLatestFinalizedRun', () => {
|
||||
// --- compareEvalResults tests ---
|
||||
|
||||
describe('compareEvalResults', () => {
|
||||
test('manual acceptance is neither a score regression nor a recovery', () => {
|
||||
const manual = manualReviewFixture();
|
||||
const prior = makeResult({ tests: [makeEntry({ name: manual.name, passed: true })] });
|
||||
const accepted = makeResult({ tests: [manual], passed: 0, failed: 0, manual_accepted_tests: 1 });
|
||||
const toManual = compareEvalResults(prior, accepted, 'prior.json', 'accepted.json');
|
||||
expect(toManual).toMatchObject({ improved: 0, regressed: 0, manual_reviewed: 1 });
|
||||
expect(toManual.deltas[0]).toMatchObject({ status_change: 'manual-review', before: { passed: true },
|
||||
after: { passed: false, manual_review: true } });
|
||||
expect(formatComparison(toManual)).toContain('PASS → MANUAL');
|
||||
expect(formatComparison(toManual)).not.toContain('REGRESSION:');
|
||||
const fromManual = compareEvalResults(accepted, prior, 'accepted.json', 'prior.json');
|
||||
expect(fromManual).toMatchObject({ improved: 0, regressed: 0, manual_reviewed: 1 });
|
||||
expect(formatComparison(fromManual)).toContain('MANUAL → PASS');
|
||||
const invalid = makeResult({ tests: [{ ...manual, passed: true }] });
|
||||
expect(compareEvalResults(prior, invalid, 'prior.json', 'invalid.json').regressed).toBe(1);
|
||||
});
|
||||
|
||||
test('manual acceptance followed by a real or malformed failure is a blocking regression', () => {
|
||||
const manual = manualReviewFixture();
|
||||
const accepted = makeResult({ tests: [manual], passed: 0, failed: 0, manual_accepted_tests: 1 });
|
||||
const { manual_review: _receipt, ...ordinary } = manual;
|
||||
for (const after of [
|
||||
{ ...ordinary, exit_reason: 'timeout' },
|
||||
{ ...manual, passed: true },
|
||||
{ ...manual, judge_scores: { clarity: 5 } },
|
||||
]) {
|
||||
const failed = makeResult({ tests: [after] });
|
||||
const comparison = compareEvalResults(accepted, failed, 'accepted.json', 'failed.json');
|
||||
expect(comparison).toMatchObject({ improved: 0, regressed: 1 });
|
||||
expect(comparison.manual_reviewed).toBeUndefined();
|
||||
expect(comparison.deltas[0]).toMatchObject({ status_change: 'regressed', before: { manual_review: true },
|
||||
after: { passed: false } });
|
||||
const output = formatComparison(comparison);
|
||||
expect(output).toContain('MANUAL → FAIL');
|
||||
expect(output).toContain('REGRESSION:');
|
||||
expect(output).toContain('blocking failure');
|
||||
}
|
||||
});
|
||||
|
||||
test('detects improved/regressed/unchanged per test', () => {
|
||||
const before = makeResult({
|
||||
tests: [
|
||||
|
||||
+57
-18
@@ -12,6 +12,8 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { isManualReviewEntry } from './cookie-workflow-manual-review';
|
||||
import type { ManualJudgeReview } from './cookie-workflow-manual-review';
|
||||
|
||||
// v2: EvalTestEntry.harvest gains optional {insertions, deletions, net} and
|
||||
// may be explicitly null (arm-benchmark harvest-failure taxonomy). Readers
|
||||
@@ -66,6 +68,7 @@ export interface EvalTestEntry {
|
||||
/** Absent in older records means executed; reuse is never a new model run. */
|
||||
execution?: 'executed' | 'reused';
|
||||
reused_from?: { input_key: string; run_id: string; revision: string; completed_at: string };
|
||||
manual_review?: ManualJudgeReview;
|
||||
/** 1-based record attempt for this name in this run. bun's --retry leaves
|
||||
* retried passes INVISIBLE in its text output (a fail→pass prints no
|
||||
* (fail) line and recaps as a clean pass — probed on 1.3.10), so the ONLY
|
||||
@@ -120,6 +123,14 @@ export interface EvalTestEntry {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function evalEntryOutcome(entry: unknown): 'passed' | 'failed' | 'manual-review' {
|
||||
if (!entry || typeof entry !== 'object') return 'failed';
|
||||
if ('manual_review' in entry) return Object.hasOwn(entry, 'manual_review') && isManualReviewEntry(entry) ? 'manual-review' : 'failed';
|
||||
const result = entry as EvalTestEntry;
|
||||
if (result.execution !== undefined && result.execution !== 'executed' && result.execution !== 'reused') return 'failed';
|
||||
return result.passed === true ? 'passed' : 'failed';
|
||||
}
|
||||
|
||||
export interface EvalResult {
|
||||
schema_version: number;
|
||||
version: string;
|
||||
@@ -135,6 +146,7 @@ export interface EvalResult {
|
||||
total_tests: number;
|
||||
executed_tests?: number;
|
||||
reused_tests?: number;
|
||||
manual_accepted_tests?: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
total_cost_usd: number;
|
||||
@@ -154,10 +166,10 @@ export interface EvalResult {
|
||||
export interface TestDelta {
|
||||
name: string;
|
||||
before: { passed: boolean; cost_usd: number; turns_used?: number; duration_ms?: number;
|
||||
detection_rate?: number; tool_summary?: Record<string, number> };
|
||||
detection_rate?: number; tool_summary?: Record<string, number>; manual_review?: boolean };
|
||||
after: { passed: boolean; cost_usd: number; turns_used?: number; duration_ms?: number;
|
||||
detection_rate?: number; tool_summary?: Record<string, number> };
|
||||
status_change: 'improved' | 'regressed' | 'unchanged';
|
||||
detection_rate?: number; tool_summary?: Record<string, number>; manual_review?: boolean };
|
||||
status_change: 'improved' | 'regressed' | 'unchanged' | 'manual-review';
|
||||
}
|
||||
|
||||
export interface ComparisonResult {
|
||||
@@ -173,6 +185,7 @@ export interface ComparisonResult {
|
||||
improved: number;
|
||||
regressed: number;
|
||||
unchanged: number;
|
||||
manual_reviewed?: number;
|
||||
tool_count_before: number;
|
||||
tool_count_after: number;
|
||||
/** After-tests that had a same-named entry in the before run. 0 = nothing was
|
||||
@@ -388,6 +401,7 @@ export function compareEvalResults(
|
||||
): ComparisonResult {
|
||||
const deltas: TestDelta[] = [];
|
||||
let improved = 0, regressed = 0, unchanged = 0;
|
||||
let manualReviewed = 0;
|
||||
let toolCountBefore = 0, toolCountAfter = 0;
|
||||
let matched = 0;
|
||||
|
||||
@@ -409,33 +423,40 @@ export function compareEvalResults(
|
||||
toolCountAfter += afterToolCount;
|
||||
|
||||
let statusChange: TestDelta['status_change'] = 'unchanged';
|
||||
const beforeManual = beforeTest !== undefined && evalEntryOutcome(beforeTest) === 'manual-review';
|
||||
const afterOutcome = evalEntryOutcome(afterTest);
|
||||
const afterManual = afterOutcome === 'manual-review';
|
||||
if (beforeTest) {
|
||||
matched++;
|
||||
if (!beforeTest.passed && afterTest.passed) { statusChange = 'improved'; improved++; }
|
||||
else if (beforeTest.passed && !afterTest.passed) { statusChange = 'regressed'; regressed++; }
|
||||
if (beforeManual && afterOutcome === 'failed') { statusChange = 'regressed'; regressed++; }
|
||||
else if (beforeManual || afterManual) { statusChange = 'manual-review'; manualReviewed++; }
|
||||
else if (evalEntryOutcome(beforeTest) === 'failed' && evalEntryOutcome(afterTest) === 'passed') { statusChange = 'improved'; improved++; }
|
||||
else if (evalEntryOutcome(beforeTest) === 'passed' && evalEntryOutcome(afterTest) === 'failed') { statusChange = 'regressed'; regressed++; }
|
||||
else { unchanged++; }
|
||||
} else {
|
||||
// New test — treat as unchanged (no prior data)
|
||||
unchanged++;
|
||||
if (afterManual) { statusChange = 'manual-review'; manualReviewed++; }
|
||||
else unchanged++;
|
||||
}
|
||||
|
||||
deltas.push({
|
||||
name: afterTest.name,
|
||||
before: {
|
||||
passed: beforeTest?.passed ?? false,
|
||||
passed: beforeTest !== undefined && evalEntryOutcome(beforeTest) === 'passed',
|
||||
cost_usd: beforeTest?.cost_usd ?? 0,
|
||||
turns_used: beforeTest?.turns_used,
|
||||
duration_ms: beforeTest?.duration_ms,
|
||||
detection_rate: beforeTest?.detection_rate,
|
||||
tool_summary: beforeToolSummary,
|
||||
...(beforeManual ? { manual_review: true } : {}),
|
||||
},
|
||||
after: {
|
||||
passed: afterTest.passed,
|
||||
passed: afterOutcome === 'passed',
|
||||
cost_usd: afterTest.cost_usd,
|
||||
turns_used: afterTest.turns_used,
|
||||
duration_ms: afterTest.duration_ms,
|
||||
detection_rate: afterTest.detection_rate,
|
||||
tool_summary: afterToolSummary,
|
||||
...(afterManual ? { manual_review: true } : {}),
|
||||
},
|
||||
status_change: statusChange,
|
||||
});
|
||||
@@ -452,12 +473,13 @@ export function compareEvalResults(
|
||||
deltas.push({
|
||||
name: `${name} (removed)`,
|
||||
before: {
|
||||
passed: beforeTest.passed,
|
||||
passed: evalEntryOutcome(beforeTest) === 'passed',
|
||||
cost_usd: beforeTest.cost_usd,
|
||||
turns_used: beforeTest.turns_used,
|
||||
duration_ms: beforeTest.duration_ms,
|
||||
detection_rate: beforeTest.detection_rate,
|
||||
tool_summary: beforeToolSummary,
|
||||
...(evalEntryOutcome(beforeTest) === 'manual-review' ? { manual_review: true } : {}),
|
||||
},
|
||||
after: { passed: false, cost_usd: 0, tool_summary: {} },
|
||||
status_change: 'unchanged',
|
||||
@@ -477,6 +499,7 @@ export function compareEvalResults(
|
||||
improved,
|
||||
regressed,
|
||||
unchanged,
|
||||
...(manualReviewed ? { manual_reviewed: manualReviewed } : {}),
|
||||
tool_count_before: toolCountBefore,
|
||||
tool_count_after: toolCountAfter,
|
||||
matched,
|
||||
@@ -495,8 +518,8 @@ export function formatComparison(c: ComparisonResult): string {
|
||||
// Per-test deltas
|
||||
for (const d of c.deltas) {
|
||||
const arrow = d.status_change === 'improved' ? '↑' : d.status_change === 'regressed' ? '↓' : '=';
|
||||
const beforeStatus = d.before.passed ? 'PASS' : 'FAIL';
|
||||
const afterStatus = d.after.passed ? 'PASS' : 'FAIL';
|
||||
const beforeStatus = d.before.manual_review ? 'MANUAL' : d.before.passed ? 'PASS' : 'FAIL';
|
||||
const afterStatus = d.after.manual_review ? 'MANUAL' : d.after.passed ? 'PASS' : 'FAIL';
|
||||
|
||||
// Turns delta
|
||||
let turnsDelta = '';
|
||||
@@ -540,6 +563,7 @@ export function formatComparison(c: ComparisonResult): string {
|
||||
if (c.improved > 0) parts.push(`${c.improved} improved`);
|
||||
if (c.regressed > 0) parts.push(`${c.regressed} regressed`);
|
||||
if (c.unchanged > 0) parts.push(`${c.unchanged} unchanged`);
|
||||
if (c.manual_reviewed) parts.push(`${c.manual_reviewed} unscored manual review`);
|
||||
lines.push(` Status: ${parts.join(', ')}`);
|
||||
|
||||
const costSign = c.total_cost_delta >= 0 ? '+' : '';
|
||||
@@ -607,7 +631,9 @@ export function generateCommentary(c: ComparisonResult): string[] {
|
||||
const regressions = c.deltas.filter(d => d.status_change === 'regressed');
|
||||
if (regressions.length > 0) {
|
||||
for (const d of regressions) {
|
||||
notes.push(`REGRESSION: "${d.name}" was passing, now fails. Investigate immediately.`);
|
||||
notes.push(d.before.manual_review
|
||||
? `REGRESSION: "${d.name}" lost its unscored manual acceptance and now has a blocking failure. Investigate immediately.`
|
||||
: `REGRESSION: "${d.name}" was passing, now fails. Investigate immediately.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,6 +643,10 @@ export function generateCommentary(c: ComparisonResult): string[] {
|
||||
notes.push(`Fixed: "${d.name}" now passes.`);
|
||||
}
|
||||
|
||||
for (const d of c.deltas.filter(delta => delta.status_change === 'manual-review')) {
|
||||
notes.push(`"${d.name}" includes an unscored manual acceptance; no model-score improvement or regression is inferred.`);
|
||||
}
|
||||
|
||||
// 3. Per-test efficiency changes (only for unchanged-status tests — regressions/improvements are already noted)
|
||||
const stable = c.deltas.filter(d => d.status_change === 'unchanged' && d.after.passed);
|
||||
for (const d of stable) {
|
||||
@@ -893,7 +923,8 @@ export class EvalCollector {
|
||||
const version = getVersion();
|
||||
const totalCost = this.tests.reduce((s, t) => s + t.cost_usd, 0);
|
||||
const totalDuration = this.tests.reduce((s, t) => s + t.duration_ms, 0);
|
||||
const passed = this.tests.filter(t => t.passed).length;
|
||||
const passed = this.tests.filter(t => evalEntryOutcome(t) === 'passed').length;
|
||||
const manual = this.tests.filter(t => evalEntryOutcome(t) === 'manual-review').length;
|
||||
|
||||
const partial: EvalResult = {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
@@ -907,8 +938,9 @@ export class EvalCollector {
|
||||
total_tests: this.tests.length,
|
||||
executed_tests: this.tests.filter(t => t.execution !== 'reused').length,
|
||||
reused_tests: this.tests.filter(t => t.execution === 'reused').length,
|
||||
...(manual ? { manual_accepted_tests: manual } : {}),
|
||||
passed,
|
||||
failed: this.tests.length - passed,
|
||||
failed: this.tests.length - passed - manual,
|
||||
total_cost_usd: Math.round(totalCost * 100) / 100,
|
||||
total_duration_ms: totalDuration,
|
||||
tests: this.tests,
|
||||
@@ -933,7 +965,8 @@ export class EvalCollector {
|
||||
const timestamp = new Date().toISOString();
|
||||
const totalCost = this.tests.reduce((s, t) => s + t.cost_usd, 0);
|
||||
const totalDuration = this.tests.reduce((s, t) => s + t.duration_ms, 0);
|
||||
const passed = this.tests.filter(t => t.passed).length;
|
||||
const passed = this.tests.filter(t => evalEntryOutcome(t) === 'passed').length;
|
||||
const manual = this.tests.filter(t => evalEntryOutcome(t) === 'manual-review').length;
|
||||
|
||||
const flaky = this.flakyRetries();
|
||||
const result: EvalResult = {
|
||||
@@ -948,8 +981,9 @@ export class EvalCollector {
|
||||
total_tests: this.tests.length,
|
||||
executed_tests: this.tests.filter(t => t.execution !== 'reused').length,
|
||||
reused_tests: this.tests.filter(t => t.execution === 'reused').length,
|
||||
...(manual ? { manual_accepted_tests: manual } : {}),
|
||||
passed,
|
||||
failed: this.tests.length - passed,
|
||||
failed: this.tests.length - passed - manual,
|
||||
total_cost_usd: Math.round(totalCost * 100) / 100,
|
||||
total_duration_ms: totalDuration,
|
||||
wall_clock_ms: Date.now() - this.createdAt,
|
||||
@@ -999,7 +1033,9 @@ export class EvalCollector {
|
||||
lines.push('═'.repeat(70));
|
||||
|
||||
for (const t of this.tests) {
|
||||
const status = !t.passed ? ' FAIL ' : t.execution === 'reused' ? ' REUSE' : ' PASS ';
|
||||
const outcome = evalEntryOutcome(t);
|
||||
const status = outcome === 'manual-review' ? 'MANUAL' : outcome === 'failed' ? ' FAIL '
|
||||
: t.execution === 'reused' ? ' REUSE' : ' PASS ';
|
||||
const cost = `$${t.cost_usd.toFixed(2)}`;
|
||||
const dur = t.duration_ms ? `${Math.round(t.duration_ms / 1000)}s` : '';
|
||||
const turns = t.turns_used !== undefined ? `${t.turns_used}t` : '';
|
||||
@@ -1010,6 +1046,8 @@ export class EvalCollector {
|
||||
} else if (t.judge_scores) {
|
||||
const scores = Object.entries(t.judge_scores).map(([k, v]) => `${k[0]}:${v}`).join(' ');
|
||||
detail = scores;
|
||||
} else if (outcome === 'manual-review') {
|
||||
detail = `unscored; approved by ${t.manual_review!.approval.approved_by} (${t.manual_review!.approval.approval_url})`;
|
||||
}
|
||||
|
||||
const name = t.name.length > 35 ? t.name.slice(0, 32) + '...' : t.name.padEnd(35);
|
||||
@@ -1020,6 +1058,7 @@ export class EvalCollector {
|
||||
const totalCost = `$${result.total_cost_usd.toFixed(2)}`;
|
||||
const totalDur = `${Math.round(result.total_duration_ms / 1000)}s`;
|
||||
lines.push(` Total: ${result.passed}/${result.total_tests} passed${' '.repeat(20)}${totalCost.padStart(6)} ${totalDur}`);
|
||||
if (result.manual_accepted_tests) lines.push(` Manual accepted: ${result.manual_accepted_tests} unscored provider refusal(s)`);
|
||||
lines.push(` Evidence: ${result.executed_tests ?? result.total_tests} executed, ${result.reused_tests ?? 0} reused`);
|
||||
if (result.flaky_retries && result.flaky_retries.length > 0) {
|
||||
// Loud, never fatal: a flaky pass must not block anyone, but it must
|
||||
|
||||
@@ -13,7 +13,8 @@ import Anthropic from '@anthropic-ai/sdk';
|
||||
import type { JSONOutputFormat } from '@anthropic-ai/sdk/resources/messages';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { CLAUDE_FRONTIER_EVAL_MODEL, resolveEvalModel } from '../../lib/eval-model';
|
||||
import { CLAUDE_FRONTIER_EVAL_MODEL, DEFAULT_JUDGE_MAX_TOKENS, resolveEvalModel } from '../../lib/eval-model';
|
||||
export { DEFAULT_JUDGE_MAX_TOKENS } from '../../lib/eval-model';
|
||||
|
||||
export interface JudgeScore {
|
||||
clarity: number; // 1-5
|
||||
@@ -22,6 +23,35 @@ export interface JudgeScore {
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
export interface JudgeRefusalEvidence {
|
||||
stop_reason: 'refusal';
|
||||
response_id: string | null;
|
||||
request_id: string | null;
|
||||
model: string | null;
|
||||
input_tokens: number | null;
|
||||
output_tokens: number | null;
|
||||
text_blocks: number;
|
||||
}
|
||||
|
||||
export class JudgeRefusalError extends Error {
|
||||
readonly refusal: JudgeRefusalEvidence;
|
||||
|
||||
constructor(response: { id?: unknown; _request_id?: unknown; model?: unknown;
|
||||
usage?: { input_tokens?: unknown; output_tokens?: unknown }; content: Array<{ type: string }> }) {
|
||||
super('Judge provider refused the evaluation; no automated score');
|
||||
this.name = 'JudgeRefusalError';
|
||||
this.refusal = {
|
||||
stop_reason: 'refusal',
|
||||
response_id: typeof response.id === 'string' ? response.id : null,
|
||||
request_id: typeof response._request_id === 'string' ? response._request_id : null,
|
||||
model: typeof response.model === 'string' ? response.model : null,
|
||||
input_tokens: typeof response.usage?.input_tokens === 'number' ? response.usage.input_tokens : null,
|
||||
output_tokens: typeof response.usage?.output_tokens === 'number' ? response.usage.output_tokens : null,
|
||||
text_blocks: response.content.filter(block => block.type === 'text').length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface OutcomeJudgeResult {
|
||||
detected: string[];
|
||||
missed: string[];
|
||||
@@ -89,7 +119,7 @@ export async function callJudge<T>(
|
||||
// Thinking and answer text share max_tokens. The old 1024-token budget
|
||||
// could be exhausted before a frontier judge emitted any JSON.
|
||||
const resolvedModel = resolveEvalModel('judge', model);
|
||||
const maxTokens = opts?.max_tokens ?? 8192;
|
||||
const maxTokens = opts?.max_tokens ?? DEFAULT_JUDGE_MAX_TOKENS;
|
||||
const client = new Anthropic();
|
||||
|
||||
const makeRequest = () => client.messages.create({
|
||||
@@ -134,6 +164,7 @@ export async function callJudge<T>(
|
||||
.map(block => block.text)
|
||||
.join('\n');
|
||||
try {
|
||||
if (response.stop_reason === 'refusal') throw new JudgeRefusalError(response);
|
||||
if (opts?.jsonSchema !== undefined) {
|
||||
if (response.stop_reason !== 'end_turn') throw new Error(`Structured judge did not complete: stop_reason=${response.stop_reason}`);
|
||||
return JSON.parse(text) as T;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { EvalTestEntry } from './eval-store';
|
||||
import { buildCookieWorkflowJudgeInput } from './cookie-workflow-judge-input';
|
||||
import { COOKIE_MANUAL_REVIEW_FILE } from './cookie-workflow-manual-review';
|
||||
|
||||
export function manualReviewFixture(root = resolve(import.meta.dir, '../..')): EvalTestEntry {
|
||||
const approval = JSON.parse(readFileSync(resolve(root, COOKIE_MANUAL_REVIEW_FILE), 'utf8'));
|
||||
return {
|
||||
name: 'setup-browser-cookies/SKILL.md workflow', suite: 'Cookie setup workflow quality', tier: 'llm-judge',
|
||||
passed: false, execution: 'executed', exit_reason: 'provider_refusal', attempt: 1, duration_ms: 1, cost_usd: 0,
|
||||
model: approval.model, prompt: buildCookieWorkflowJudgeInput(root).prompt,
|
||||
error: 'Synthetic provider refusal fixture, not live model evidence',
|
||||
manual_review: { approval, refusal: { stop_reason: 'refusal', response_id: 'msg_synthetic_fixture',
|
||||
request_id: 'req_synthetic_fixture', model: approval.model, input_tokens: 1, output_tokens: 0, text_blocks: 0 } },
|
||||
};
|
||||
}
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user