mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
v1.91.302.0 stamp #3553 (train 41 slot 1)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
10a37c8c02
63 files changed
+1502
-4958
No files matched your search
@@ -14,7 +14,7 @@ updates:
|
||||
# The windowsHide patch (package.json patchedDependencies, #2160/#1989) is
|
||||
# keyed to one playwright-core version; a minor or major bump drops it and
|
||||
# brings back Windows console flashes. Patch updates still flow. The 1.64
|
||||
# migration (re-key the patch, the Dia/cookie qualification pins, the
|
||||
# migration (re-key the patch, the cookie qualification pins, the
|
||||
# arm64 artifact name) is owned by Garry, revisit 2026-11-15 (#2923).
|
||||
ignore:
|
||||
- dependency-name: "playwright"
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
#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
|
||||
@@ -1,231 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { closeSync, constants, createReadStream, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { release } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { assertOwnedDiaProfile, browserOperationTimedOut, browserPreflightError, browserRootFacts, browserStartupFacts, browserStderrFacts,
|
||||
nativeDiaLaunchOptions, observeBrowserLaunches, ownsFreshAccount, parseDirectoryRecord,
|
||||
readFreshAccountConfiguration, stopOwnedBrowserGroup, validateQualificationHost } from './qualify-dia-macos.ts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const sha256 = async file => {
|
||||
const hash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
||||
return hash.digest('hex');
|
||||
};
|
||||
|
||||
export function normalizedLaunchHashes(args, env, ownedPaths) {
|
||||
const paths = Object.entries(ownedPaths).sort(([, left], [, right]) => right.length - left.length);
|
||||
const normalize = value => {
|
||||
const equals = value.startsWith('--') ? value.indexOf('=') : -1;
|
||||
const prefix = equals >= 0 ? value.slice(0, equals + 1) : '';
|
||||
const candidate = equals >= 0 ? value.slice(equals + 1) : value;
|
||||
for (const [role, owned] of paths) {
|
||||
if (candidate === owned || candidate.startsWith(owned + path.sep)) return prefix + '<' + role + '>' + candidate.slice(owned.length);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
return { argvSha256: hash(args.map(normalize)), environmentSha256: hash(Object.entries(env).sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => [key, normalize(value)])) };
|
||||
}
|
||||
|
||||
export function compareDiaLaunchReceipts(left, right) {
|
||||
const invalid = { comparable: false, qualificationCredit: false, reason: 'incomplete_or_incompatible_arms' };
|
||||
if (!left?.launcher?.accountGuid || left.launcher.accountGuid === right?.launcher?.accountGuid) return invalid;
|
||||
const fingerprints = [];
|
||||
for (const [receipt, runtime] of [[left, 'bun'], [right, 'node']]) {
|
||||
const qualification = receipt?.qualification;
|
||||
const control = receipt?.backgroundPreflight?.comparisonControl;
|
||||
const source = qualification?.launchComparison?.source;
|
||||
const config = receipt?.launchComparison;
|
||||
if (config?.mode !== 'launch-only' || config.runtime !== runtime || config.qualificationCredit !== false
|
||||
|| receipt.backgroundPreflight?.status !== 'passed' || !control?.ready || qualification?.launchComparison?.qualificationCredit !== false
|
||||
|| qualification.keychainStage !== 'completed' || qualification.isolation?.registeredIdentity !== true
|
||||
|| qualification.isolation?.sharedRegisteredHome !== true || qualification.artifact?.signatureVerified !== true
|
||||
|| qualification.artifact?.gatekeeperNotarized !== true || qualification.artifact?.macosCompatibility?.compatible !== true
|
||||
|| qualification.platform?.os !== 'darwin' || qualification.platform?.architecture !== 'arm64'
|
||||
|| qualification.platform?.bun !== '1.4.2' || qualification.platform?.playwright !== '1.62.1'
|
||||
|| !/^\d+(?:\.\d+){1,2}$/.test(qualification.artifact.macosCompatibility.hostVersion)
|
||||
|| qualification.artifact?.architectures?.includes('arm64') !== true
|
||||
|| ['serviceStopped', 'userDomainStopped', 'userProcessesStopped', 'accountRemoved', 'groupRemoved', 'stagingRemoved'].some(key => receipt.launcherCleanup?.[key] !== true)
|
||||
|| ['ownedBrowsersStopped', 'sourceProfileRemoved', 'keychainRestored', 'mountDetached', 'fixtureRemoved'].some(key => qualification.cleanup?.[key] !== true)
|
||||
|| ['pass', 'fail', 'skip'].some(key => receipt.counts?.[key] !== 0 || qualification.counts?.[key] !== 0)) return invalid;
|
||||
for (const [result, purpose] of [[control, 'control'], [source, 'source']]) {
|
||||
const policy = result?.launchAttempts?.[0];
|
||||
if (result?.protocol !== 1 || result.purpose !== purpose || result.samplingEnabled !== false || result.rootCount !== 1
|
||||
|| result.supervisor?.closed !== true || result.supervisor?.exitCode !== 0 || result.cleanup?.confirmed !== true
|
||||
|| result.cleanup?.childClosed !== true || result.cleanup?.groupAbsent !== true || result.cleanup?.launchSettled !== true || result.launchAttempts?.length !== 1
|
||||
|| result.cleanup?.groups?.length !== 1 || result.cleanup.groups[0].absenceConfirmed !== true || result.cleanup.groups[0].childCloseObserved !== true
|
||||
|| typeof result.ready !== 'boolean' || typeof result.launchReturned !== 'boolean'
|
||||
|| policy?.sandboxRequired !== true || policy?.sandboxDisablingFlag !== false || policy?.pipeFlag !== true || policy?.tcpDebuggingFlag !== false
|
||||
|| policy?.mockKeychainFlag !== false || policy?.passwordStoreFlag !== false || policy?.firstRunSuppressed !== false
|
||||
|| policy?.headlessFlag !== true || policy?.expectedProfile !== true || policy?.detached !== true || policy?.shellDisabled !== true
|
||||
|| policy?.stdioCount !== 5 || policy?.extraPipeDescriptors !== true || policy?.profileArgumentCount !== 1
|
||||
|| result.driver?.runtime !== runtime || result.driver?.version !== (runtime === 'bun' ? '1.4.2' : '24.18.0')
|
||||
|| result.driver?.os !== 'darwin' || result.driver?.architecture !== 'arm64' || result.driver?.playwright !== '1.62.1'
|
||||
|| result.driver?.release !== qualification.platform.release
|
||||
|| result.driver?.executableSha256 !== config.executableSha256 || result.driver?.driverSha256 !== config.driverSha256
|
||||
|| result.driver?.helpersSha256 !== config.helpersSha256 || (result.ready && (!result.protocolResponded || !result.startupPages?.allowed || !result.postProbePages?.allowed))
|
||||
|| ![result.argvSha256, result.environmentSha256, config.executableSha256, config.driverSha256, config.helpersSha256].every(value => /^[a-f0-9]{64}$/.test(value))) return invalid;
|
||||
}
|
||||
const hashes = [receipt.launcher?.sourceRevision, receipt.launcher?.archiveSha256, receipt.launcher?.destinationSha256,
|
||||
qualification.artifact.sha256, qualification.artifact.executableSha256];
|
||||
if (!/^[a-f0-9]{40}$/.test(hashes[0]) || !hashes.slice(1).every(value => /^[a-f0-9]{64}$/.test(value))) return invalid;
|
||||
fingerprints.push(JSON.stringify({ hashes, platform: qualification.platform, compatibility: qualification.artifact.macosCompatibility,
|
||||
version: qualification.artifact.version, bundle: qualification.artifact.bundleId, team: qualification.artifact.team,
|
||||
driver: config.driverSha256, helpers: config.helpersSha256, controlArgs: control.argvSha256, controlEnv: control.environmentSha256,
|
||||
sourceArgs: source.argvSha256, sourceEnv: source.environmentSha256 }));
|
||||
}
|
||||
if (fingerprints[0] !== fingerprints[1]) return { ...invalid, reason: 'comparison_inputs_differ' };
|
||||
const bun = left.qualification.launchComparison.source.ready === true;
|
||||
const node = right.qualification.launchComparison.source.ready === true;
|
||||
return { comparable: true, qualificationCredit: false, outcome: bun ? node ? 'both_ready' : 'bun_only_ready' : node ? 'node_only_ready' : 'neither_ready' };
|
||||
}
|
||||
|
||||
export async function runProtectedLaunch(executable, profile, env, purpose, ownedPaths) {
|
||||
const result = { protocol: 1, purpose, stage: 'runtime_import', launchReturned: false, protocolResponded: false, ready: false,
|
||||
timedOut: false, error: null, samplingEnabled: false, cleanup: { childClosed: false, groupAbsent: false, confirmed: false } };
|
||||
const { chromium } = await import('playwright');
|
||||
const cp = require('node:child_process');
|
||||
const original = cp.spawn;
|
||||
cp.spawn = function(command, args, options) {
|
||||
if (command === executable) Object.assign(result, normalizedLaunchHashes(args, options.env, ownedPaths));
|
||||
return original.call(this, command, args, options);
|
||||
};
|
||||
const observer = observeBrowserLaunches(new Map([[executable, profile]]));
|
||||
let context;
|
||||
let timer;
|
||||
let launchSettled = false;
|
||||
try {
|
||||
result.stage = 'launch';
|
||||
const launch = chromium.launchPersistentContext(profile, nativeDiaLaunchOptions(executable, env));
|
||||
void launch.then(() => { launchSettled = true; }, () => { launchSettled = true; });
|
||||
context = await Promise.race([launch,
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('operation_timeout')), 30_000); })]);
|
||||
clearTimeout(timer);
|
||||
result.launchReturned = true;
|
||||
result.stage = 'ownership';
|
||||
if (observer.children.length !== 1) throw new Error('source_process_ownership_unconfirmed');
|
||||
result.stage = 'startup_pages';
|
||||
const urls = context.pages().map(page => page.url());
|
||||
result.startupPages = { ...browserStartupFacts(urls, 'http://127.0.0.1:1'), allowed: urls.every(url => url === 'about:blank') };
|
||||
if (!result.startupPages.allowed) throw new Error('onboarding_or_external_page');
|
||||
result.stage = 'protocol_probe';
|
||||
if (!context.pages()[0]) throw new Error('source_protocol_page_unavailable');
|
||||
result.protocolResponded = await Promise.race([context.pages()[0].evaluate(() => 1).then(value => value === 1),
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('operation_timeout')), 5000); })]);
|
||||
clearTimeout(timer);
|
||||
const after = context.pages().map(page => page.url());
|
||||
result.postProbePages = { ...browserStartupFacts(after, 'http://127.0.0.1:1'), allowed: after.every(url => url === 'about:blank') };
|
||||
if (!result.postProbePages.allowed) throw new Error('onboarding_or_external_page');
|
||||
result.ready = result.protocolResponded;
|
||||
result.stage = 'ready';
|
||||
} catch (error) {
|
||||
const reasons = browserStderrFacts(observer.children).flatMap(facts => Object.entries(facts.reasonCounts ?? {})
|
||||
.filter(([, count]) => count > 0).map(([reason]) => reason));
|
||||
result.error = browserPreflightError(error, reasons);
|
||||
result.timedOut = browserOperationTimedOut(error);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
const deadline = performance.now() + 5_000;
|
||||
observer.stop();
|
||||
result.rootCount = observer.children.length;
|
||||
result.launchAttempts = observer.attempts;
|
||||
result.rootsBeforeCleanup = browserRootFacts(observer.children);
|
||||
result.stderrBeforeCleanup = browserStderrFacts(observer.children);
|
||||
if (context) void context.close().catch(() => {});
|
||||
const groups = [];
|
||||
for (const child of observer.children) {
|
||||
const facts = { pid: child.pid, signalSent: false, absenceConfirmed: false, childCloseObserved: false };
|
||||
try { await stopOwnedBrowserGroup(child, deadline, facts); }
|
||||
catch { facts.failed = true; }
|
||||
groups.push(facts);
|
||||
}
|
||||
result.cleanup.groups = groups;
|
||||
result.cleanup.childClosed = observer.children.length === 1 && observer.children.every(child => child.closeObserved);
|
||||
result.cleanup.groupAbsent = groups.length === 1 && groups.every(group => group.absenceConfirmed);
|
||||
result.cleanup.launchSettled = launchSettled;
|
||||
result.cleanup.confirmed = result.cleanup.childClosed && result.cleanup.groupAbsent && launchSettled;
|
||||
result.rootsAfterCleanup = browserRootFacts(observer.children);
|
||||
result.stderrAfterCleanup = browserStderrFacts(observer.children);
|
||||
if (launchSettled) { observer.restore(); cp.spawn = original; }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function readComparisonRequest() {
|
||||
const buffer = Buffer.alloc(16 * 1024 + 1);
|
||||
let length = 0;
|
||||
while (length < buffer.length) {
|
||||
const read = readSync(0, buffer, length, buffer.length - length, null);
|
||||
if (!read) break;
|
||||
length += read;
|
||||
}
|
||||
if (length > 16 * 1024) throw new Error('invalid_driver_request');
|
||||
const request = JSON.parse(buffer.subarray(0, length).toString());
|
||||
if (!request || !['control', 'source'].includes(request.purpose)
|
||||
|| Object.keys(request).some(key => !(request.purpose === 'control' ? ['purpose'] : ['purpose', 'assetRoot', 'executableName', 'executableSha256']).includes(key))) throw new Error('invalid_driver_request');
|
||||
return request;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
validateQualificationHost(process.env);
|
||||
const request = readComparisonRequest();
|
||||
const account = readFreshAccountConfiguration(process.argv[2], 'comparison-driver');
|
||||
const config = account.launchComparison;
|
||||
const runtimeVersion = process.versions.bun ?? process.versions.node;
|
||||
if ((config.runtime === 'bun' ? process.versions.bun !== '1.4.2' : Boolean(process.versions.bun) || runtimeVersion !== '24.18.0')
|
||||
|| process.arch !== 'arm64' || require('playwright/package.json').version !== '1.62.1') throw new Error('invalid_driver_runtime');
|
||||
if (await sha256(process.execPath) !== config.executableSha256 || await sha256(import.meta.filename) !== config.driverSha256
|
||||
|| await sha256(path.join(import.meta.dirname, 'qualify-dia-macos.ts')) !== config.helpersSha256) throw new Error('driver_inputs_changed');
|
||||
const identity = spawnSync('/usr/bin/dscl', ['.', '-read', '/Users/' + account.account, 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory', 'GeneratedUID'],
|
||||
{ env: account.environment, encoding: 'utf8', timeout: 5000, maxBuffer: 64 * 1024 });
|
||||
if (identity.error || identity.status !== 0 || !ownsFreshAccount(parseDirectoryRecord(identity.stdout), account)) throw new Error('driver_identity_unconfirmed');
|
||||
let executable = account.destinationExecutable;
|
||||
let profile = path.join(account.temporary, 'probe/chromium');
|
||||
let temporary = account.temporary;
|
||||
const ownedPaths = { home: account.home, snapshot: account.snapshot, temporary: account.temporary, work: account.work };
|
||||
if (request.purpose === 'source') {
|
||||
const root = request.assetRoot;
|
||||
if (typeof root !== 'string' || realpathSync(root) !== root || path.dirname(root) !== account.temporary || !path.basename(root).startsWith('dia-')
|
||||
|| lstatSync(root).uid !== account.uid || typeof request.executableName !== 'string' || !request.executableName
|
||||
|| path.basename(request.executableName) !== request.executableName || ['.', '..'].includes(request.executableName)) throw new Error('invalid_source_request');
|
||||
executable = realpathSync(path.join(root, 'Dia.app/Contents/MacOS', request.executableName));
|
||||
if (!executable.startsWith(path.join(root, 'Dia.app/Contents/MacOS') + path.sep)
|
||||
|| !/^[a-f0-9]{64}$/.test(request.executableSha256) || await sha256(executable) !== request.executableSha256) throw new Error('source_identity_unconfirmed');
|
||||
profile = path.join(account.home, 'Library/Application Support/Dia/User Data');
|
||||
if (realpathSync(profile) !== profile || lstatSync(profile).uid !== account.uid) throw new Error('source_profile_unowned');
|
||||
if (JSON.stringify(readdirSync(profile)) !== JSON.stringify(['.gstack-dia-owner'])) throw new Error('source_profile_not_fresh');
|
||||
const descriptor = openSync(path.join(profile, '.gstack-dia-owner'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const marker = Buffer.alloc(65);
|
||||
let bytes;
|
||||
try {
|
||||
const entry = fstatSync(descriptor);
|
||||
if (!entry.isFile() || entry.uid !== account.uid || entry.nlink !== 1 || entry.size !== 64 || (entry.mode & 0o077) !== 0) throw new Error('source_profile_unowned');
|
||||
bytes = readSync(descriptor, marker, 0, marker.length, 0);
|
||||
} finally { closeSync(descriptor); }
|
||||
if (bytes !== 64) throw new Error('source_profile_unowned');
|
||||
const info = lstatSync(profile, { bigint: true });
|
||||
assertOwnedDiaProfile({ home: account.home, profile, uid: account.uid, dev: info.dev, ino: info.ino, nonce: marker.subarray(0, bytes).toString() });
|
||||
temporary = path.join(root, 't');
|
||||
ownedPaths.assets = root;
|
||||
} else {
|
||||
if (await sha256(executable) !== account.destinationSha256) throw new Error('control_identity_unconfirmed');
|
||||
try { lstatSync(profile); throw new Error('control_profile_not_fresh'); }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
}
|
||||
const env = { HOME: account.home, TMPDIR: temporary, PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' };
|
||||
const result = await runProtectedLaunch(executable, profile, env, request.purpose, ownedPaths);
|
||||
result.driver = { runtime: config.runtime, version: runtimeVersion, architecture: process.arch, os: process.platform, release: release(),
|
||||
executableSha256: config.executableSha256, driverSha256: config.driverSha256, helpersSha256: config.helpersSha256, playwright: '1.62.1' };
|
||||
return result;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().then(result => { process.stdout.write(JSON.stringify(result) + '\n'); }, () => {
|
||||
process.stdout.write(JSON.stringify({ protocol: 1, ready: false, error: 'driver_admission_failed', cleanup: { confirmed: false } }) + '\n');
|
||||
process.exitCode = 2;
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,864 +0,0 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { accessSync, chmodSync, constants, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { assertDiaSocketPath, browserPreflightError, browserStartupCategory, captureUserKeychains, fixtureKeychainRestoreCommands, FRESH_WORK_PREFIX, type FreshAccount,
|
||||
nativeDiaLaunchOptions, observeBrowserLaunches, observeFixtureKeychain, ownsFreshAccount, parseDirectoryRecord, stopOwnedBrowserGroup,
|
||||
inspectMachOArchitectures, playwrightModuleLoadFacts, prepareKeychainHome, readFreshAccountConfiguration, runDiaLaunchComparison, runGuiReadiness,
|
||||
validateQualificationHost, writePrivateReceipt } from './qualify-dia-macos';
|
||||
export { FRESH_WORK_PREFIX, ownsFreshAccount, parseDirectoryRecord } from './qualify-dia-macos';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const repository = path.resolve(import.meta.dir, '../..');
|
||||
|
||||
interface UserDomainObservation {
|
||||
uid: number;
|
||||
state: 'present' | 'absent' | 'unavailable';
|
||||
hasGuiDomain: boolean;
|
||||
exitCode: number | null;
|
||||
stdoutBytes: number;
|
||||
stderrBytes: number;
|
||||
structure?: {
|
||||
complete: boolean;
|
||||
type: 'user' | 'other' | 'unavailable';
|
||||
handleMatchesUid: boolean | null;
|
||||
creator: 'launchctl' | 'other' | 'unavailable';
|
||||
creatorIsProbe: boolean | null;
|
||||
counts: Record<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.2' || require(path.join(account.snapshot, 'node_modules/playwright/package.json')).version !== '1.62.1') throw new Error('pinned_runtime_mismatch');
|
||||
mkdirSync(probe, { mode: 0o700 });
|
||||
receipt.reason = 'background_keychain_preflight';
|
||||
snapshot = captureUserKeychains(env, [account.home, account.temporary]);
|
||||
const password = randomBytes(24).toString('hex');
|
||||
const value = randomBytes(24).toString('hex');
|
||||
keychainChanged = true;
|
||||
run('/usr/bin/security', ['create-keychain', '-p', password, keychain]);
|
||||
keychainCreated = true;
|
||||
run('/usr/bin/security', ['set-keychain-settings', '-lut', '300', keychain]);
|
||||
run('/usr/bin/security', ['unlock-keychain', '-p', password, keychain]);
|
||||
run('/usr/bin/security', ['list-keychains', '-d', 'user', '-s', keychain]);
|
||||
run('/usr/bin/security', ['default-keychain', '-d', 'user', '-s', keychain]);
|
||||
run('/usr/bin/security', ['add-generic-password', '-s', 'Gstack Native Probe', '-a', 'fixture', '-w', value,
|
||||
'-T', '/usr/bin/security', keychain]);
|
||||
const observed = observeFixtureKeychain(env, [account.home, account.temporary], keychain, value);
|
||||
receipt.keychainObservations = { ...observed, preferencesFileExists: existsSync(path.join(account.home, 'Library/Preferences/com.apple.security.plist')) };
|
||||
if (!observed.searchPathMatches || !observed.defaultPathMatches || !observed.explicitReadMatches) throw new Error('native_keychain_probe_failed');
|
||||
receipt.preflight.keychain = true;
|
||||
if (account.launchComparison) {
|
||||
receipt.reason = 'comparison_chromium_control';
|
||||
launchAttempted = true;
|
||||
comparisonControl = await runDiaLaunchComparison(account, 'control');
|
||||
if (!comparisonControl) throw new Error('comparison_control_failed');
|
||||
receipt.comparisonControl = comparisonControl;
|
||||
if (!comparisonControl.ready || !comparisonControl.cleanup?.confirmed) throw new Error('comparison_control_failed');
|
||||
receipt.preflight.headlessChromium = true;
|
||||
receipt.status = 'passed';
|
||||
receipt.reason = 'background_session_ready';
|
||||
} else {
|
||||
receipt.browserPreflight = { stage: 'runtime_import', launchReturned: false, ownedRootCount: 0,
|
||||
startupPageCount: null, startupPageCategories: [], pageSelected: false, contentSet: false, readbackMatched: false };
|
||||
receipt.reason = 'background_browser_runtime_import';
|
||||
const { chromium } = await import('playwright');
|
||||
const profile = path.join(probe, 'chromium');
|
||||
observer = observeBrowserLaunches(new Map([[account.destinationExecutable, profile]]));
|
||||
launchAttempted = true;
|
||||
receipt.browserPreflight.stage = 'launch';
|
||||
receipt.reason = 'background_browser_launch';
|
||||
context = await limit(chromium.launchPersistentContext(profile, nativeDiaLaunchOptions(account.destinationExecutable, env)), 40_000);
|
||||
receipt.browserPreflight.launchReturned = true;
|
||||
receipt.browserPreflight.stage = 'ownership';
|
||||
receipt.reason = 'background_browser_ownership';
|
||||
receipt.browserPreflight.ownedRootCount = observer.children.length;
|
||||
if (observer.children.length !== 1) throw new Error('background_browser_ownership_failed');
|
||||
receipt.browserPreflight.stage = 'startup_pages';
|
||||
receipt.reason = 'background_browser_startup_pages';
|
||||
const pages = context.pages();
|
||||
const startupUrls = pages.map((page: any) => page.url());
|
||||
receipt.browserPreflight.startupPageCount = pages.length;
|
||||
receipt.browserPreflight.startupPageCategories = startupUrls.map(browserStartupCategory);
|
||||
if (startupUrls.some((url: string) => url !== 'about:blank')) throw new Error('background_browser_startup_page_rejected');
|
||||
receipt.browserPreflight.stage = 'page_selection';
|
||||
receipt.reason = 'background_browser_page_selection';
|
||||
const page = pages[0] ?? await limit(context.newPage(), 5_000);
|
||||
receipt.browserPreflight.pageSelected = true;
|
||||
receipt.browserPreflight.stage = 'content_set';
|
||||
receipt.reason = 'background_browser_content_set';
|
||||
await limit(page.setContent('<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.2') throw new Error('run_as_unprivileged_pinned_ci_runner');
|
||||
const outputRoot = realpathSync(process.env.RUNNER_TEMP!);
|
||||
const output = path.join(outputRoot, 'dia-native-qualification.json');
|
||||
if (existsSync(output)) throw new Error('fresh_output_required');
|
||||
const work = realpathSync(mkdtempSync(FRESH_WORK_PREFIX));
|
||||
const home = path.join(work, 'home');
|
||||
const temporary = path.join(work, 'tmp');
|
||||
const snapshot = path.join(work, 'repo');
|
||||
const bin = path.join(work, 'bin');
|
||||
const browserDirectory = path.join(work, 'browser');
|
||||
const archive = path.join(work, 'source.tar');
|
||||
const suffix = randomBytes(6).toString('hex');
|
||||
const accountName = 'gsdia' + suffix;
|
||||
const label = 'ai.gstack.dia.' + suffix;
|
||||
const deadline = performance.now() + 16 * 60_000;
|
||||
const hostEnv = { HOME: homedir(), PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' };
|
||||
let cleanupDeadline = 0;
|
||||
const run = (command: string, args: string[], timeout = 10_000) => {
|
||||
const remaining = (cleanupDeadline || deadline) - performance.now();
|
||||
if (remaining <= 0) throw new Error('fresh_launcher_deadline');
|
||||
return safeCommand(command, args, Math.min(timeout, remaining), hostEnv);
|
||||
};
|
||||
const rootCommand = (command: string, args: string[], timeout = 10_000) => run('/usr/bin/sudo', ['-n', command, ...args], timeout);
|
||||
let account: FreshAccount | undefined;
|
||||
let userCreated = false;
|
||||
let groupCreated = false;
|
||||
let serviceAttempted = false;
|
||||
let domainBeforeCreation: ParentDomainObservation | undefined;
|
||||
let stage = 'fresh_launcher_preflight';
|
||||
const receipt: Record<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;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Native Qualification
|
||||
|
||||
# Dispatch-only diagnostic campaigns for native browser integrations: owned
|
||||
# Windows cookie extraction, Windows launch/credential diagnostics, and Dia on
|
||||
# disposable Apple-silicon Macs. They exercise real browsers and OS services,
|
||||
# Windows cookie extraction and Windows launch/credential diagnostics. They
|
||||
# exercise real browsers and OS services,
|
||||
# so they are qualification evidence, not PR gates; they used to live in
|
||||
# windows-free-tests.yml, where their red dispatch runs read as a red PR lane.
|
||||
#
|
||||
@@ -19,9 +19,6 @@ on:
|
||||
options:
|
||||
- cookie-native
|
||||
- windows-native-diagnostics
|
||||
- dia-native
|
||||
- dia-launch-comparison
|
||||
- dia-gui-readiness
|
||||
|
||||
concurrency:
|
||||
group: native-qualification-${{ github.ref }}-${{ inputs.mode }}
|
||||
@@ -102,53 +99,3 @@ jobs:
|
||||
name: windows-native-diagnostics
|
||||
path: ${{ runner.temp }}/gstack-free-test-*.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
dia-native-qualification:
|
||||
if: startsWith(inputs.mode, 'dia-')
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runtime: ${{ fromJSON(inputs.mode == 'dia-launch-comparison' && '["bun","node"]' || '["bun"]') }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
if: inputs.mode == 'dia-launch-comparison'
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
architecture: arm64
|
||||
- name: Install pinned dependencies
|
||||
if: inputs.mode != 'dia-gui-readiness'
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install the synthetic destination browser
|
||||
if: inputs.mode != 'dia-gui-readiness'
|
||||
run: bunx --no-install playwright install chromium
|
||||
- name: Inspect GUI readiness without browser or Keychain access
|
||||
if: inputs.mode == 'dia-gui-readiness'
|
||||
env:
|
||||
GSTACK_DIA_NATIVE_QUALIFY: '1'
|
||||
run: bun --no-env-file --no-install --no-macros --config=/dev/null .github/scripts/run-dia-native-qualification.ts --gui-readiness-only
|
||||
- name: Qualify native Dia discovery, decryption, and import
|
||||
if: inputs.mode == 'dia-native'
|
||||
env:
|
||||
GSTACK_DIA_NATIVE_QUALIFY: '1'
|
||||
run: bun --no-env-file --no-install --no-macros --config=/dev/null .github/scripts/run-dia-native-qualification.ts
|
||||
- name: Compare protected native Dia launch without qualification credit
|
||||
if: inputs.mode == 'dia-launch-comparison'
|
||||
env:
|
||||
GSTACK_DIA_NATIVE_QUALIFY: '1'
|
||||
COMPARISON_RUNTIME: ${{ matrix.runtime }}
|
||||
run: bun --no-env-file --no-install --no-macros --config=/dev/null .github/scripts/run-dia-native-qualification.ts --launch-comparison "$COMPARISON_RUNTIME"
|
||||
- name: Preserve only the sanitized qualification receipt
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
|
||||
with:
|
||||
name: ${{ inputs.mode == 'dia-gui-readiness' && 'dia-gui-readiness' || inputs.mode == 'dia-launch-comparison' && format('dia-launch-comparison-{0}', matrix.runtime) || 'dia-native-qualification' }}
|
||||
path: ${{ runner.temp }}/dia-native-qualification.json
|
||||
if-no-files-found: error
|
||||
@@ -18,7 +18,7 @@ name: Windows Free Tests
|
||||
# the artifact.
|
||||
#
|
||||
# Native browser qualification campaigns (Windows cookie extraction, launch
|
||||
# diagnostics, Dia on macOS) are dispatch-only in native-qualification.yml.
|
||||
# diagnostics) are dispatch-only in native-qualification.yml.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
+1
-1
@@ -199,7 +199,7 @@ Storage reset and authentication verification are independent opt-ins. `--clear-
|
||||
|
||||
### Shell injection prevention
|
||||
|
||||
The browser registry (Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet, Arc, Dia) is hardcoded. Database roots come from known platform locations; profile directory input is validated. Keychain access uses `Bun.spawn()` with explicit argument arrays, not shell string interpolation.
|
||||
The browser registry (Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet and Arc) is hardcoded. Database roots come from known platform locations; profile directory input is validated. Keychain access uses `Bun.spawn()` with explicit argument arrays, not shell string interpolation.
|
||||
|
||||
### Egress receipt ledger (v1.63.0.0)
|
||||
|
||||
|
||||
+2
-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` copies selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet, Arc, and Dia; verify sign-in separately, or log in once in headed mode |
|
||||
| Your sessions are already there | `/setup-browser-cookies` copies selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet and Arc; verify sign-in separately, or log in once in headed mode |
|
||||
| You watch the tabs the agent opens in Aside | `/open-gstack-browser` (or `$B connect`) shows the headed GStack Browser with the side panel |
|
||||
| Sign-in wall: sign in inside Aside, say "done" | `$B handoff` opens a visible Chrome at the same page; `$B resume` continues |
|
||||
| One `aside repl` script per flow, fresh session each time | Persistent daemon: cookies, tabs, and localStorage carry over between `$B` calls |
|
||||
@@ -568,7 +568,7 @@ from `snapshot`, or `@c` refs from `snapshot -C`. Full table:
|
||||
|
||||
#### Choosing a source and checking sign-in
|
||||
|
||||
Select the source browser and account/profile explicitly. The picker recognizes Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet, Arc, and Dia. It shows current profile names from `Local State`, falling back to Preferences and then the directory name, with directory labels to distinguish duplicate names. `--profile` takes that directory (`Default`, `Profile 2`), not its display name. Without it, only a sole relevant profile is selected; ambiguity or unreadable profiles require a choice. The omitted-browser default remains `comet` for CLI compatibility, not as a recommendation. The picker opening link is one-use and expires after five minutes.
|
||||
Select the source browser and account/profile explicitly. The picker recognizes Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet and Arc. It shows current profile names from `Local State`, falling back to Preferences and then the directory name, with directory labels to distinguish duplicate names. `--profile` takes that directory (`Default`, `Profile 2`), not its display name. Without it, only a sole relevant profile is selected; ambiguity or unreadable profiles require a choice. The omitted-browser default remains `comet` for CLI compatibility, not as a recommendation. The picker opening link is one-use and expires after five minutes.
|
||||
|
||||
For direct import, first navigate to a page matching `--domain`. Example after choosing Chrome's `Profile 2`:
|
||||
|
||||
|
||||
+79
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
## [1.91.302.0] - 2026-10-29
|
||||
|
||||
**Three flaky gate cases from the 2026-10-28 daily flaky pass are repaired: shared-libs-review-index-flags, ship-exploratory-late-input (with ship-exploratory-plan-checks, which failed the same way), and the real-Chromium cookie-auth positive case.** Each red was diagnosed from its captured run before the fix, and each repair has a free test that fails without it.
|
||||
|
||||
@@ -15,6 +15,84 @@
|
||||
- New free tests: `test/qa-caller-review-plan.test.ts` (replays both 40707142223 captures and both 40737741454 ship-exploratory-unavailable captures, and runs the real plan for all four ship cases) and `test/shared-libs-index-flags-readonly.test.ts` (replays the captured refresh against the assume-unchanged fixture, with a read-only control).
|
||||
- `scripts/lib/windows-curation.ts`: `test/shared-libs-index-flags-readonly.test.ts` is curated off the Windows subset. Its fixture starts the review through `bin/gstack-review-log`, a bash script, which windows-latest cannot launch (Windows Free Tests run 40727825859, shard 5).
|
||||
|
||||
## [1.91.301.0] - 2026-10-29
|
||||
|
||||
**Four paid gate cases that went red on 2026-10-28 without a product defect now pass on the runs that failed them.** Each repair replays the failing run as a free test that fails without it.
|
||||
|
||||
### Fixed
|
||||
- **plan-design-with-ui-scope**: the Design focus question counts when it puts "review all 7 areas or focus" in its rated title and letters its options ("A) Review all 7 areas"), as gate 40664336141 asked it.
|
||||
- **plan-review-report**: the case turns the default-on outside voice off with `codex_reviews: disabled`, the skill's documented switch. In gate 40660626349 both reds ran out of the 20-turn cap before writing the report, and the outside voice had used 4 of their 21 turns. The cap is unchanged.
|
||||
- **shared-libs-review-path-eligibility**: a Bash read that printed a whole evidence file counts even when a later command in the same call failed, as one did in run 40617200857.
|
||||
- **office-hours-brain-writeback**: the fake `gbrain` saves a page piped on stdin, the form the SAVE_RESULTS template shows, as well as `--content`.
|
||||
|
||||
### Gate integrity
|
||||
- `designFocusBoundary` (test/skill-e2e-plan-design-with-ui.test.ts) accepts the all-7 offer in the rated title line and rejects issue-numbered option sets. Seven new negative controls cover a missing rating, a /7 rating, no focus, another count, an offer below the title, issue-numbered options and a single option.
|
||||
- `hasTrustedSharedLibsCheck` (test/helpers/shared-libs-review-start-evidence.ts) accepts an errored Bash read only in a multi-command call whose output holds the exact evidence source. Start, check and finish calls must still succeed. The existing "errored caller read" control still fails, and five new controls do too.
|
||||
- The plan-review-report case input now disables the outside voice. Outside-voice coverage is not part of this case's assertions.
|
||||
|
||||
## [1.91.300.0] - 2026-10-28
|
||||
|
||||
**Three free tests that went red at random now fail only when the code under test is wrong.** Today's flaky-test pass (GSTA-139) traced two Linux reds to their fixtures and fixed a third that was certain to fail under load. No product code changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/ubicloud-runner.test.ts`**: the watchdog cases expected exit 124 but got 2 or 0, about one run in 62. The cause was not timing. The fake `ssh` had a `*-O*` case arm, meant for `ssh -O exit` but never matched by it, and it caught any remote command whose checkout path held `-O`. That happened whenever the fixture's temporary directory name started with `O`. The arm is gone, and a new test runs a checkout named `ubi-run-O1x2y3`.
|
||||
- **`test/ship-queue-train.test.ts`, `test/gstack-ship-queue.test.ts`**: `coordinatorClone` now clones the fixture origin with `--no-local`, through git's transport like a real clone of GitHub, instead of copying `origin.git/objects` file by file. Every push into the fixture origin leaves a detached `git maintenance run --auto` there. The local transport drops the shard's `GIT_CONFIG_COUNT` maintenance-off setting, so that process still creates and deletes `objects/maintenance.lock` after the push returns. Two runs failed with `failed to copy file … No such file or directory` on a loose object. The process that removed that object was not identified; the transport clone never lists the directory at all.
|
||||
- **`test/qa-fix-loop-fixture.test.ts` late-timeout**: the scenario ran its real setup inside a 1000 ms mocked work budget under a 1160 ms deadline, so on a loaded machine the attempt never started (0 of 10 runs under 24 busy loops). It now uses #3413's qa-only fix: the clock stands still from the test body's start until the capture begins, and the deadline is the registered 6080 ms outer budget (20 of 20 under the same load).
|
||||
|
||||
### Gate integrity
|
||||
- `test/qa-fix-loop-fixture.test.ts` late-timeout asserts that the capture gets exactly 1000 ms, where it used to accept anything between 0 and 1000, and its test deadline is the registered outer budget rather than budget − 5000 + 80 ms. If the workflow's outer budget drops back to the bare work budget, the scenario still fails. A new `slow-setup` scenario keeps the check the old range assertion gave. A controlled clock spends 400 ms in setup, and the capture must get exactly 600 ms. If the workflow passed the whole work budget to the capture instead of what setup left, it would fail.
|
||||
|
||||
## [1.91.299.0] - 2026-10-28
|
||||
|
||||
**The Captain brief now says what `rec` does when a gate carries earlier decisions.** At a gate after the owner already answered some items, such as a round-cap gate, `gstack-owner-brief render --captain` listed each item's recommendation ("keep the plan as written", `rec resolves to d2a`) while `gstack-gate render` and `gstack-autoplan answer --reply rec` keep the carried answer (`d2b`). A Captain relaying the brief told the owner the opposite of what their one-word reply would record (#3546). The brief now takes its choices from the same resolver `gstack-gate render` uses.
|
||||
|
||||
### Fixed
|
||||
- **Carried choices in the Captain brief**: each choice under "What we need from you", the header, the change-something example and the relaying agent's `rec resolves to` line use the `rec` resolution from `parseReply`, given the answers already recorded at this gate the way `gstack-autoplan answer` reads them. A carried item shows the owner's earlier answer marked `(your answer at gate_rev N, carried forward)`, an item answered earlier at this gate is marked `(your answer at this gate)`, and the header says how many answers `rec` keeps.
|
||||
|
||||
### Tests
|
||||
- `test/captain-brief.test.ts` drives a fixture autoplan run to a round-cap gate with three carried decisions, renders `gstack-gate render` and `gstack-owner-brief render --captain` for it, and asserts the same `rec` resolution and choice labels. A second case records a partial answer at that gate and checks the brief's `rec` against what `gstack-autoplan answer --reply rec` then records. The negative control strips the carried decisions and expects both renders to fall back to the recommendations.
|
||||
|
||||
## [1.91.298.0] - 2026-10-28
|
||||
|
||||
**Dia is gone from gstack.** `/setup-browser-cookies` and `cookie-import-browser` no longer list Dia as a cookie source, and the Dia qualification harness is deleted. That harness was a set of macOS campaigns that never qualified a native import, and its `dia-launch-comparison` free test was failing the merge train on Ubicloud runners. Cookie import from Comet, Chrome, Chromium, Arc, Brave, Edge, Opera and Opera GX is unchanged.
|
||||
|
||||
### Removed
|
||||
- **Dia as a cookie source**: the registry entry, the `dia` alias, and every Dia mention in the `cookie-import-browser` description, the browse and setup-browser-cookies skills, `gstack/llms.txt`, README, BROWSER.md, ARCHITECTURE.md, CONTRIBUTING.md, docs/skills.md and docs/TESTING_INTERNALS.md.
|
||||
- The `dia-native`, `dia-launch-comparison` and `dia-gui-readiness` modes are gone from `native-qualification.yml`, along with their job, the four `.github/scripts` files behind them, and their three `browse/test` files. Those tests also leave the free-test duration seed and the test typecheck baseline.
|
||||
- **`clang` on the Ubicloud free-suite VM**: only the Dia GUI readiness test compiled C on Linux.
|
||||
|
||||
### Tests
|
||||
- The cookie tests that used Dia as their macOS fixture browser now use Arc, which has the same `User Data` layout and Keychain shape: numbered-profile discovery, Keychain denial, the credential deadline, the Node bundle import and the picker UI. The historical cookie-workflow approval fixture adds one reverse edit so it still rebuilds the approved prompt byte for byte. No threshold or allowlist changed.
|
||||
|
||||
## [1.91.297.0] - 2026-10-28
|
||||
|
||||
**The terminal agent on Windows no longer waits on PowerShell to learn its own start time, so it boots in about a second and a slow PowerShell can no longer leave it without an identity.** Every agent boot ran `powershell.exe Get-CimInstance` with a 2 s timeout, twice when started standalone. On a loaded `windows-latest` runner one launch took 0.94 s at the median and up to 10.8 s, so agent boot took 2.4 to 2.8 s at the median and 5.1 s on a runner's first boot, past the owner-watchdog test's 5 s window. Measured again after the change under the same load: 0.86 to 0.91 s at the median, 1.18 s at most, 120 of 120 boots inside the window.
|
||||
|
||||
### Fixed
|
||||
- **`browse/src/terminal-agent-control.ts`**: under Bun the Windows start time comes from `GetProcessTimes` in-process and is written as the same `/Date(<ms>)/` value PowerShell's `CreationDate` gives (147 of 147 processes matched), so records from earlier releases and from the Node daemon, which keeps the PowerShell reader, still compare. The kill-time check still reads the command line through PowerShell, and a process is stopped only when it carries the record's `--agent-gen`. On Windows a start time that disagrees between the two readers no longer reads as gone when the live process carries that generation or its command line cannot be read, so no second agent starts beside it.
|
||||
|
||||
### Tests
|
||||
- `browse/test/process-liveness-windows.test.ts`: the in-process reader, the Node fallback, the kill-time command-line check, a reader disagreement and the unreadable path (never signalled, never gone, blocks a second agent), plus two Windows-only cases: the in-process value equals PowerShell's `CreationDate`, and a live agent whose identity cannot be read blocks a second one.
|
||||
- `browse/test/path-security-realpath.test.ts` builds its 8.3 alias with `GetShortPathNameW` instead of a PowerShell launch that stalled for 30 s in Windows Free Tests 40624015029 and was reported as "8.3 short names are disabled". A failed lookup now fails the test instead of skipping it.
|
||||
- `browse/test/cookie-import-native-job.test.ts` prints the Restart Manager receipt when the owner assertion fails, so the next red names its cause.
|
||||
|
||||
### Gate integrity
|
||||
- Test 5 in `process-liveness-windows.test.ts` expected the PowerShell mock's `fixture-owner-start` as the owner start time on every platform. On Windows that expectation followed the old implementation, so it moved with the product. It now requires the in-process value, equality with a real read, and no PowerShell query for the owner. The Linux and macOS assertion is unchanged.
|
||||
|
||||
## [1.91.296.0] - 2026-10-28
|
||||
|
||||
**The docsync gate stops failing ship-docsync-current and ship-docsync-store when the agent asks the docs helper for its usage in the middle of a line of reads.** Five red paid trials since 2026-10-27 each failed on a single refused line. In four, the agent ended a line of reads with `gstack-docs-candidate --help` (screen 40558235014, gate 40600731931, and screen 40627127462 twice). In the fifth, gate 40643690306, the line included `git tag`. v1.91.185.0 tried to stop the first slip by moving the "own Bash call" rule into the sentence that names `--help`, and the slip came back four times. The helper's `--help` prints its usage and returns before it touches Git or any file, so refusing it protected nothing. The 2026-10-27 backstop red (40515158924, `toContain`) ran on a tree from before #3516, which had already fixed it.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-observer.ts`**: a line of reads may now include the installed candidate's `--help`, either alone or after `snapshot` or `compare`, optionally with `2>&1` or `2>/dev/null`. It counts as a neutral read, the same way a joined Git read does, but never when it is fed by a pipe and never in a line that also runs `cd`. `git tag`, `git tag -l` and `git tag --list` are now accepted as Git reads, alongside the branch listings #3516 added. A tag operand or any other option is still refused.
|
||||
- **`test/helpers/docsync-interface.ts`**: the fixture interface declares both changes. It names the candidate's `--help` as the one exception to the own-call rule, and it adds tag listings to the Git read list.
|
||||
|
||||
### Tests
|
||||
- `test/docsync-command-grammar.test.ts` replays the five captured lines, which were refused before this change, and checks that each is now accepted, while the same line with `; git add app.ts` appended is still refused. Negative controls that stay refused: pipe-fed, `| sh`, `>` redirect and `cd` forms; `snapshot` and `compare` joined to reads; `-h`, `--version` and `--help snapshot`; the `~` and foreign helper paths; the lifecycle helpers' `--help` joined to reads; and 13 tag writes or filtered listings.
|
||||
|
||||
### Gate integrity
|
||||
- This is a gate edit, because the docs detector now accepts more lines. The test that pinned the joined `--help` as refused now expects it to be accepted. Focused paid trials at e967634: ship-docsync-current PASS ($0.84; its agent did join `--help` onto a line of reads) and ship-docsync-store PASS ($0.84).
|
||||
|
||||
## [1.91.295.0] - 2026-10-28
|
||||
|
||||
**Deleting or renaming a test no longer fails pregate with PREGATE_NO_LANE.** A PR that removed a test file, or renamed one, failed `lanes` because the removed path had no test lane of its own, so cleanup PRs needed a hand-written `dependencies` entry to pass (#3529). Pregate now accounts for a removed test the way it accounts for an edit. A renamed test runs under its new name, a deleted test runs the remaining tests of the files it exercised, and either one runs the tests of any file that still names it, such as a curation list. Every waived test is printed on its own line, so the reason is visible in the gate output. Removing any other file still needs a lane, as before.
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ the new defaults.
|
||||
| Clear pregate's test tier on a machine under 8 cores ([remote gate](docs/pregate.md#remote-gate)) | `gstack-pregate --stage tests --remote --spend-cap 2 --timeout 2700 --allow-repo-commands` (same command keyed or keyless) | Keyed: one billed 16-vCPU VM, about 7 minutes and $0.06 for the whole free tier, worst case $0.38; keyless: free, local parallel shards, practical for small selections only (98 files took 103 s on 4 cores) | Keyed: `UBICLOUD_API_KEY`; keyless: nothing |
|
||||
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs) | Free | Windows, Git Bash |
|
||||
| Refresh the Windows duration seed | `gh workflow run windows-free-tests.yml --ref <branch> -f record_durations=true`, then commit the `free-test-durations-windows` artifact | Free CI runners | Pushed branch, `gh` with workflow rights |
|
||||
| Run a native Windows or Dia qualification campaign | `gh workflow run native-qualification.yml --ref <branch> -f mode=<mode>` ([modes](docs/TESTING_INTERNALS.md#windows-free-lane)) | Free CI runners | Pushed branch, `gh` with workflow rights |
|
||||
| Run a native Windows qualification campaign | `gh workflow run native-qualification.yml --ref <branch> -f mode=<mode>` ([modes](docs/TESTING_INTERNALS.md#windows-free-lane)) | Free CI runners | Pushed branch, `gh` with workflow rights |
|
||||
| Preview which paid cases my diff selects | `bun run eval:select` (PR profile; `--profile full` for the plain touchfile selection) | Free | — |
|
||||
| Preview the CI paid slice plan | `bun run scripts/test-paid-shards.ts --tier periodic --list --slice-budget 420 --jobs 2` (diff-selected; `EVALS_ALL=1` lists everything) | Free | — |
|
||||
| Run paid coverage for my change (agents: detached) | `bun run eval:bg:pr` (foreground: `bun run test:pr`) | API spend for the selected cases only; about 10 minutes when dispatched to CI | Dispatch: a clean, pushed HEAD and `gh`. Local fallback: `ANTHROPIC_API_KEY`, Claude Code CLI, a plain terminal |
|
||||
|
||||
@@ -169,7 +169,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan-
|
||||
| `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini). |
|
||||
| `/browse` | **QA Engineer** | Give the agent eyes. Drives your [Aside](https://aside.com) browser first — your real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts. No Aside? It falls back to gstack's own Chromium: real clicks, ~100ms per command, and `/open-gstack-browser` shows it headed with sidebar, anti-bot stealth, and auto model routing. Every other browser skill stands on it. |
|
||||
| `/scrape` | **Data Extractor** | Pull structured data off a web page — tables, lists, prices — in your Aside browser with the page's real logged-in state. On the fallback browser, `/skillify` turns the flow into a permanent browser-skill that runs in ~200ms next time. |
|
||||
| `/setup-browser-cookies` | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet, Arc, and Dia into gstack's bundled browser. Choose your profile and domains; copying and sign-in verification are separate. Only needed on the fallback path — Aside already has your sessions. |
|
||||
| `/setup-browser-cookies` | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet and Arc into gstack's bundled browser. Choose your profile and domains; copying and sign-in verification are separate. Only needed on the fallback path — Aside already has your sessions. |
|
||||
| `/autoplan` | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → DX → eng review automatically (eng always last, so the shipping gate reviews the final amended plan) with encoded decision principles. Surfaces only taste decisions for your approval. |
|
||||
| `/spec` | **Spec Author** | Turn vague intent into a precise, executable spec in five phases (why, scope, technical with mandatory code-reading, draft, file). Outside-review quality gate before filing (Claude Code on Codex; Codex on other harnesses; blocks below 7/10), fail-closed secret redaction, dedupe against existing issues, archive to `$GSTACK_STATE_ROOT/projects/$SLUG/specs/` for team-corpus recall. `--execute` spawns `claude -p` in a fresh worktree; `/ship` auto-closes the source issue on merge. Plan-mode aware. |
|
||||
| `/learn` | **Memory** | Manage what gstack learned across sessions. Review, search, prune, and export project-specific patterns, pitfalls, and preferences. Learnings compound across sessions so gstack gets smarter on your codebase over time. |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.295.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.302.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,
|
||||
|
||||
+1
-2
@@ -452,8 +452,7 @@ HTML into a PNG or PDF is the render engine's job: use /make-pdf, /diagram, or
|
||||
/design-html for that.
|
||||
|
||||
On the fallback path, `/setup-browser-cookies` asks the user to choose the source
|
||||
browser and account/profile; Dia is macOS-only. Navigate to the matching target
|
||||
before a direct `--domain` import. Cookies copied is **not checked**, not proof
|
||||
browser and account/profile. Navigate to the matching target before a direct `--domain` import. Cookies copied is **not checked**, not proof
|
||||
of login. `--verify-auth` requires a daemon-configured exact visible identity
|
||||
assertion; HTTP 200 and cookie counts are not enough. Storage stays intact unless
|
||||
the user explicitly approves `--clear-storage` for the captured origin on a Chromium target. Other engines reject reset, not ordinary import or auth checks. Do not
|
||||
|
||||
@@ -112,8 +112,7 @@ HTML into a PNG or PDF is the render engine's job: use /make-pdf, /diagram, or
|
||||
/design-html for that.
|
||||
|
||||
On the fallback path, `/setup-browser-cookies` asks the user to choose the source
|
||||
browser and account/profile; Dia is macOS-only. Navigate to the matching target
|
||||
before a direct `--domain` import. Cookies copied is **not checked**, not proof
|
||||
browser and account/profile. Navigate to the matching target before a direct `--domain` import. Cookies copied is **not checked**, not proof
|
||||
of login. `--verify-auth` requires a daemon-configured exact visible identity
|
||||
assertion; HTTP 200 and cookie counts are not enough. Storage stays intact unless
|
||||
the user explicitly approves `--clear-storage` for the captured origin on a Chromium target. Other engines reject reset, not ordinary import or auth checks. Do not
|
||||
|
||||
@@ -94,7 +94,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] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. |
|
||||
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet or arc. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. |
|
||||
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
|
||||
| `dialog-dismiss` | Auto-dismiss next dialog |
|
||||
| `fill <sel> <val>` | Fill input |
|
||||
|
||||
@@ -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. `auto` (alias `reset`) unpins: headed follows the window again, headless returns to 1280x720.', usage: 'viewport [<WxH>|auto] [--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: 'Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]' },
|
||||
'cookie-import-browser': { category: 'Interaction', description: 'Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet or arc. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]' },
|
||||
'header': { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header <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]' },
|
||||
|
||||
@@ -118,7 +118,6 @@ const BROWSER_REGISTRY: BrowserInfo[] = [
|
||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/', windowsNative: true },
|
||||
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/', windowsNative: true },
|
||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||
{ name: 'Dia', dataDir: 'Dia/User Data/', keychainService: 'Dia Safe Storage', aliases: ['dia'] },
|
||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/', windowsNative: true },
|
||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/', windowsNative: true },
|
||||
// Windows-only. Local State sits directly under the browser root in %APPDATA% — no User Data segment.
|
||||
|
||||
@@ -24,9 +24,10 @@ import { atomicWriteSync } from '../../lib/fs-atomic';
|
||||
import { readPidCmdline, readPidStartTime } from './xvfb';
|
||||
import { emptyBunConfig, findWindowsBunExe, type BunExeLookup } from './bun-child-config';
|
||||
|
||||
function agentProcessInfo(pid: number): { startTime: string; commandLine: string } {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' };
|
||||
if (process.platform !== 'win32') return { startTime: readPidStartTime(pid), commandLine: readPidCmdline(pid) };
|
||||
const FILETIME_UNIX_EPOCH_MS = 11_644_473_600_000n;
|
||||
let win32Kernel: any;
|
||||
|
||||
function windowsProcessQuery(pid: number): { startTime: string; commandLine: string } {
|
||||
const script = `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object CreationDate,CommandLine | ConvertTo-Json -Compress)`;
|
||||
try {
|
||||
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true, timeout: 2000 });
|
||||
@@ -36,8 +37,40 @@ function agentProcessInfo(pid: number): { startTime: string; commandLine: string
|
||||
} catch { return { startTime: '', commandLine: '' }; }
|
||||
}
|
||||
|
||||
export const win32CreationTimeSource = {
|
||||
read(pid: number): string | undefined {
|
||||
if (!process.versions.bun) return;
|
||||
try {
|
||||
const { dlopen, FFIType, ptr } = require('bun:ffi');
|
||||
win32Kernel ??= 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 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
}).symbols;
|
||||
const handle = win32Kernel.OpenProcess(0x1000, 0, pid);
|
||||
if (!handle) return;
|
||||
try {
|
||||
const times = new BigUint64Array(4);
|
||||
if (!win32Kernel.GetProcessTimes(handle, ptr(times, 0), ptr(times, 8), ptr(times, 16), ptr(times, 24)) || times[0] === 0n) return;
|
||||
return `/Date(${times[0] / 10_000n - FILETIME_UNIX_EPOCH_MS})/`;
|
||||
} finally {
|
||||
win32Kernel.CloseHandle(handle);
|
||||
}
|
||||
} catch { return; }
|
||||
},
|
||||
};
|
||||
|
||||
function agentProcessInfo(pid: number): { startTime: string; commandLine: string } {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return { startTime: '', commandLine: '' };
|
||||
if (process.platform !== 'win32') return { startTime: readPidStartTime(pid), commandLine: readPidCmdline(pid) };
|
||||
const query = windowsProcessQuery(pid);
|
||||
return { startTime: win32CreationTimeSource.read(pid) ?? query.startTime, commandLine: query.commandLine };
|
||||
}
|
||||
|
||||
export function readAgentStartTime(pid: number): string {
|
||||
return agentProcessInfo(pid).startTime;
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return '';
|
||||
if (process.platform !== 'win32') return readPidStartTime(pid);
|
||||
return win32CreationTimeSource.read(pid) ?? windowsProcessQuery(pid).startTime;
|
||||
}
|
||||
|
||||
const pendingAgentExits = new Set<any>();
|
||||
@@ -335,7 +368,8 @@ function agentStatus(record: AgentRecord, ownerPid?: number): 'owned' | 'gone' |
|
||||
if (ownerPid !== undefined && (record.ownerPid !== ownerPid || record.ownerStartTime !== readAgentStartTime(ownerPid))) return 'unknown';
|
||||
const actual = agentProcessInfo(record.pid);
|
||||
if (!actual.startTime) return isAgentRecordLive(record) ? 'unknown' : 'gone';
|
||||
if (actual.startTime !== record.startTime) return 'gone';
|
||||
if (actual.startTime !== record.startTime && process.platform !== 'win32') return 'gone';
|
||||
if (actual.startTime !== record.startTime && !actual.commandLine) return 'unknown';
|
||||
try {
|
||||
let state: string | undefined;
|
||||
if (process.platform === 'linux') {
|
||||
@@ -348,7 +382,7 @@ function agentStatus(record: AgentRecord, ownerPid?: number): 'owned' | 'gone' |
|
||||
} catch {}
|
||||
if (!isAgentRecordLive(record)) return 'gone';
|
||||
return actual.commandLine.split(/\s+/).some(arg => arg.replace(/^['"]|['"]$/g, '') === `--agent-gen=${record.gen}`)
|
||||
? 'owned' : 'unknown';
|
||||
? 'owned' : actual.startTime === record.startTime ? 'unknown' : 'gone';
|
||||
}
|
||||
|
||||
export function isOurAgent(record: AgentRecord, ownerPid?: number): boolean {
|
||||
|
||||
@@ -14,7 +14,7 @@ let platform: PropertyDescriptor;
|
||||
let spawn: typeof Bun.spawn;
|
||||
|
||||
function fixture(encrypted: Buffer, hostPlatform: 'darwin' | 'linux' | 'win32' = 'darwin') {
|
||||
const folder = hostPlatform === 'darwin' ? 'Library/Application Support/Dia/User Data/Default'
|
||||
const folder = hostPlatform === 'darwin' ? 'Library/Application Support/Arc/User Data/Default'
|
||||
: hostPlatform === 'linux' ? '.config/chromium/Default' : 'AppData/Local/Chromium/User Data/Default';
|
||||
const dir = path.join(home, folder);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -72,7 +72,7 @@ describe('credential subprocess whole-operation deadline', () => {
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 19 as any; });
|
||||
try {
|
||||
const result = importCookies('dia', ['fixture.test']);
|
||||
const result = importCookies('arc', ['fixture.test']);
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
await expect(result).rejects.toMatchObject({ code: 'keychain_timeout', action: 'retry' });
|
||||
@@ -94,7 +94,7 @@ describe('credential subprocess whole-operation deadline', () => {
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 20 as any; });
|
||||
try {
|
||||
const result = importCookies('dia', ['fixture.test']);
|
||||
const result = importCookies('arc', ['fixture.test']);
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
await expect(result).rejects.toMatchObject({ code: 'keychain_timeout' });
|
||||
@@ -114,7 +114,7 @@ describe('credential subprocess whole-operation deadline', () => {
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob([privateValue.repeat(4_000)]).stream(),
|
||||
stderr: new Blob([]).stream(), kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let error: any;
|
||||
try { await importCookies('dia', ['fixture.test']); } catch (caught) { error = caught; }
|
||||
try { await importCookies('arc', ['fixture.test']); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('keychain_error');
|
||||
expect(error.message).not.toContain(privateValue);
|
||||
@@ -127,8 +127,8 @@ describe('credential subprocess whole-operation deadline', () => {
|
||||
for (const [code, stderr, expected] of [[0, '', 'fixture-value'], [1, 'user canceled private-detail', 'keychain_denied'], [1, 'could not be found private-detail', 'keychain_not_found']] as const) {
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(code), stdout: new Blob([code ? '' : 'fixture-password\n']).stream(),
|
||||
stderr: new Blob([stderr]).stream(), kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn;
|
||||
if (!code) expect((await importCookies('dia', ['fixture.test'])).cookies[0].value).toBe(expected);
|
||||
else await expect(importCookies('dia', ['fixture.test'])).rejects.toMatchObject({ code: expected });
|
||||
if (!code) expect((await importCookies('arc', ['fixture.test'])).cookies[0].value).toBe(expected);
|
||||
else await expect(importCookies('arc', ['fixture.test'])).rejects.toMatchObject({ code: expected });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('credential subprocess whole-operation deadline', () => {
|
||||
const polyfill = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
|
||||
const nodeFixture = path.join(home, 'node-fixture');
|
||||
fs.mkdirSync(nodeFixture);
|
||||
const dir = path.join(nodeFixture, 'Library/Application Support/Dia/User Data/Default');
|
||||
const dir = path.join(nodeFixture, 'Library/Application Support/Arc/User Data/Default');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(nodeFixture) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
@@ -213,7 +213,7 @@ describe('credential subprocess whole-operation deadline', () => {
|
||||
Bun.spawn = (_command, options) => original([process.execPath, '-e', 'console.log("fixture-node-password")'], options);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
const { importCookies } = await import(process.argv[2]);
|
||||
const result = await importCookies('dia', ['fixture.test']);
|
||||
const result = await importCookies('arc', ['fixture.test']);
|
||||
console.log(JSON.stringify({ count: result.count, value: result.cookies[0]?.value }));
|
||||
`, polyfill, pathToFileURL(bundle).href], { encoding: 'utf8', timeout: 15_000,
|
||||
env: { ...process.env, HOME: nodeFixture, USERPROFILE: nodeFixture, NODE_NO_WARNINGS: '1' } });
|
||||
|
||||
@@ -999,9 +999,9 @@ function safeNativeEnvelope(output: string): object {
|
||||
|
||||
function assertOwnerReceipt(receipt: { available: boolean; owners: { pid: number; creationMatched: boolean; isTestHost: boolean }[] }, pid: number) {
|
||||
const before = JSON.stringify(receipt);
|
||||
expect(receipt.available).toBe(true);
|
||||
expect(Array.isArray(receipt.owners)).toBe(true);
|
||||
expect(receipt.owners.some(owner => owner.pid === pid && owner.creationMatched && owner.isTestHost)).toBe(true);
|
||||
expect(receipt.available, before).toBe(true);
|
||||
expect(Array.isArray(receipt.owners), before).toBe(true);
|
||||
expect(receipt.owners.some(owner => owner.pid === pid && owner.creationMatched && owner.isTestHost), before).toBe(true);
|
||||
expect(JSON.stringify(receipt)).toBe(before);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('native-cookie production admission', () => {
|
||||
});
|
||||
|
||||
test('does not substitute a different browser for an unsupported identity', () => {
|
||||
expect(adapter({ ...options, browserName: 'Dia' })).toMatchObject({ code: 'not_supported', typed: true });
|
||||
expect(adapter({ ...options, browserName: 'Arc' })).toMatchObject({ code: 'not_supported', typed: true });
|
||||
});
|
||||
|
||||
test('rejects mismatched and copied profile roots', () => {
|
||||
|
||||
@@ -108,10 +108,10 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('cookie import reliability', () => {
|
||||
test('discovers Dia with only a numbered macOS profile', () => {
|
||||
profile('Library/Application Support/Dia/User Data', 'Profile 2');
|
||||
expect(findInstalledBrowsers().map(browser => browser.name)).toContain('Dia');
|
||||
expect(listProfiles('dia')[0].name).toBe('Profile 2');
|
||||
test('discovers Arc with only a numbered macOS profile', () => {
|
||||
profile('Library/Application Support/Arc/User Data', 'Profile 2');
|
||||
expect(findInstalledBrowsers().map(browser => browser.name)).toContain('Arc');
|
||||
expect(listProfiles('arc')[0].name).toBe('Profile 2');
|
||||
});
|
||||
|
||||
test('prefers current Local State names and sorts profile numbers naturally', () => {
|
||||
@@ -213,13 +213,13 @@ describe('cookie import reliability', () => {
|
||||
});
|
||||
|
||||
test('actual Keychain denial is sanitized, never repeated, and clears its deadline', async () => {
|
||||
const dir = profile('Library/Application Support/Dia/User Data', 'Default');
|
||||
const dir = profile('Library/Application Support/Arc/User Data', 'Default');
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run("UPDATE cookies SET value = '', encrypted_value = ?", [Buffer.from('v10synthetic-encrypted-row')]);
|
||||
db.close();
|
||||
let requests = 0;
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
expect(command).toEqual(['security', 'find-generic-password', '-s', 'Dia Safe Storage', '-w']);
|
||||
expect(command).toEqual(['security', 'find-generic-password', '-s', 'Arc Safe Storage', '-w']);
|
||||
requests++;
|
||||
return { stdout: new Blob([]).stream(), stderr: new Blob(['user canceled synthetic-private-detail']).stream(), exited: Promise.resolve(1), kill() {} };
|
||||
}) as typeof Bun.spawn;
|
||||
@@ -227,7 +227,7 @@ describe('cookie import reliability', () => {
|
||||
const clear = spyOn(globalThis, 'clearTimeout').mockImplementation(() => {});
|
||||
try {
|
||||
let error: any;
|
||||
try { await withCookieReadRetry(() => importCookies('dia', ['example.test'])); } catch (caught) { error = caught; }
|
||||
try { await withCookieReadRetry(() => importCookies('arc', ['example.test'])); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('keychain_denied');
|
||||
expect(error.message).not.toContain('synthetic-private-detail');
|
||||
@@ -252,7 +252,7 @@ describe('cookie import reliability', () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||
expect(listSupportedBrowserNames()).toEqual(['Chrome', 'Chromium', 'Brave', 'Edge', 'Opera', 'Opera GX']);
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
expect(listSupportedBrowserNames()).toEqual(['Comet', 'Chrome', 'Chromium', 'Arc', 'Dia', 'Brave', 'Edge']);
|
||||
expect(listSupportedBrowserNames()).toEqual(['Comet', 'Chrome', 'Chromium', 'Arc', 'Brave', 'Edge']);
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||
expect(listSupportedBrowserNames()).toEqual(['Chrome', 'Chromium', 'Brave', 'Edge']);
|
||||
});
|
||||
@@ -618,7 +618,7 @@ describe('cookie import reliability', () => {
|
||||
|
||||
test('windowsNative matches exactly the browsers the native extractor maps', () => {
|
||||
const env = { LOCALAPPDATA: 'C:\\Users\\fixture\\AppData\\Local' };
|
||||
for (const browser of ['Comet', 'Chrome', 'Chromium', 'Arc', 'Dia', 'Brave', 'Edge', 'Opera', 'Opera GX']) {
|
||||
for (const browser of ['Comet', 'Chrome', 'Chromium', 'Arc', 'Brave', 'Edge', 'Opera', 'Opera GX']) {
|
||||
let mapped = true;
|
||||
try { nativeBrowserPaths(browser, env); } catch { mapped = false; }
|
||||
expect(resolveBrowserInfo(browser).windowsNative === true).toBe(mapped);
|
||||
|
||||
@@ -51,10 +51,10 @@ describe('rendered cookie picker', () => {
|
||||
requests = [];
|
||||
imported = [];
|
||||
handler = () => undefined;
|
||||
browsers = [{ name: 'Chrome', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'] }, { name: 'Dia', aliases: ['dia'] }];
|
||||
browsers = [{ name: 'Chrome', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'] }, { name: 'Arc', aliases: ['arc'] }];
|
||||
profiles = {
|
||||
Chrome: { profiles: [{ name: 'Default', displayName: 'Personal' }, { name: 'Profile 1', displayName: 'Personal' }], recommendedProfile: 'Default' },
|
||||
Dia: { profiles: [{ name: 'Profile 1', displayName: 'Work' }, { name: 'Profile 2', displayName: 'Work' }], recommendedProfile: 'Profile 2' },
|
||||
Arc: { profiles: [{ name: 'Profile 1', displayName: 'Work' }, { name: 'Profile 2', displayName: 'Work' }], recommendedProfile: 'Profile 2' },
|
||||
};
|
||||
errors = [];
|
||||
context = await browser.newContext();
|
||||
@@ -200,8 +200,8 @@ describe('rendered cookie picker', () => {
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Arc', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .arc-profile-2.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
});
|
||||
|
||||
@@ -211,12 +211,12 @@ describe('rendered cookie picker', () => {
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Chrome');
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Arc', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .arc-profile-2.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
await page.getByRole('button', { name: 'Chrome', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/profiles').map(request => request.browser)).toEqual(['Chrome', 'Dia', 'Chrome']);
|
||||
expect(requests.filter(request => request.path === '/profiles').map(request => request.browser)).toEqual(['Chrome', 'Arc', 'Chrome']);
|
||||
});
|
||||
|
||||
test('does not interpret a browser alias substring as an installed browser', async () => {
|
||||
@@ -241,34 +241,34 @@ describe('rendered cookie picker', () => {
|
||||
for (const earlierFirst of [true, false]) {
|
||||
test(`ignores stale profile responses when the earlier request returns ${earlierFirst ? 'first' : 'last'}`, async () => {
|
||||
const chrome = Promise.withResolvers<Response>();
|
||||
const dia = Promise.withResolvers<Response>();
|
||||
const arc = Promise.withResolvers<Response>();
|
||||
const chromeSeen = Promise.withResolvers<void>();
|
||||
const diaSeen = Promise.withResolvers<void>();
|
||||
const arcSeen = Promise.withResolvers<void>();
|
||||
handler = request => {
|
||||
if (request.path !== '/profiles') return;
|
||||
if (request.browser === 'Chrome') { chromeSeen.resolve(); return chrome.promise; }
|
||||
diaSeen.resolve(); return dia.promise;
|
||||
arcSeen.resolve(); return arc.promise;
|
||||
};
|
||||
await openPicker();
|
||||
await chromeSeen.promise;
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await diaSeen.promise;
|
||||
await page.getByRole('button', { name: 'Arc', exact: true }).click();
|
||||
await arcSeen.promise;
|
||||
if (earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('/profiles?browser=Chrome'));
|
||||
chrome.resolve(Response.json(profiles.Chrome));
|
||||
await response;
|
||||
expect(await page.locator('.profile-pill.active').count()).toBe(0);
|
||||
}
|
||||
dia.resolve(Response.json(profiles.Dia));
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
arc.resolve(Response.json(profiles.Arc));
|
||||
await page.getByRole('button', { name: 'Import .arc-profile-2.test', exact: true }).waitFor();
|
||||
if (!earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('/profiles?browser=Chrome'));
|
||||
chrome.resolve(Response.json(profiles.Chrome));
|
||||
await response;
|
||||
}
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Dia');
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Arc');
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
expect(requests.filter(request => request.path === '/domains').map(request => request.browser)).toEqual(['Dia']);
|
||||
expect(requests.filter(request => request.path === '/domains').map(request => request.browser)).toEqual(['Arc']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -347,16 +347,16 @@ describe('rendered cookie picker', () => {
|
||||
const add = page.getByRole('button', { name: 'Import .chrome-default.test', exact: true });
|
||||
await add.evaluate((element: HTMLButtonElement) => { element.click(); element.click(); });
|
||||
await seen.promise;
|
||||
expect(await page.getByRole('button', { name: 'Dia', exact: true }).isDisabled()).toBe(true);
|
||||
expect(await page.getByRole('button', { name: 'Arc', exact: true }).isDisabled()).toBe(true);
|
||||
expect(await page.getByRole('button', { name: 'Personal (Profile 1)', exact: true }).isDisabled()).toBe(true);
|
||||
for (const selector of ['#btn-import-all', '#clear-storage', '#verify-auth', '#search', '.btn-trash']) expect(await page.locator(selector).isDisabled()).toBe(true);
|
||||
await page.locator('#btn-import-all').evaluate((element: HTMLButtonElement) => element.click());
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).evaluate((element: HTMLButtonElement) => element.click());
|
||||
await page.getByRole('button', { name: 'Arc', exact: true }).evaluate((element: HTMLButtonElement) => element.click());
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
result.resolve(Response.json({ imported: 1, failed: 0, domainCounts: { '.chrome-default.test': 1 }, outcome: 'imported', reset: 'not_requested', verification: { verified: false, reason: 'not_requested' } }));
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Chrome');
|
||||
expect(await page.getByRole('button', { name: 'Dia', exact: true }).isEnabled()).toBe(true);
|
||||
expect(await page.getByRole('button', { name: 'Arc', exact: true }).isEnabled()).toBe(true);
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseGuiReadiness, runGuiReadiness, validateGuiReadinessAuthority } from '../../.github/scripts/qualify-dia-macos';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'dia-gui-readiness-test-')));
|
||||
const source = path.resolve(import.meta.dir, '../../.github/scripts/dia-gui-readiness.c');
|
||||
const executable = path.join(root, 'metadata-probe');
|
||||
const names = ['chrome', 'chromium', 'arc', 'dia', 'comet', 'brave', 'edge', 'safari', 'cookies'];
|
||||
const observation = () => ({ protocol: 1, supported: true, identity: { effectiveUidMatches: true, homeMatchesRegistered: true },
|
||||
security: { status: 0, graphicAccess: true, rootSession: false, tty: false, remote: false },
|
||||
quartz: { present: true, sameUid: true, loginDone: true, onConsole: true }, browserRoots: null as any });
|
||||
|
||||
beforeAll(() => {
|
||||
const wrapper = path.join(root, 'metadata-probe.c');
|
||||
writeFileSync(wrapper, `#define main native_readiness_main\n#include ${JSON.stringify(source)}\n#undef main
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) return 2;
|
||||
int home = open(argv[1], O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK);
|
||||
if (home < 0) return 2;
|
||||
print_browser_roots(home, getuid()); close(home); printf("\\n"); return 0;
|
||||
}`);
|
||||
const compiler = Bun.which('clang');
|
||||
if (!compiler) throw new Error('clang is required for the POSIX metadata regression');
|
||||
const compiled = spawnSync(compiler, ['-std=c11', '-O2', '-Wall', '-Wextra', wrapper,
|
||||
...(process.platform === 'darwin' ? ['-framework', 'Security', '-framework', 'ApplicationServices'] : []), '-o', executable],
|
||||
{ encoding: 'utf8', timeout: 30_000 });
|
||||
if (compiled.status !== 0) throw new Error('metadata_fixture_compile_failed');
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const inspect = (home: string) => {
|
||||
const result = spawnSync(executable, [home], { encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
|
||||
describe('read-only GUI readiness', () => {
|
||||
test('metadata inspection reports missing fixed roots without creating state', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'empty-')));
|
||||
const before = readdirSync(home);
|
||||
const result = inspect(home);
|
||||
expect(Object.keys(result)).toEqual(names);
|
||||
for (const name of names) expect(result[name]).toEqual({ state: 'absent', kind: null, ownerMatches: null, ancestorBlocked: false });
|
||||
expect(readdirSync(home)).toEqual(before);
|
||||
expect(lstatSync(home).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
test('metadata inspection never follows ancestor or target symlinks', () => {
|
||||
const outside = realpathSync(mkdtempSync(path.join(root, 'other-')));
|
||||
writeFileSync(path.join(outside, 'private-sentinel'), 'unchanged fixture contents');
|
||||
const linked = realpathSync(mkdtempSync(path.join(root, 'linked-')));
|
||||
symlinkSync(outside, path.join(linked, 'Library'), 'dir');
|
||||
const blocked = inspect(linked);
|
||||
for (const name of names) expect(blocked[name]).toEqual({ state: 'unavailable', kind: 'symlink', ownerMatches: true, ancestorBlocked: true });
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'target-')));
|
||||
mkdirSync(path.join(home, 'Library'));
|
||||
symlinkSync(outside, path.join(home, 'Library/Safari'), 'dir');
|
||||
expect(inspect(home).safari).toEqual({ state: 'present', kind: 'symlink', ownerMatches: true, ancestorBlocked: false });
|
||||
expect(readdirSync(outside)).toEqual(['private-sentinel']);
|
||||
expect(readFileSync(path.join(outside, 'private-sentinel'), 'utf8')).toBe('unchanged fixture contents');
|
||||
});
|
||||
|
||||
test('wrong ancestor types and access failures are unavailable, never absent', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'wrong-type-')));
|
||||
writeFileSync(path.join(home, 'Library'), 'not a directory');
|
||||
expect(inspect(home).dia).toEqual({ state: 'unavailable', kind: 'file', ownerMatches: true, ancestorBlocked: true });
|
||||
const denied = realpathSync(mkdtempSync(path.join(root, 'denied-')));
|
||||
const library = path.join(denied, 'Library');
|
||||
mkdirSync(library, { mode: 0o700 });
|
||||
chmodSync(library, 0);
|
||||
try {
|
||||
expect(inspect(denied).dia).toEqual({ state: 'unavailable', kind: 'directory', ownerMatches: true, ancestorBlocked: true });
|
||||
expect(lstatSync(library).mode & 0o777).toBe(0);
|
||||
} finally { chmodSync(library, 0o700); }
|
||||
});
|
||||
|
||||
test('present root metadata does not open contents or change them', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'present-')));
|
||||
const safari = path.join(home, 'Library/Safari');
|
||||
mkdirSync(safari, { recursive: true });
|
||||
const file = path.join(safari, 'private-sentinel');
|
||||
writeFileSync(file, 'private fixture content');
|
||||
chmodSync(file, 0);
|
||||
const before = lstatSync(file);
|
||||
const result = inspect(home);
|
||||
expect(result.safari).toEqual({ state: 'present', kind: 'directory', ownerMatches: true, ancestorBlocked: false });
|
||||
expect(lstatSync(file).mode).toBe(before.mode);
|
||||
expect(lstatSync(file).mtimeMs).toBe(before.mtimeMs);
|
||||
expect(JSON.stringify(result)).not.toContain('private');
|
||||
chmodSync(file, 0o600);
|
||||
expect(readFileSync(file, 'utf8')).toBe('private fixture content');
|
||||
});
|
||||
|
||||
test('GUI usability requires both the security capability and the caller-owned logged-in Quartz session', () => {
|
||||
const original = observation();
|
||||
const before = JSON.stringify(original);
|
||||
Object.freeze(original.identity); Object.freeze(original.security); Object.freeze(original.quartz); Object.freeze(original);
|
||||
expect(parseGuiReadiness(original, false).usableGui).toBe(true);
|
||||
expect(JSON.stringify(original)).toBe(before);
|
||||
for (const changed of [
|
||||
{ ...observation(), security: { ...observation().security, graphicAccess: false } },
|
||||
{ ...observation(), quartz: { present: false, sameUid: null, loginDone: null, onConsole: null } },
|
||||
{ ...observation(), quartz: { present: true, sameUid: false, loginDone: null, onConsole: null } },
|
||||
{ ...observation(), quartz: { ...observation().quartz, loginDone: false } },
|
||||
{ ...observation(), identity: { effectiveUidMatches: true, homeMatchesRegistered: false } },
|
||||
{ ...observation(), security: { status: -60500, graphicAccess: null, rootSession: null, tty: null, remote: null } },
|
||||
]) expect(parseGuiReadiness(changed, false).usableGui).toBe(false);
|
||||
});
|
||||
|
||||
test('schema rejects extra identity text, cross-UID session details and false absence claims', () => {
|
||||
for (const changed of [
|
||||
{ ...observation(), username: 'private-name' },
|
||||
{ ...observation(), quartz: { ...observation().quartz, uid: 501 } },
|
||||
{ ...observation(), quartz: { present: true, sameUid: false, loginDone: true, onConsole: true } },
|
||||
{ ...observation(), security: { ...observation().security, status: -1 } },
|
||||
{ ...observation(), security: { ...observation().security, graphicAccess: 1 } },
|
||||
]) expect(() => parseGuiReadiness(changed, false)).toThrow('invalid_gui_readiness_receipt');
|
||||
const roots = Object.fromEntries(names.map(name => [name, { state: 'absent', kind: null, ownerMatches: null, ancestorBlocked: false }]));
|
||||
expect(parseGuiReadiness({ ...observation(), browserRoots: roots }, true).browserRoots).toEqual(roots);
|
||||
expect(() => parseGuiReadiness({ ...observation(), browserRoots: roots }, false)).toThrow('invalid_gui_readiness_receipt');
|
||||
expect(() => parseGuiReadiness({ ...observation(), browserRoots: { ...roots, dia: { state: 'absent', kind: 'symlink', ownerMatches: true, ancestorBlocked: true } } }, true))
|
||||
.toThrow('invalid_gui_readiness_receipt');
|
||||
});
|
||||
|
||||
test('GUI authority cannot carry browser/comparison authority or placeholder destination hashes', () => {
|
||||
const account: any = { work: '/private/tmp/dn-fixture', guiReadiness: { mode: 'gui-readiness-only', executable: '/private/tmp/dn-fixture/bin/gui-readiness',
|
||||
executableSha256: 'a'.repeat(64), sourceSha256: 'b'.repeat(64) } };
|
||||
expect(() => validateGuiReadinessAuthority(account, 'coordinator')).not.toThrow();
|
||||
for (const changed of [{ ...account, launchComparison: {} }, { ...account, destinationExecutable: '' }, { ...account, destinationSha256: 'a'.repeat(64) },
|
||||
{ ...account, guiReadiness: null }, { ...account, guiReadiness: { ...account.guiReadiness, executableSha256: ['a'.repeat(64)] } },
|
||||
{ ...account, guiReadiness: { ...account.guiReadiness, executable: '/unowned/probe' } }]) {
|
||||
expect(() => validateGuiReadinessAuthority(changed, 'coordinator')).toThrow('gui_readiness_authority_invalid');
|
||||
}
|
||||
expect(() => validateGuiReadinessAuthority(account, 'comparison-driver')).toThrow('gui_readiness_authority_invalid');
|
||||
});
|
||||
|
||||
test('the registered probe validates hashes, bounds execution, and discards unrecognized output', async () => {
|
||||
const hash = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
const exeHash = hash(executable);
|
||||
const sourceHash = hash(source);
|
||||
const input = observation();
|
||||
const execute = ((command: string, args: string[], options: any) => {
|
||||
expect(command).toBe(executable); expect(args).toEqual([]);
|
||||
expect(options.timeout).toBeGreaterThan(0); expect(options.timeout).toBeLessThanOrEqual(5000);
|
||||
expect(options.killSignal).toBe('SIGKILL'); expect(options.maxBuffer).toBe(16 * 1024);
|
||||
return { status: 0, stdout: JSON.stringify(input), stderr: 'private diagnostic text' };
|
||||
}) as typeof spawnSync;
|
||||
const result = await runGuiReadiness(executable, exeHash, source, sourceHash, false, { HOME: root }, 5000, execute);
|
||||
expect(result.available).toBe(true);
|
||||
expect(JSON.stringify(result)).not.toContain('private diagnostic');
|
||||
await expect(runGuiReadiness(executable, 'c'.repeat(64), source, sourceHash, false, {}, 5000, execute)).rejects.toThrow('gui_readiness_inputs_changed');
|
||||
const malformed = (() => ({ status: 0, stdout: JSON.stringify({ ...input, username: 'private-name' }), stderr: '' })) as typeof spawnSync;
|
||||
expect((await runGuiReadiness(executable, exeHash, source, sourceHash, false, {}, 5000, malformed)).available).toBe(false);
|
||||
for (const timeout of [0, NaN, Infinity]) await expect(runGuiReadiness(executable, exeHash, source, sourceHash, false, {}, timeout, execute)).rejects.toThrow('gui_readiness_budget_exhausted');
|
||||
});
|
||||
|
||||
test('the readiness launcher loads without node_modules or Playwright and rejects conflicting CLI modes', () => {
|
||||
const directory = path.join(root, 'no-dependencies/.github/scripts');
|
||||
mkdirSync(directory, { recursive: true });
|
||||
for (const file of ['run-dia-native-qualification.ts', 'qualify-dia-macos.ts']) copyFileSync(path.resolve(import.meta.dir, '../../.github/scripts', file), path.join(directory, file));
|
||||
for (const args of [['--gui-readiness-only'], ['--gui-readiness-only', '--launch-comparison', 'node']]) {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=/dev/null', path.join(directory, 'run-dia-native-qualification.ts'), ...args],
|
||||
{ cwd: root, env: { HOME: root, PATH: '/usr/bin:/bin' }, encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(2); expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout).reason).toBe('fresh_account_launcher_preflight_failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { compareDiaLaunchReceipts, normalizedLaunchHashes } from '../../.github/scripts/dia-launch-driver.mjs';
|
||||
import { runDiaLaunchComparison, safeDiaComparisonResponse } from '../../.github/scripts/qualify-dia-macos';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'dc-')));
|
||||
const driverFile = path.resolve(import.meta.dir, '../../.github/scripts/dia-launch-driver.mjs');
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('diagnostic-only Dia runtime comparison', () => {
|
||||
test('normalization replaces only declared owned path boundaries', () => {
|
||||
const first = normalizedLaunchHashes(['--user-data-dir=/owned/a/home/profile', '--headless'], { HOME: '/owned/a/home', PATH: '/usr/bin:/bin' }, { home: '/owned/a/home' });
|
||||
const second = normalizedLaunchHashes(['--user-data-dir=/owned/b/home/profile', '--headless'], { HOME: '/owned/b/home', PATH: '/usr/bin:/bin' }, { home: '/owned/b/home' });
|
||||
expect(first).toEqual(second);
|
||||
expect(normalizedLaunchHashes(['--user-data-dir=/owned/a/home-other'], {}, { home: '/owned/a/home' }).argvSha256)
|
||||
.not.toBe(normalizedLaunchHashes(['--user-data-dir=/owned/b/home-other'], {}, { home: '/owned/b/home' }).argvSha256);
|
||||
expect(normalizedLaunchHashes(['--label=https://private.invalid/owned/a/home'], {}, { home: '/owned/a/home' }).argvSha256)
|
||||
.not.toBe(normalizedLaunchHashes(['--label=https://private.invalid/owned/b/home'], {}, { home: '/owned/b/home' }).argvSha256);
|
||||
expect(JSON.stringify(first)).not.toContain('/owned');
|
||||
});
|
||||
|
||||
test('IPC rejects raw payloads at any nesting depth', () => {
|
||||
for (const value of [{ args: ['private-argv'] }, { cleanup: { raw: 'private-value' } }, { error: 'private-error-value' },
|
||||
{ startupPages: { categories: ['https://private.invalid'] } }, { driver: { release: '/private/path' } }]) {
|
||||
expect(safeDiaComparisonResponse(value)).toBe(false);
|
||||
}
|
||||
expect(safeDiaComparisonResponse({ protocol: 1, purpose: 'source', error: 'operation_timeout', cleanup: { confirmed: false } })).toBe(true);
|
||||
});
|
||||
|
||||
test('the actual driver entry refuses an unapproved host without exposing its request', () => {
|
||||
for (const executable of [process.execPath, Bun.which('node')!]) {
|
||||
const result = spawnSync(executable, [driverFile, '/private-untrusted/account.json'], {
|
||||
env: { PATH: '/usr/bin:/bin', HOME: root }, input: '{"private":"request-value"}', encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toEqual({ protocol: 1, ready: false, error: 'driver_admission_failed', cleanup: { confirmed: false } });
|
||||
}
|
||||
});
|
||||
|
||||
test('the real bounded stdin callback works under both runtimes and refuses raw request payloads', () => {
|
||||
const entry = path.join(root, 'stdin-probe.mjs');
|
||||
writeFileSync(entry, `import { readComparisonRequest } from ${JSON.stringify(pathToFileURL(driverFile).href)};
|
||||
try { const request = readComparisonRequest(); process.stdout.write(JSON.stringify({purpose:request.purpose})); }
|
||||
catch { process.stdout.write(JSON.stringify({rejected:true})); }`);
|
||||
for (const executable of [process.execPath, Bun.which('node')!]) {
|
||||
for (const input of ['{"purpose":"control"}', '{"purpose":"control","env":{"PRIVATE":"value"}}', 'x'.repeat(16 * 1024 + 1)]) {
|
||||
const result = spawnSync(executable, [entry], { env: { HOME: root, PATH: '/usr/bin:/bin' }, input, encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toEqual(input === '{"purpose":"control"}' ? { purpose: 'control' } : { rejected: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
test('both pinned runtimes execute the same protected worker with matched normalized inputs', async () => {
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node 24.18 is required for the comparison regression');
|
||||
const entry = path.join(root, 'probe.mjs');
|
||||
const executablePath = realpathSync((await import('playwright')).chromium.executablePath());
|
||||
writeFileSync(entry, `import { runProtectedLaunch } from ${JSON.stringify(pathToFileURL(driverFile).href)};
|
||||
const home = process.env.HOME;
|
||||
const result = await runProtectedLaunch(${JSON.stringify(executablePath)}, home + '/profile',
|
||||
{ HOME: home, TMPDIR: home + '/tmp', PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' }, 'control', { home });
|
||||
process.stdout.write(JSON.stringify(result) + '\\n');`);
|
||||
const results: any[] = [];
|
||||
for (const [name, executable] of [['bun', process.execPath], ['node', node]]) {
|
||||
const home = path.join(root, name);
|
||||
mkdirSync(home);
|
||||
mkdirSync(path.join(home, 'tmp'));
|
||||
const result = spawnSync(executable, [entry], { env: { HOME: home, PATH: '/usr/bin:/bin:/usr/sbin:/sbin' },
|
||||
encoding: 'utf8', timeout: 40_000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
const observed = JSON.parse(result.stdout);
|
||||
expect(observed.ready).toBe(true);
|
||||
expect(observed.protocolResponded).toBe(true);
|
||||
expect(observed.cleanup.confirmed).toBe(true);
|
||||
expect(observed.cleanup.childClosed).toBe(true);
|
||||
expect(observed.cleanup.groupAbsent).toBe(true);
|
||||
expect(observed.cleanup.launchSettled).toBe(true);
|
||||
expect(observed.rootCount).toBe(1);
|
||||
expect(observed.launchAttempts[0].pipeFlag).toBe(true);
|
||||
expect(observed.launchAttempts[0].sandboxDisablingFlag).toBe(false);
|
||||
expect(observed.samplingEnabled).toBe(false);
|
||||
expect(safeDiaComparisonResponse(observed)).toBe(true);
|
||||
expect(JSON.stringify(observed)).not.toContain(home);
|
||||
results.push(observed);
|
||||
}
|
||||
expect(results[0].argvSha256).toBe(results[1].argvSha256);
|
||||
expect(results[0].environmentSha256).toBe(results[1].environmentSha256);
|
||||
}, 90_000);
|
||||
|
||||
const arm = (runtime: 'bun' | 'node', ready: boolean) => {
|
||||
const sha = 'a'.repeat(64);
|
||||
const config = { mode: 'launch-only', runtime, qualificationCredit: false, executableSha256: runtime === 'bun' ? sha : 'b'.repeat(64), driverSha256: sha, helpersSha256: sha };
|
||||
const driver = { runtime, version: runtime === 'bun' ? '1.4.2' : '24.18.0', architecture: 'arm64', os: 'darwin', release: '24.6.0', playwright: '1.62.1',
|
||||
executableSha256: config.executableSha256, driverSha256: sha, helpersSha256: sha };
|
||||
const result = (purpose: string, success: boolean) => ({ protocol: 1, purpose, ready: success, launchReturned: success, protocolResponded: success,
|
||||
samplingEnabled: false, rootCount: 1, supervisor: { closed: true, exitCode: 0 }, cleanup: { confirmed: true, childClosed: true, groupAbsent: true, launchSettled: true,
|
||||
groups: [{ absenceConfirmed: true, childCloseObserved: true }] }, startupPages: { allowed: true }, postProbePages: { allowed: true },
|
||||
argvSha256: sha, environmentSha256: sha, driver, launchAttempts: [{ sandboxRequired: true, sandboxDisablingFlag: false, pipeFlag: true,
|
||||
tcpDebuggingFlag: false, mockKeychainFlag: false, passwordStoreFlag: false, firstRunSuppressed: false, headlessFlag: true,
|
||||
expectedProfile: true, detached: true, shellDisabled: true, stdioCount: 5, extraPipeDescriptors: true, profileArgumentCount: 1 }] });
|
||||
return { launchComparison: config, counts: { pass: 0, fail: 0, skip: 0 }, launcher: { accountGuid: runtime + '-separate-account', sourceRevision: 'c'.repeat(40), archiveSha256: sha, destinationSha256: sha },
|
||||
launcherCleanup: { serviceStopped: true, userDomainStopped: true, userProcessesStopped: true, accountRemoved: true, groupRemoved: true, stagingRemoved: true },
|
||||
backgroundPreflight: { status: 'passed', comparisonControl: result('control', true) },
|
||||
qualification: { launchComparison: { qualificationCredit: false, source: result('source', ready) }, counts: { pass: 0, fail: 0, skip: 0 },
|
||||
keychainStage: 'completed', isolation: { registeredIdentity: true, sharedRegisteredHome: true },
|
||||
cleanup: { ownedBrowsersStopped: true, sourceProfileRemoved: true, keychainRestored: true, mountDetached: true, fixtureRemoved: true },
|
||||
platform: { os: 'darwin', architecture: 'arm64', bun: '1.4.2', playwright: '1.62.1', release: '24.6.0' },
|
||||
artifact: { signatureVerified: true, gatekeeperNotarized: true, macosCompatibility: { compatible: true, hostVersion: '15.7.9' },
|
||||
architectures: ['arm64'], sha256: sha, executableSha256: sha, version: '1.49.1', bundleId: 'company.thebrowser.dia', team: 'S6N382Y83G' } } };
|
||||
};
|
||||
|
||||
test('paired receipts distinguish runtime readiness without awarding qualification credit', () => {
|
||||
expect(compareDiaLaunchReceipts(arm('bun', false), arm('node', true))).toEqual({ comparable: true, qualificationCredit: false, outcome: 'node_only_ready' });
|
||||
expect(compareDiaLaunchReceipts(arm('bun', false), arm('node', false)).outcome).toBe('neither_ready');
|
||||
expect(compareDiaLaunchReceipts(arm('bun', true), arm('node', true)).outcome).toBe('both_ready');
|
||||
});
|
||||
|
||||
test('incomplete or incompatible arms cannot support a runtime conclusion', () => {
|
||||
const left = arm('bun', false);
|
||||
const mutations = [
|
||||
(right: any) => { right.backgroundPreflight.comparisonControl.ready = false; },
|
||||
(right: any) => { right.launcherCleanup.userDomainStopped = false; },
|
||||
(right: any) => { right.qualification.cleanup.sourceProfileRemoved = false; },
|
||||
(right: any) => { right.qualification.artifact.executableSha256 = 'b'.repeat(64); },
|
||||
(right: any) => { right.qualification.launchComparison.source.argvSha256 = 'b'.repeat(64); },
|
||||
(right: any) => { right.qualification.launchComparison.source.launchAttempts[0].sandboxDisablingFlag = true; },
|
||||
(right: any) => { right.qualification.launchComparison.source.driver.version = '24.19.0'; },
|
||||
(right: any) => { right.qualification.launchComparison.source.cleanup.launchSettled = false; },
|
||||
(right: any) => { right.counts.pass = 1; },
|
||||
(right: any) => { right.launcher.accountGuid = left.launcher.accountGuid; },
|
||||
(right: any) => { delete right.qualification.platform; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const right = arm('node', true);
|
||||
mutate(right);
|
||||
expect(compareDiaLaunchReceipts(left, right).comparable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('comparison supervisor binds code hashes and rejects extra private response fields', async () => {
|
||||
const work = path.join(root, 'ipc');
|
||||
mkdirSync(path.join(work, 'bin'), { recursive: true });
|
||||
mkdirSync(path.join(work, 'repo/.github/scripts'), { recursive: true });
|
||||
const executable = path.join(work, 'bin/node');
|
||||
const driver = path.join(work, 'repo/.github/scripts/dia-launch-driver.mjs');
|
||||
const helpers = path.join(work, 'repo/.github/scripts/qualify-dia-macos.ts');
|
||||
for (const file of [executable, driver, helpers]) writeFileSync(file, 'synthetic code');
|
||||
const hash = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
const account: any = { work, snapshot: path.join(work, 'repo'), configFile: path.join(work, 'account.json'), environment: { HOME: work },
|
||||
launchComparison: { mode: 'launch-only', runtime: 'node', executable, executableSha256: hash(executable), driverSha256: hash(driver), helpersSha256: hash(helpers) } };
|
||||
const response: any = { protocol: 1, purpose: 'control', ready: false, launchReturned: false, samplingEnabled: false, cleanup: { confirmed: false },
|
||||
driver: { runtime: 'node', version: '24.18.0', architecture: 'arm64', os: 'darwin', playwright: '1.62.1',
|
||||
executableSha256: hash(executable), driverSha256: hash(driver), helpersSha256: hash(helpers) } };
|
||||
let calls = 0;
|
||||
const execute = ((command: string, args: string[], options: any) => {
|
||||
calls++;
|
||||
expect(command).toBe(executable);
|
||||
expect(args).toEqual([driver, account.configFile]);
|
||||
expect(JSON.parse(options.input)).toEqual({ purpose: 'control' });
|
||||
expect(options.timeout).toBeLessThanOrEqual(40_000);
|
||||
expect(options.maxBuffer).toBe(64 * 1024);
|
||||
return { status: 0, stdout: JSON.stringify(response), stderr: '' };
|
||||
}) as typeof spawnSync;
|
||||
expect((await runDiaLaunchComparison(account, 'control', undefined, execute)).supervisor.exitCode).toBe(0);
|
||||
response.error = 'private-error-value';
|
||||
expect((await runDiaLaunchComparison(account, 'control', undefined, execute)).error).toBe('driver_exchange_failed');
|
||||
writeFileSync(executable, 'changed executable');
|
||||
await expect(runDiaLaunchComparison(account, 'control', undefined, execute)).rejects.toThrow('comparison_driver_inputs_changed');
|
||||
expect(calls).toBe(2);
|
||||
await expect(runDiaLaunchComparison({ ...account, launchComparison: undefined }, 'control', undefined, execute)).rejects.toThrow('comparison_driver_authority_missing');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@ import { afterAll, beforeAll, 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 { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
|
||||
const VALIDATOR = path.resolve(import.meta.dir, '../src/path-security.ts');
|
||||
const WIN = process.platform === 'win32';
|
||||
@@ -43,11 +44,16 @@ function validate(tempDir: string, checks: Array<['output' | 'read', string]>):
|
||||
}
|
||||
|
||||
function shortPath(dir: string): string {
|
||||
const result = Bun.spawnSync(['powershell', '-NoProfile', '-NonInteractive', '-Command',
|
||||
`(New-Object -ComObject Scripting.FileSystemObject).GetFolder($env:GSTACK_LONG_DIR).ShortPath`], {
|
||||
env: { ...process.env, GSTACK_LONG_DIR: dir }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000,
|
||||
});
|
||||
return result.stdout.toString().trim();
|
||||
const kernel = dlopen('kernel32.dll', { GetShortPathNameW: { args: [FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.u32 } });
|
||||
try {
|
||||
const input = Buffer.from(dir + '\0', 'utf16le');
|
||||
const output = Buffer.alloc(65536);
|
||||
const length = kernel.symbols.GetShortPathNameW(ptr(input), ptr(output), 32768);
|
||||
if (length === 0 || length >= 32768) throw new Error(`GetShortPathNameW failed for ${dir} (returned ${length})`);
|
||||
return output.subarray(0, length * 2).toString('utf16le');
|
||||
} finally {
|
||||
kernel.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe('#3104 one realpath for safe dirs and targets', () => {
|
||||
|
||||
@@ -4,7 +4,10 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { isProcessAlive } from '../src/error-handling';
|
||||
import { spawnTerminalAgent } from '../src/terminal-agent-control';
|
||||
import {
|
||||
isAgentRecordGone, isOurAgent, readAgentStartTime, spawnTerminalAgent, stopAgentByRecord, win32CreationTimeSource, writeAgentRecord,
|
||||
type AgentRecord,
|
||||
} from '../src/terminal-agent-control';
|
||||
|
||||
// REGRESSION TEST for the Windows terminal-agent leak.
|
||||
//
|
||||
@@ -117,8 +120,10 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
|
||||
}
|
||||
return originalProbe(command, options);
|
||||
}) as typeof Bun.spawnSync);
|
||||
const powershellCalls: string[] = [];
|
||||
const windowsProbe = spyOn(childProcess, 'spawnSync').mockImplementation(((command: string, args: string[], options: any) => {
|
||||
if (command === 'powershell.exe') {
|
||||
powershellCalls.push(args.join(' '));
|
||||
const owner = args.join(' ').includes(`ProcessId = ${process.pid}'`);
|
||||
return { status: 0, stdout: JSON.stringify(owner ? { CreationDate: 'fixture-owner-start', CommandLine: 'test-owner' } : null), stderr: '' };
|
||||
}
|
||||
@@ -143,7 +148,14 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
|
||||
// Owner-PID lifetime tie (#2019): the agent polls this and exits when
|
||||
// its owning browse server dies, so it can't be adopted by PID 1.
|
||||
expect(captured.env.BROWSE_OWNER_PID).toBe(String(process.pid));
|
||||
expect(captured.env.BROWSE_OWNER_START_TIME).toBe('fixture-owner-start');
|
||||
if (process.platform === 'win32') {
|
||||
expect(captured.env.BROWSE_OWNER_START_TIME).toMatch(/^\/Date\(\d+\)\/$/);
|
||||
expect(captured.env.BROWSE_OWNER_START_TIME).toBe(readAgentStartTime(process.pid));
|
||||
expect(powershellCalls.filter(call => call.includes(`ProcessId = ${process.pid}'`))).toEqual([]);
|
||||
expect(powershellCalls.every(call => call.includes("ProcessId = 2147483647'"))).toBe(true);
|
||||
} else {
|
||||
expect(captured.env.BROWSE_OWNER_START_TIME).toBe('fixture-owner-start');
|
||||
}
|
||||
// Detached background daemon — must not inherit a terminal either.
|
||||
expect(captured.stdio).toEqual(['ignore', 'ignore', 'ignore']);
|
||||
await exited;
|
||||
@@ -174,3 +186,145 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
|
||||
expect(windowMs).toBeGreaterThanOrEqual(tick * guardMax);
|
||||
});
|
||||
});
|
||||
|
||||
describe('win32 terminal-agent identity (GSTA-139)', () => {
|
||||
const pid = 2147483645;
|
||||
const gen = 'win32-identity-generation';
|
||||
const birth = '/Date(1761600000123)/';
|
||||
const ownedCommand = `"C:\\bun\\bun.exe" run terminal-agent.ts --agent-gen=${gen}`;
|
||||
const record = (startTime = birth): AgentRecord => ({ pid, gen, startTime, startedAt: 0, ownerPid: pid, ownerStartTime: birth });
|
||||
type PowerShell = { status: number | null; creationDate?: string; commandLine?: string };
|
||||
|
||||
function onWin32(scenario: { ffi: string | undefined; powershell: PowerShell }, run: (calls: { powershell: number; signals: unknown[] }) => void): void {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
const calls = { powershell: 0, signals: [] as unknown[] };
|
||||
let live = true;
|
||||
const kill = spyOn(process, 'kill').mockImplementation(((target: number, signal: unknown) => {
|
||||
expect(target).toBe(pid);
|
||||
if (!live) throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
if (signal !== 0) { calls.signals.push(signal); live = false; }
|
||||
return true;
|
||||
}) as typeof process.kill);
|
||||
const read = spyOn(win32CreationTimeSource, 'read').mockImplementation((target: number) => (target === pid && live ? scenario.ffi : undefined));
|
||||
const probe = spyOn(childProcess, 'spawnSync').mockImplementation(((command: string) => {
|
||||
expect(command).toBe('powershell.exe');
|
||||
calls.powershell++;
|
||||
const { status, creationDate, commandLine } = scenario.powershell;
|
||||
return status === 0
|
||||
? { status, stdout: JSON.stringify({ CreationDate: creationDate, CommandLine: commandLine }), stderr: '' }
|
||||
: { status, stdout: '', stderr: '', error: Object.assign(new Error('spawnSync powershell.exe ETIMEDOUT'), { code: 'ETIMEDOUT' }) };
|
||||
}) as typeof childProcess.spawnSync);
|
||||
Object.defineProperty(process, 'platform', { ...platform, value: 'win32' });
|
||||
try {
|
||||
run(calls);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
probe.mockRestore();
|
||||
read.mockRestore();
|
||||
kill.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
test('the start time is read in-process, so a PowerShell launch killed at its timeout cannot empty it', () => {
|
||||
onWin32({ ffi: birth, powershell: { status: null } }, calls => {
|
||||
expect(readAgentStartTime(pid)).toBe(birth);
|
||||
expect(calls.powershell).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('without the in-process reader (Node daemon) the start time falls back to the PowerShell CreationDate', () => {
|
||||
onWin32({ ffi: undefined, powershell: { status: 0, creationDate: birth, commandLine: ownedCommand } }, calls => {
|
||||
expect(readAgentStartTime(pid)).toBe(birth);
|
||||
expect(calls.powershell).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('kill-time ownership still requires the generation in the PowerShell command line', () => {
|
||||
onWin32({ ffi: birth, powershell: { status: 0, creationDate: birth, commandLine: ownedCommand } }, calls => {
|
||||
expect(isOurAgent(record())).toBe(true);
|
||||
expect(stopAgentByRecord(record(), 0)).toBe(true);
|
||||
expect(calls.signals).toEqual(['SIGTERM']);
|
||||
});
|
||||
for (const powershell of [{ status: 0, creationDate: birth, commandLine: 'bun run unrelated.ts' }, { status: null }]) {
|
||||
onWin32({ ffi: birth, powershell }, calls => {
|
||||
expect(isOurAgent(record())).toBe(false);
|
||||
expect(isAgentRecordGone(record())).toBe(false);
|
||||
expect(stopAgentByRecord(record(), 0)).toBe(false);
|
||||
expect(calls.signals).toEqual([]);
|
||||
});
|
||||
}
|
||||
onWin32({ ffi: '/Date(1761600000124)/', powershell: { status: 0, creationDate: birth, commandLine: 'bun run unrelated.ts' } }, calls => {
|
||||
expect(isAgentRecordGone(record())).toBe(true);
|
||||
expect(calls.signals).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('a start time that disagrees across readers still identifies the live agent by its generation, so no second agent starts', () => {
|
||||
const daemonRecorded = '/Date(1761596400123)/';
|
||||
onWin32({ ffi: birth, powershell: { status: 0, creationDate: daemonRecorded, commandLine: ownedCommand } }, calls => {
|
||||
expect(isAgentRecordGone(record(daemonRecorded))).toBe(false);
|
||||
expect(isOurAgent(record(daemonRecorded))).toBe(true);
|
||||
expect(stopAgentByRecord(record(daemonRecorded), 0)).toBe(true);
|
||||
expect(calls.signals).toEqual(['SIGTERM']);
|
||||
});
|
||||
onWin32({ ffi: birth, powershell: { status: null } }, calls => {
|
||||
expect(isAgentRecordGone(record(daemonRecorded))).toBe(false);
|
||||
expect(stopAgentByRecord(record(daemonRecorded), 0)).toBe(false);
|
||||
expect(calls.signals).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('an unreadable start time is unknown: never signalled and never treated as gone', () => {
|
||||
onWin32({ ffi: undefined, powershell: { status: null } }, calls => {
|
||||
expect(readAgentStartTime(pid)).toBe('');
|
||||
expect(isOurAgent(record())).toBe(false);
|
||||
expect(isAgentRecordGone(record())).toBe(false);
|
||||
expect(stopAgentByRecord(record(), 0)).toBe(false);
|
||||
expect(calls.signals).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('the in-process start time equals the CreationDate earlier releases recorded', () => {
|
||||
const child = Bun.spawn([process.execPath, '-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
try {
|
||||
for (const target of [process.pid, child.pid]) {
|
||||
const script = `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${target}' | Select-Object CreationDate | ConvertTo-Json -Compress)`;
|
||||
const legacy = childProcess.spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', windowsHide: true, timeout: 60_000 });
|
||||
expect(legacy.status).toBe(0);
|
||||
const recorded = JSON.parse(legacy.stdout).CreationDate;
|
||||
expect(recorded).toMatch(/^\/Date\(\d+\)\/$/);
|
||||
expect(win32CreationTimeSource.read(target)).toBe(recorded);
|
||||
expect(readAgentStartTime(target)).toBe(recorded);
|
||||
}
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
}, 150_000);
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('a live agent whose identity cannot be read blocks a second agent', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-unknown-'));
|
||||
const script = path.join(tmpDir, 'fake-agent.ts');
|
||||
fs.writeFileSync(script, '// no-op\n');
|
||||
const live = Bun.spawn([process.execPath, '-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
writeAgentRecord(tmpDir, { pid: live.pid, gen, startTime: birth, startedAt: 0, ownerPid: process.pid, ownerStartTime: birth });
|
||||
const original = childProcess.spawnSync;
|
||||
const probe = spyOn(childProcess, 'spawnSync').mockImplementation(((command: string, args: string[], options: any) => (
|
||||
command === 'powershell.exe' ? { status: null, stdout: '', stderr: '' } : original(command, args, options)
|
||||
)) as typeof childProcess.spawnSync);
|
||||
const read = spyOn(win32CreationTimeSource, 'read').mockImplementation(() => undefined);
|
||||
const origSpawn = (Bun as any).spawn;
|
||||
let spawned = 0;
|
||||
(Bun as any).spawn = () => { spawned++; throw new Error('a second agent must not start'); };
|
||||
try {
|
||||
expect(spawnTerminalAgent({ stateFile: path.join(tmpDir, 'state.json'), serverPort: 12345, ownerPid: process.pid, cwd: tmpDir, scriptPath: script })).toBeNull();
|
||||
expect(spawned).toBe(0);
|
||||
expect(isProcessAlive(live.pid)).toBe(true);
|
||||
} finally {
|
||||
(Bun as any).spawn = origSpawn;
|
||||
read.mockRestore();
|
||||
probe.mockRestore();
|
||||
live.kill();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -967,8 +967,8 @@ with `GSTACK_FREE_RETRY_FLAKY=0`.
|
||||
A stock Ubuntu 24.04 VM differs from a GitHub-hosted runner in three ways that
|
||||
the scripts correct: the login umask is `002` (group-writable directories fail
|
||||
the CSO private-state checks), AppArmor blocks the unprivileged user
|
||||
namespaces Chromium's sandbox needs, and `clang` and `python3-venv` are absent
|
||||
(required by the Dia readiness and Python runner tests).
|
||||
namespaces Chromium's sandbox needs, and `python3-venv` is absent (required by
|
||||
the Python runner tests).
|
||||
|
||||
VMs are named `ubirun-<owner>-<epoch>-<hex>`, plus a `-t<hours>` TTL suffix
|
||||
(below); `<owner>` is `UBI_OWNER` (set it to the thread id on a Capy machine;
|
||||
@@ -1427,7 +1427,13 @@ Free-suite flake rules:
|
||||
every shard child's `GIT_CONFIG_*` pairs as a backstop, so it covers fixtures
|
||||
that inherit the environment, and `bun run scripts/ship-measure.ts free
|
||||
--backend local` measures with that environment; a fixture with its own
|
||||
replacement env never sees the backstop.
|
||||
replacement env never sees the backstop. Neither does the receiving side of
|
||||
a push to a local path: git's local transport drops `GIT_CONFIG_*` from the
|
||||
`receive-pack` it starts, so every push into a fixture's bare origin still
|
||||
leaves a detached maintenance run in that origin. A fixture that clones such
|
||||
an origin passes `--no-local` (`coordinatorClone` in
|
||||
`test/helpers/ship-queue-fixture.ts`), so the clone reads it through git's
|
||||
transport instead of copying `objects/` entry by entry.
|
||||
|
||||
To reproduce a ledger entry, take the shard's exact file list from the CI run's
|
||||
`free-plan` artifact, then rerun it with the flaky retry off on a CI-mirrored VM:
|
||||
@@ -1523,10 +1529,10 @@ on win32, drives a POSIX signal path) leaves it through a
|
||||
the file moved.
|
||||
|
||||
Native qualification campaigns (Windows cookie extraction, Windows launch
|
||||
diagnostics, Dia on macOS) run only on dispatch of `native-qualification.yml`:
|
||||
diagnostics) run only on dispatch of `native-qualification.yml`:
|
||||
|
||||
```bash
|
||||
gh workflow run native-qualification.yml --ref <branch> -f mode=<cookie-native|windows-native-diagnostics|dia-native|dia-launch-comparison|dia-gui-readiness>
|
||||
gh workflow run native-qualification.yml --ref <branch> -f mode=<cookie-native|windows-native-diagnostics>
|
||||
```
|
||||
|
||||
### Real-setup install fixture
|
||||
|
||||
@@ -63,7 +63,7 @@ The plan-ceo-review-plan-mode (:89, :108), plan-devex-review-plan-mode (:181-222
|
||||
| auq-format-gate | test/skill-e2e-ask-user-question-format-compliance.test.ts:58 | question ~ `options differ in kind` | AUQ tool input | yes | (c) |
|
||||
| auq-format-gate | test/skill-e2e-ask-user-question-format-compliance.test.ts:61, :68 | recommendation substance ≥ 4 | — | — | judge |
|
||||
| plan-tune-inspect | test/skill-e2e-plan-tune.test.ts:167-169, :185 | output names ≥2 of 3 seeded question IDs or aliases | question-log is seeded fixture input | yes (inspection report) | (c) |
|
||||
| plan-review-report | test/skill-e2e-plan.test.ts:463-491 | plan.md `## GSTACK REVIEW REPORT`, CEO/Eng/Design rows, ends with `NO UNRESOLVED DECISIONS` or a bullet | rows come from the seeded review log; the table is rendered | yes | (c) |
|
||||
| plan-review-report | test/skill-e2e-plan.test.ts:472-500 | plan.md `## GSTACK REVIEW REPORT`, CEO/Eng/Design rows, ends with `NO UNRESOLVED DECISIONS` or a bullet | rows come from the seeded review log; the table is rendered | yes | (c) |
|
||||
|
||||
### test/skill-e2e-design.test.ts (gate cases)
|
||||
|
||||
@@ -89,7 +89,7 @@ The plan-ceo-review-plan-mode (:89, :108), plan-devex-review-plan-mode (:181-222
|
||||
|---|---|---|---|---|---|
|
||||
| shared-libs-read-only | test/skill-e2e-shared-libs.test.ts:113-120 | report ~ uncommitted/overlay/raw, tip and branch SHAs, retry-worker/route, no blob link for branch-only file | none (read-only by contract) | yes | (c) |
|
||||
| shared-libs-unsupported-git | test/skill-e2e-shared-libs.test.ts:136, :144 | report ~ unavailable/unsupported/limited; not "no commits/PRs found" | none | yes | (c) |
|
||||
| shared-libs-review-path-eligibility | test/skill-e2e-shared-libs-paths.test.ts:133-134 | `trace + output` ~ submodule/160000, check-ignore/ignored (output alone satisfies) | Bash command trace (first operand) | no | (b) |
|
||||
| shared-libs-review-path-eligibility | test/skill-e2e-shared-libs-paths.test.ts:134-135 | `trace + output` ~ submodule/160000, check-ignore/ignored (output alone satisfies) | Bash command trace (first operand) | no | (b) |
|
||||
|
||||
### test/skill-e2e-ship-docsync.test.ts, test/skill-e2e-docsync-spawned.test.ts
|
||||
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples.
|
||||
| [`/document-generate`](#document-generate) | **Technical Writer** | Generate Diataxis docs (tutorial / how-to / reference / explanation) for a feature from code. |
|
||||
| [`/retro`](#retro) | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. |
|
||||
| [`/browse`](#browse) | **QA Engineer** | Give the agent eyes. Drives your Aside browser first — real sessions, real clicks, real screenshots — through deterministic `aside repl` scripts, and falls back to gstack's own Chromium (~100ms per command) when Aside isn't there. |
|
||||
| [`/setup-browser-cookies`](#setup-browser-cookies) | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet, Arc, and Dia into the fallback browser. Choose your profile and domains; check sign-in separately. Unnecessary on Aside, which already has your sessions. |
|
||||
| [`/setup-browser-cookies`](#setup-browser-cookies) | **Session Manager** | Copy selected cookies from Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, or macOS-only Comet and Arc into the fallback browser. Choose your profile and domains; check sign-in separately. Unnecessary on Aside, which already has your sessions. |
|
||||
| [`/autoplan`](#autoplan) | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → DX → eng review automatically (eng always last, so the shipping gate reviews the final amended plan) with encoded decision principles. Surfaces only taste decisions for your approval. |
|
||||
| [`/plan-devex-review`](#plan-devex-review) | **DX Reviewer** | Plan-stage DX review. TTHW (time-to-hello-world), magical moments, friction points, persona traces, and error messages scored for the agents that read them ([agent-ready errors](agent-ready-errors.md)). Three modes: Expansion, Polish, Triage. |
|
||||
| [`/devex-review`](#devex-review) | **DX Reviewer (live)** | Live developer experience audit. Walks the actual onboarding flow, measures TTHW, catches the docs lies, and triggers CLI errors the way an agent meets them (no terminal, contained, redacted) through `gstack-error-capture`. |
|
||||
@@ -1179,7 +1179,7 @@ This is my **session manager mode** — for the fallback browser. With Aside ope
|
||||
|
||||
For authenticated testing on gstack's own browser, `/setup-browser-cookies` copies selected cookies from your daily browser. Sites may also need storage or a fresh login, so copying cookies is not proof that the session works.
|
||||
|
||||
The picker detects Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet, Arc, and Dia. Choose the browser, account/profile, and domains. Profile labels use the current `Local State` name with a directory discriminator, so renamed profiles and duplicate names are distinguishable. No cookie values are displayed; source/profile labels are still sensitive.
|
||||
The picker detects Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet and Arc. Choose the browser, account/profile, and domains. Profile labels use the current `Local State` name with a directory discriminator, so renamed profiles and duplicate names are distinguishable. No cookie values are displayed; source/profile labels are still sensitive.
|
||||
|
||||
```
|
||||
You: /setup-browser-cookies
|
||||
|
||||
+1
-1
@@ -103,7 +103,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] [--clear-storage] [--verify-auth]`: Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet, arc, dia.
|
||||
- `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]`: Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet or arc.
|
||||
- `dialog-accept [text]`: Auto-accept next alert/confirm/prompt.
|
||||
- `dialog-dismiss`: Auto-dismiss next dialog
|
||||
- `fill <sel> <val>`: Fill input
|
||||
|
||||
+15
-10
@@ -13,8 +13,8 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { gateCost, gateCostSentence } from './autoplan-gate-cost';
|
||||
import { recHash, type GateItem } from './gate-list';
|
||||
import { gateCost, gateCostSentence, priorChoices } from './autoplan-gate-cost';
|
||||
import { parseReply, recHash, type GateItem } from './gate-list';
|
||||
import { renderUrgentBlock } from './headless-artifacts';
|
||||
import { findSection, firstTitle } from './markdown-sections';
|
||||
import type { BriefInputs } from './owner-brief';
|
||||
@@ -75,7 +75,12 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
|
||||
const missing = sections.filter(s => !s.found || !s.found.body.trim()).map(s => s.title);
|
||||
const urgent = inputs.findings.some(r => r.urgent === true || r.outside_plan === true) ? [renderUrgentBlock(inputs.findings).trimEnd(), ''] : [];
|
||||
const items = list.items.filter(i => i.id !== 'p1');
|
||||
const recText = (i: GateItem) => plainOption(i.options.find(x => x.key === i.recommended)?.text ?? i.recommended);
|
||||
const prior = priorChoices(o.outDir, list.gate_rev);
|
||||
const rec = parseReply('rec', list, undefined, prior).choices;
|
||||
const recKey = (i: GateItem) => rec.find(c => c.id === i.id)?.chosen ?? i.recommended;
|
||||
const recOption = (i: GateItem) => i.options.find(x => x.key === recKey(i));
|
||||
const kept = items.filter(i => rec.find(c => c.id === i.id)?.prior);
|
||||
const keptNote = (i: GateItem) => i.carried?.chosen === recKey(i) ? ` (your answer at gate_rev ${i.carried.gate_rev}, carried forward)` : ' (your answer at this gate)';
|
||||
|
||||
let header: string[];
|
||||
if (!pending) {
|
||||
@@ -84,8 +89,8 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
|
||||
header = [`**Not ready for approval:** the plan "${title}" is missing ${missing.join(', ')}. The thread will add them and send this again; do not approve from this draft.`];
|
||||
} else {
|
||||
header = [
|
||||
`**Decision needed:** approve the plan "${title}". Reply **rec** to take every recommendation (recommended), or say in words what to change.`,
|
||||
`${items.length ? `${items.length} choice${items.length === 1 ? '' : 's'} below; rec takes the recommended answer for each.` : 'No open choices besides the approval itself.'} Nothing is built until you answer.`,
|
||||
`**Decision needed:** approve the plan "${title}". Reply **rec** to ${kept.length ? 'keep the answers you already gave and take every other recommendation' : 'take every recommendation'} (recommended), or say in words what to change.`,
|
||||
`${items.length ? `${items.length} choice${items.length === 1 ? '' : 's'} below; rec ${kept.length ? `keeps the ${kept.length} you already answered and takes the recommended answer for the rest` : 'takes the recommended answer for each'}.` : 'No open choices besides the approval itself.'} Nothing is built until you answer.`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -94,15 +99,15 @@ export function renderCaptainBrief(inputs: BriefInputs, o: { outDir: string }):
|
||||
if (!pending) need.push('Nothing. This gate is answered.', '');
|
||||
else {
|
||||
need.push(`One decision: approve the plan.`, '');
|
||||
need.push(`- **rec (recommended):** ${p1 ? plainOption(p1.options.find(x => x.key === p1.recommended)?.text ?? 'approve') : 'approve'}${items.length ? ', taking these recommendations:' : '.'}`);
|
||||
items.forEach((i, n) => need.push(` ${n + 1}. ${stripRef(i.title)}: ${recText(i)}${i.options.find(x => x.key === i.recommended)?.changes_plan ? ' (changes the plan)' : ''}.`));
|
||||
need.push(`- **rec (recommended):** ${p1 ? plainOption(recOption(p1)?.text ?? 'approve') : 'approve'}${items.length ? `, taking these ${kept.length ? 'answers' : 'recommendations'}:` : '.'}`);
|
||||
items.forEach((i, n) => need.push(` ${n + 1}. ${stripRef(i.title)}: ${plainOption(recOption(i)?.text ?? recKey(i))}${kept.includes(i) ? keptNote(i) : recOption(i)?.changes_plan ? ' (changes the plan)' : ''}.`));
|
||||
const example = items.find(i => i.options.length > 1);
|
||||
const alt = example?.options.find(x => x.key !== example.recommended);
|
||||
const alt = example?.options.find(x => x.key !== recKey(example));
|
||||
need.push(`- **Change something:** say which choice and what you want instead, in words${example && alt ? ` (for example "${clip(stripRef(example.title), 60)}: ${plainOption(alt.text)}")` : ''}. A change that alters the plan reopens its review before anything is built.`);
|
||||
need.push('- **Reject:** nothing is built.', '');
|
||||
if (!missing.length) need.push(`- **do it:** rec at full scope: every recommendation above, the follow-ons the plan names (docs, trains, cleanups) included, carried through to done with no further ask.`, '', 'Reply: **rec**, or **do it**', '');
|
||||
if (!missing.length) need.push(`- **do it:** rec at full scope: every ${kept.length ? 'choice' : 'recommendation'} above, the follow-ons the plan names (docs, trains, cleanups) included, carried through to done with no further ask.`, '', 'Reply: **rec**, or **do it**', '');
|
||||
}
|
||||
const tokens = list.items.map(i => `${i.id}${i.recommended}`).join(' ');
|
||||
const tokens = rec.map(c => `${c.id}${c.chosen}`).join(' ');
|
||||
const trailer = `_For the relaying agent, not the person:_ run ${list.run}, gate_rev=${list.gate_rev}, rec_hash=${hash}; rec resolves to ${tokens}; plan ${plan.file ?? '(missing)'} (sha256 ${plan.sha}). Answer with ` +
|
||||
`\`gstack-autoplan answer --out ${o.outDir} --gate-rev ${list.gate_rev} --reply rec --rec-hash ${hash} --by agent:<id> --owner-reply "<their exact words>"\`, then \`gstack-approvals import --autoplan ${o.outDir} --by <name>\`.`;
|
||||
// The price sits above the trailer: the relaying agent sends everything above that line, so the person approving sees it.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.295",
|
||||
"version": "1.91.302",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -57,9 +57,6 @@
|
||||
"browse/test/daemon-log-hygiene.test.ts": 32,
|
||||
"browse/test/daemon-mismatch-refuse.test.ts": 105,
|
||||
"browse/test/data-platform.test.ts": 21,
|
||||
"browse/test/dia-gui-readiness.test.ts": 895,
|
||||
"browse/test/dia-launch-comparison.test.ts": 2807,
|
||||
"browse/test/dia-macos-qualification.test.ts": 2829,
|
||||
"browse/test/domain-skills-e2e.test.ts": 520,
|
||||
"browse/test/domain-skills-storage.test.ts": 232,
|
||||
"browse/test/dotenv-isolation.test.ts": 405,
|
||||
|
||||
@@ -34,10 +34,6 @@
|
||||
"browse/test/cookie-import-reliability.test.ts\tTS2769\tNo overload matches this call. The last overload gave the following error. Argument of type 'number' is not assignable to parameter of type 'Function'.": 1,
|
||||
"browse/test/cookie-import-transport.test.ts\tTS2741\tProperty 'preconnect' is missing in type '() => Promise<Response>' but required in type 'typeof fetch'.": 2,
|
||||
"browse/test/cookie-import-transport.test.ts\tTS2741\tProperty 'preconnect' is missing in type '() => Promise<never>' but required in type 'typeof fetch'.": 1,
|
||||
"browse/test/dia-gui-readiness.test.ts\tTS2352\tConversion of type '() => { status: number; stderr: string; stdout: string; }' to type '{ (command: string): SpawnSyncReturns<NonSharedBuffer>; (command: string, options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<...>; (command: string, options: SpawnSyncOptionsWithBufferEncoding): SpawnSyncReturns<...>; (command: string, options?: SpawnSyncOptions | undefined): SpawnSyncReturns<...>; (comm...' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Type '{ status: number; stderr: string; stdout: string; }' is missing the following properties from type 'SpawnSyncReturns<NonSharedBuffer>': pid, output, signal": 1,
|
||||
"browse/test/dia-launch-comparison.test.ts\tTS7016\tCould not find a declaration file for module '../../.github/scripts/dia-launch-driver.mjs'. '.github/scripts/dia-launch-driver.mjs' implicitly has an 'any' type.": 1,
|
||||
"browse/test/dia-macos-qualification.test.ts\tTS2352\tConversion of type '() => { status: number; stderr: string; stdout: string; }' to type '{ (command: string): SpawnSyncReturns<NonSharedBuffer>; (command: string, options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<...>; (command: string, options: SpawnSyncOptionsWithBufferEncoding): SpawnSyncReturns<...>; (command: string, options?: SpawnSyncOptions | undefined): SpawnSyncReturns<...>; (comm...' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Type '{ status: number; stderr: string; stdout: string; }' is missing the following properties from type 'SpawnSyncReturns<NonSharedBuffer>': pid, output, signal": 2,
|
||||
"browse/test/dia-macos-qualification.test.ts\tTS2769\tNo overload matches this call. The last overload gave the following error. Argument of type 'string' is not assignable to parameter of type '\"browser_profile_unavailable\" | \"code_signing_error\" | \"debugging_pipe_unavailable\" | \"default_profile_policy\" | \"dynamic_library_error\" | \"graphics_or_bootstrap_error\" | \"keychain_access_failed\" | \"keychain_interaction_disallowed\" | \"keychain_interaction_required\"'.": 1,
|
||||
"browse/test/domain-skills-e2e.test.ts\tTS2339\tProperty 'cleanup' does not exist on type 'BrowserManager'.": 1,
|
||||
"browse/test/extension-token.test.ts\tTS2353\tObject literal may only specify known properties, and 'idleTimeoutMs' does not exist in type 'ServerConfig'.": 1,
|
||||
"browse/test/handoff.test.ts\tTS2339\tProperty 'pid' does not exist on type 'never'.": 1,
|
||||
|
||||
@@ -11,7 +11,7 @@ NODE_SHA256=1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
sudo -E apt-get update -qq
|
||||
sudo -E apt-get install -y -qq --no-install-recommends \
|
||||
git curl unzip zip xz-utils ca-certificates build-essential clang python3-venv jq \
|
||||
git curl unzip zip xz-utils ca-certificates build-essential python3-venv jq \
|
||||
xvfb x11-utils poppler-utils fonts-noto-color-emoji zsh >/dev/null
|
||||
|
||||
# Ubuntu 24.04 blocks unprivileged user namespaces, which Chromium's sandbox
|
||||
|
||||
@@ -201,6 +201,6 @@ Never request or publish cookie values, passwords, identity or profile text, ses
|
||||
|
||||
## Platform boundaries
|
||||
|
||||
Dia is macOS-only. Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts.
|
||||
Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts.
|
||||
|
||||
Windows supports DPAPI-compatible cookies, not all App-Bound Encryption; native extraction stays disabled pending qualification. Closing Chrome cannot bypass Chrome 136+ default-directory protection, including numbered profiles. No TCP fallback or real-profile copies. Offer headed manual sign-in only with a display available.
|
||||
@@ -77,6 +77,6 @@ Never request or publish cookie values, passwords, identity or profile text, ses
|
||||
|
||||
## Platform boundaries
|
||||
|
||||
Dia is macOS-only. Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts.
|
||||
Keychain approval is the user's choice. Database retries are bounded; permission denial needs user action, not repeated prompts.
|
||||
|
||||
Windows supports DPAPI-compatible cookies, not all App-Bound Encryption; native extraction stays disabled pending qualification. Closing Chrome cannot bypass Chrome 136+ default-directory protection, including numbered profiles. No TCP fallback or real-profile copies. Offer headed manual sign-in only with a display available.
|
||||
@@ -4,7 +4,7 @@
|
||||
* every choice rec takes shown in words, gate ids only on the agent line, the
|
||||
* rec_hash binding, and the incomplete and answered states.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
@@ -13,6 +13,8 @@ import { capyRelayLine, renderCaptainBrief } from '../lib/captain-brief';
|
||||
import { parseReply, recHash, type GateList } from '../lib/gate-list';
|
||||
import { findSection, headings } from '../lib/markdown-sections';
|
||||
import { loadBriefInputs } from '../lib/owner-brief';
|
||||
import { capyRunner, phaseVia, readRunJson, writePlan } from './helpers/autoplan-capy';
|
||||
import { SMALL_PLAN } from './helpers/autoplan-run-fixture';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const dirs: string[] = [];
|
||||
@@ -151,3 +153,89 @@ describe('gstack-owner-brief render --captain', () => {
|
||||
expect(capyRelayLine('/r', {})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('carried decisions at a cap gate (#3546)', () => {
|
||||
const r = capyRunner();
|
||||
afterAll(() => r.cleanup());
|
||||
const SECTIONS = '\n## Before → after\n\nBefore: retries hammer. After: they back off.\n\n## Why it matters\n\nOutages.\n\n## What caused this work\n\nIncident 7.\n\n## Big decisions\n\n1. Exponential backoff.\n';
|
||||
const PLAN = SMALL_PLAN + SECTIONS;
|
||||
const WORDS: Record<string, string> = { 'keep your direction': 'keep the plan as written', 'accept the change': "make the reviewer's change" };
|
||||
|
||||
function cappedRun(): string {
|
||||
const dir = path.join(r.host.base, 'carried');
|
||||
const plan = writePlan(dir, PLAN);
|
||||
const out = path.join(dir, 'run');
|
||||
expect(r.ap(['next', '--out', out, '--plan', plan, '--no-ui', '--max-rounds', '1']).status).toBe(0);
|
||||
const challenge = (title: string) => ({ severity: 'Medium', title, fix: `change: ${title}`, user_challenge: true, changes: 'scope' });
|
||||
phaseVia(r, out, 'ceo', { native: [challenge('Use a queue table'), challenge('Add metrics')], outside: [challenge('Drop jitter')] });
|
||||
phaseVia(r, out, 'eng', { native: [], outside: [] });
|
||||
const ds = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')).items.filter((i: any) => i.id.startsWith('d'));
|
||||
expect(ds.map((i: any) => i.recommended)).toEqual(['a', 'a', 'a']);
|
||||
expect(r.ap(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', `${ds.map((i: any) => `${i.id}b`).join(' ')} p1b`]).status).toBe(0);
|
||||
const v2 = path.join(dir, 'plan-v2.md');
|
||||
fs.writeFileSync(v2, PLAN.replace('cap 30 s', 'cap 60 s, dead-letter after 8 attempts'));
|
||||
const rev = r.ap(['revise', '--out', out, '--plan', v2, '--incorporates', readRunJson(out).amendments.map((a: any) => a.decision).join(',')]);
|
||||
expect(rev.stdout).toMatch(/^REVISED: plan=[0-9a-f]{12} reopened=none capped=ceo,eng$/m);
|
||||
return out;
|
||||
}
|
||||
const cli = (bin: string, args: string[]) => spawnSync('bun', [path.join(ROOT, 'bin', bin), ...args], { encoding: 'utf8', timeout: 60_000 }).stdout ?? '';
|
||||
function renders(out: string) {
|
||||
const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList;
|
||||
const gateText = cli('gstack-gate', ['render', path.join(out, 'gate.json'), '--all']);
|
||||
const briefText = cli('gstack-owner-brief', ['render', '--captain', '--out', out]);
|
||||
const gateRec = JSON.parse(/\`rec\` resolves to: (\{.*\})$/m.exec(gateText)![1]!).choices as Record<string, string>;
|
||||
const briefRec = Object.fromEntries(/rec resolves to ([a-z0-9 ]+);/.exec(briefText)![1]!.split(' ').map(t => [t.slice(0, -1), t.slice(-1)]));
|
||||
return { gate, gateText, briefText, gateRec, briefRec };
|
||||
}
|
||||
let out = '';
|
||||
beforeAll(() => { out = cappedRun(); });
|
||||
|
||||
test('the Captain brief resolves rec to the carried choices, as gstack-gate render does, with the same choice labels', () => {
|
||||
const { gate, gateText, briefText, gateRec, briefRec } = renders(out);
|
||||
expect(gate.gate_rev).toBe(2);
|
||||
expect(gate.items.some(i => i.cap?.kind === 'delta')).toBe(true);
|
||||
const carried = gate.items.filter(i => i.carried);
|
||||
expect(carried.map(i => [i.recommended, i.carried!.chosen])).toEqual([['a', 'b'], ['a', 'b'], ['a', 'b']]);
|
||||
expect(briefRec).toEqual(gateRec);
|
||||
expect(Object.fromEntries(carried.map(i => [i.id, gateRec[i.id]]))).toEqual({ d1: 'b', d2: 'b', d3: 'b' });
|
||||
gate.items.filter(i => i.id !== 'p1').forEach((item, n) => {
|
||||
const text = item.options.find(o => o.key === gateRec[item.id])!.text;
|
||||
expect(gateText).toContain(` ${item.id}${gateRec[item.id]}) ${text}`);
|
||||
expect(briefText).toContain(` ${n + 1}. ${item.title.replace(/\s*\[[^\]]*\]\s*$/, '')}: ${WORDS[text] ?? text}`);
|
||||
});
|
||||
expect(briefText).toContain("Use a queue table: make the reviewer's change (your answer at gate_rev 1, carried forward).");
|
||||
expect(briefText).toContain('rec keeps the 3 you already answered and takes the recommended answer for the rest.');
|
||||
expect(briefText).toContain('Reply **rec** to keep the answers you already gave and take every other recommendation (recommended)');
|
||||
});
|
||||
|
||||
test('an answer already given at this gate is what rec keeps, as gstack-autoplan answer --reply rec records it', () => {
|
||||
const copy = path.join(r.host.base, 'prior');
|
||||
fs.cpSync(out, copy, { recursive: true });
|
||||
const partial = r.ap(['answer', '--by', 'human', '--out', copy, '--gate-rev', '2', '--reply', 'd2a']);
|
||||
expect(partial.stdout).toMatch(/^GATE_PENDING: /m);
|
||||
const { briefText, briefRec } = renders(copy);
|
||||
expect(briefRec).toMatchObject({ d1: 'b', d2: 'a', d3: 'b' });
|
||||
expect(briefText).toContain('Add metrics: keep the plan as written (a reviewer suggested changing it) (your answer at this gate).');
|
||||
const answered = r.ap(['answer', '--by', 'human', '--out', copy, '--gate-rev', '2', '--reply', 'rec']);
|
||||
expect(answered.stdout).toMatch(/^APPROVED: gate_rev=2/m);
|
||||
const recorded = fs.readFileSync(path.join(copy, 'decisions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(d => d.gate_rev === 2);
|
||||
expect(Object.fromEntries(recorded.map(d => [d.label.toLowerCase(), d.chosen]))).toEqual(briefRec);
|
||||
});
|
||||
|
||||
test('negative control: without carried decisions (gate items and their decision rows) both resolve rec to the recommendations', () => {
|
||||
const copy = path.join(r.host.base, 'uncarried');
|
||||
fs.cpSync(out, copy, { recursive: true });
|
||||
const gate = JSON.parse(fs.readFileSync(path.join(copy, 'gate.json'), 'utf8')) as GateList;
|
||||
expect(Object.fromEntries(gate.items.map(i => [i.id, i.recommended]))).not.toEqual(renders(out).gateRec);
|
||||
for (const item of gate.items) delete item.carried;
|
||||
fs.writeFileSync(path.join(copy, 'gate.json'), JSON.stringify(gate));
|
||||
const rows = fs.readFileSync(path.join(copy, 'decisions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
fs.writeFileSync(path.join(copy, 'decisions.jsonl'), rows.map(d => JSON.stringify(d.carried_from ? { ...d, status: 'pending', chosen: undefined, answered_at: undefined, reply: undefined, carried_from: undefined } : d)).join('\n') + '\n');
|
||||
const { briefText, gateRec, briefRec } = renders(copy);
|
||||
expect(briefRec).toEqual(gateRec);
|
||||
expect(gateRec).toEqual(Object.fromEntries(gate.items.map(i => [i.id, i.recommended])));
|
||||
expect(briefText).toContain('Use a queue table: keep the plan as written (a reviewer suggested changing it).');
|
||||
expect(briefText).not.toContain('carried forward');
|
||||
expect(briefText).toContain('rec takes the recommended answer for each.');
|
||||
});
|
||||
});
|
||||
@@ -234,16 +234,65 @@ test('the installed docs-candidate helper is admitted only with a private .json
|
||||
expect(docsNativeInterface(fixture)).toContain('compare <that .json> and --help commands');
|
||||
});
|
||||
|
||||
// Train gate 39904966815 (ship-docsync-completion, attempt 1) and GSTA-63's focused run joined the candidate's
|
||||
// --help onto a line of reads. The join stays refused; the own-call rule now sits in the sentence naming --help.
|
||||
test('a candidate --help joined to reads stays refused, and the interface says so where it names the command', () => {
|
||||
// Train gate 39904966815 and GSTA-63's focused run joined the candidate's --help onto a line of reads; v1.91.185.0 moved the
|
||||
// own-call rule into the sentence naming --help and the slip came back four times (JOINED_READ_LINES below). The --help only
|
||||
// prints usage, so it now joins a line of reads like a Git read; every other own-call command stays alone.
|
||||
test('a candidate --help joined to reads is a read, and the interface names it as the one exception to the own-call rule', () => {
|
||||
const skills = fixture.skills.split(path.sep).join('/');
|
||||
const joined = `git diff; git diff --cached; cat options.ts; ls -la handbook handbook/reference handbook/reference/commands; ${skills}/bin/gstack-docs-candidate --help`;
|
||||
expect(docsCommandAllowed(joined, fixture)).toBe(false);
|
||||
expect(docsCommandAllowed(`${skills}/bin/gstack-docs-candidate --help`, fixture)).toBe(true);
|
||||
const candidate = `${skills}/bin/gstack-docs-candidate`;
|
||||
const joined = `git diff; git diff --cached; cat options.ts; ls -la handbook handbook/reference handbook/reference/commands; ${candidate} --help`;
|
||||
expect(docsCommandAllowed(joined, fixture)).toBe(true);
|
||||
for (const command of [`${candidate} --help`, `${candidate} --help 2>&1`, `git status; ${candidate} snapshot --help`, `${candidate} compare --help 2>/dev/null; git log --oneline -3`,
|
||||
`${candidate} --help | head -20`]) expect(docsCommandAllowed(command, fixture), command).toBe(true);
|
||||
const record = path.join(fixture.home, 'c.json');
|
||||
for (const command of [
|
||||
`git status; ${candidate} snapshot --out ${record} --audit-id a --mode edit --base main`, `git status; ${candidate} compare ${record}`,
|
||||
`${candidate} --help; git add app.ts`, `${candidate} --help > ${path.join(fixture.home, 'usage.md')}`, `${candidate} --help | sh`,
|
||||
`echo x | ${candidate} --help`, `git status | ${candidate} --help`, `cd ${fixture.repo} && ${candidate} --help`, `git status; ${candidate} --help snapshot`,
|
||||
`git status; ${candidate} --version`, `git status; ${candidate} -h`, `git status; ${candidate} --help --exec rm`,
|
||||
`git status; ~/.claude/skills/gstack/bin/gstack-docs-candidate --help`, `git status; ${path.join(path.dirname(fixture.home), 'bin/gstack-docs-candidate')} --help`,
|
||||
`git status; ${skills}/bin/gstack-skill-start --help`, `git status; ${skills}/bin/gstack-skill-end --help`,
|
||||
]) expect(docsCommandAllowed(command, fixture), command).toBe(false);
|
||||
const sentence = docsNativeInterface(fixture).split(/(?<=\.)\s+(?=[A-Z])/).find(part => part.includes('gstack-docs-candidate snapshot'));
|
||||
expect(sentence).toContain('as its own Bash call, with nothing joined before or after it');
|
||||
expect(sentence).toContain('a --help is no exception');
|
||||
expect(sentence).not.toContain('a --help is no exception');
|
||||
expect(docsNativeInterface(fixture)).toContain(`So may the installed ${candidate} --help, alone or after snapshot or compare, because it only prints usage (the one exception)`);
|
||||
});
|
||||
|
||||
// Five red ship-docsync parents, each red only on 'command outside declared docs observation interface', and each with exactly
|
||||
// one refused line (every other Bash call of the five captures replays clean): four joined the candidate's --help onto a line of
|
||||
// reads, and gate 40643690306 put `git tag` in one. {home} is the fixture home; the sha is the capture file's sha256 prefix.
|
||||
const JOINED_READ_LINES: Array<[source: string, scenario: 'current' | 'store', line: string]> = [
|
||||
['screen 40558235014 slice 5 @ 70cedc3d, capture c73fb9d0b1b0', 'current',
|
||||
'git diff main HEAD; cat personal-note.txt; ls -la; find handbook; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'],
|
||||
['gate 40600731931 slice 4 @ 5fef38a5, capture 606503d8b78a', 'store',
|
||||
'cat handbook/reference/commands/widget.md.tmpl; echo ---; cat CHANGELOG.md TODOS.md VERSION package.json AGENTS.md; echo ---; cat personal-note.txt; echo ---; find handbook; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'],
|
||||
['screen 40627127462 slice 5 @ 76a155d4, capture 67fa4dea377a', 'current',
|
||||
'git diff main...HEAD; cat handbook/reference/commands/widget.md.tmpl; ls -la handbook handbook/reference handbook/reference/commands; cat personal-note.txt | head -3; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'],
|
||||
['screen 40627127462 rerun 1 @ 76a155d4, capture 53d06fea1a89', 'current',
|
||||
'git diff main HEAD; git rev-parse origin/main; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help 2>&1'],
|
||||
['gate 40643690306 slice 4 @ c188a78c, capture 6ac1902bd83f', 'store',
|
||||
'git rev-parse HEAD origin/main; git merge-base main HEAD; git tag; cat {home}/fixture-interface.md | head -5; ls {home}'],
|
||||
];
|
||||
|
||||
test.each(JOINED_READ_LINES)('captured joined read line from %s is a declared read', (_source, scenario, line) => {
|
||||
const owned = fixtureDocs(scenario);
|
||||
try {
|
||||
const command = line.replaceAll('{home}', owned.home);
|
||||
const publish = path.join(owned.home, 'fixture-publish.ts');
|
||||
expect(docsCommandAllowed(command, owned, [publish])).toBe(true);
|
||||
expect(docsToolFailures(nativeResult(command, 'usage: gstack-docs-candidate snapshot ...'), owned, [publish], true)).toEqual([]);
|
||||
expect(docsCommandAllowed(`${command}; git add app.ts`, owned, [publish])).toBe(false);
|
||||
} finally { owned.clean(); }
|
||||
});
|
||||
|
||||
test('operand-free tag listings are reads, and every tag write stays refused', () => {
|
||||
for (const listing of ['git tag', 'git tag -l', 'git tag --list', 'git status; git tag; git log --oneline -3'])
|
||||
expect(docsCommandAllowed(listing, fixture), listing).toBe(true);
|
||||
for (const write of ['git tag v1', 'git tag -a v1 -m release', 'git tag -d v1', 'git tag -f v1 HEAD~1', 'git tag -s v1', 'git tag -v v1', 'git tag -l "v*"',
|
||||
'git tag --contains HEAD', 'git tag -n', 'git tag --sort=-v:refname', 'git tag -l v1', 'git tag; git tag v2', 'git tag | sh'])
|
||||
expect(docsCommandAllowed(write, fixture), write).toBe(false);
|
||||
expect(docsNativeInterface(fixture)).toContain('tag listings with no operand and only -l/--list');
|
||||
});
|
||||
|
||||
test('the installed lifecycle helpers are admitted for their own --help, which starts nothing', () => {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"provenance": {
|
||||
"kind": "public native AskUserQuestion calls and answers only (observation.json fingerprints[].nativeCall, byte-exact)",
|
||||
"runId": 40664336141,
|
||||
"artifact": "paid-slice-6-a1",
|
||||
"headSha": "00e713837b71189588268b63a0e860c63c48d51c",
|
||||
"recordedOutcome": "ceiling_reached",
|
||||
"recordedReviewCount": 1,
|
||||
"recordedStep0Count": 3,
|
||||
"recordedPassed": false,
|
||||
"note": "Opus 5.5 put the Step 0D offer in the title ('I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?') and carried the brief's option letters into the labels ('A) Review all 7 areas (recommended)', 'B) Focus on hierarchy/states/responsive'). The runner reached the review ceiling on the issue-labeled D4 finding, but the focus predicate accepted an all-seven offer only as dimensions/passes in the title or as the leading words of an option label, so the gate failed."
|
||||
},
|
||||
"calls": [
|
||||
{
|
||||
"preReview": true,
|
||||
"observedAtMs": 38216,
|
||||
"call": {
|
||||
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
|
||||
"toolUseId": "toolu_01B8NoAdG9DH1cyCwWXnU8Ny",
|
||||
"questions": [
|
||||
{
|
||||
"header": "Routing",
|
||||
"question": "D1: Should I add gstack skill routing rules to CLAUDE.md?\nProject/branch/task: dashboard plan review on main.\nELI10: gstack works better when your project's CLAUDE.md lists which skill handles which kind of request, for example that design plan reviews go to /plan-design-review. Adding that list means editing CLAUDE.md and committing it. Plan mode is on, so I can't make that edit now. If you choose A, I'll do it after you leave plan mode.\nStakes if we pick wrong: Without the rules you call skills by name yourself. With them, CLAUDE.md gets a new section.\nRecommendation: A because it routes later requests to the right skill without you having to name it.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nPros / cons:\nA) Add routing rules (recommended)\n \u2705 Future requests route to the matching gstack skill without you naming it\n \u2705 This is asked once per project and never comes up again\n \u274c Adds a commit to CLAUDE.md, and it has to wait until plan mode ends\nB) No thanks\n \u2705 CLAUDE.md stays as it is, with no new commit in this repo\n \u2705 You keep full manual control over which skill runs and when\n \u274c You have to type the skill name yourself every time you want one\nNet: automatic routing versus leaving CLAUDE.md unchanged.",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "A) Add routing rules (recommended)",
|
||||
"description": "\u2705 Future requests route to the matching skill without you naming it. \u2705 Asked once per project. \u274c Adds a CLAUDE.md commit, which waits until plan mode ends."
|
||||
},
|
||||
{
|
||||
"label": "B) No thanks",
|
||||
"description": "\u2705 CLAUDE.md stays as it is, with no new commit. \u2705 You keep manual control of skill calls. \u274c You type skill names yourself every time."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"answered": true,
|
||||
"failed": false,
|
||||
"answers": {
|
||||
"D1: Should I add gstack skill routing rules to CLAUDE.md?\nProject/branch/task: dashboard plan review on main.\nELI10: gstack works better when your project's CLAUDE.md lists which skill handles which kind of request, for example that design plan reviews go to /plan-design-review. Adding that list means editing CLAUDE.md and committing it. Plan mode is on, so I can't make that edit now. If you choose A, I'll do it after you leave plan mode.\nStakes if we pick wrong: Without the rules you call skills by name yourself. With them, CLAUDE.md gets a new section.\nRecommendation: A because it routes later requests to the right skill without you having to name it.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nPros / cons:\nA) Add routing rules (recommended)\n \u2705 Future requests route to the matching gstack skill without you naming it\n \u2705 This is asked once per project and never comes up again\n \u274c Adds a commit to CLAUDE.md, and it has to wait until plan mode ends\nB) No thanks\n \u2705 CLAUDE.md stays as it is, with no new commit in this repo\n \u2705 You keep full manual control over which skill runs and when\n \u274c You have to type the skill name yourself every time you want one\nNet: automatic routing versus leaving CLAUDE.md unchanged.": "A) Add routing rules (recommended)"
|
||||
},
|
||||
"unansweredQuestionIndices": [],
|
||||
"answeredAt": "2026-10-28T09:30:34.396Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"preReview": true,
|
||||
"observedAtMs": 87270,
|
||||
"call": {
|
||||
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
|
||||
"toolUseId": "toolu_01XQ2nXMdLz3Q4tQU2LTE5ph",
|
||||
"questions": [
|
||||
{
|
||||
"header": "Focus",
|
||||
"question": "D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?\nProject/branch/task: /dashboard plan review on main.\nELI10: The plan says how the dashboard behaves but not how it looks or what users notice first. The biggest gaps are panel hierarchy, layout at each screen size, empty-state content, and accessibility for the modal and toasts. Next I'll generate mockups, then work through 7 areas: hierarchy, states, user journey, AI-slop risk, design system, responsive/accessibility, and open decisions.\nStakes if we pick wrong: If I narrow the focus, unreviewed areas get built with default guesses, such as a generic three-card grid.\nRecommendation: A because almost every area has a gap and the plan is small enough to cover in full.\nCompleteness: A=10/10, B=6/10\nPros / cons:\nA) Review all 7 areas (recommended)\n \u2705 Every gap is surfaced before implementation, including accessibility and design-system gaps\n \u2705 The final report scores all 7 areas, so you know exactly what is still open\n \u274c More decision questions, roughly one for each gap found across the 7 areas\nB) Focus on hierarchy, states and responsive only\n \u2705 Fewer questions; the gaps that most affect what users see get fixed first\n \u2705 Gets to a plan you can build from faster, with less back-and-forth\n \u274c Accessibility and design-system gaps stay open and become implementer guesses\nNet: full coverage versus fewer questions.",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "A) Review all 7 areas (recommended)",
|
||||
"description": "\u2705 Every gap surfaced, including accessibility and design system. \u2705 Report scores all 7 areas. \u274c More decision questions."
|
||||
},
|
||||
{
|
||||
"label": "B) Focus on hierarchy/states/responsive",
|
||||
"description": "\u2705 Fewer questions; the most visible gaps first. \u2705 Faster to a plan you can build from. \u274c Accessibility and design-system gaps left to the implementer."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"answered": true,
|
||||
"failed": false,
|
||||
"answers": {
|
||||
"D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?\nProject/branch/task: /dashboard plan review on main.\nELI10: The plan says how the dashboard behaves but not how it looks or what users notice first. The biggest gaps are panel hierarchy, layout at each screen size, empty-state content, and accessibility for the modal and toasts. Next I'll generate mockups, then work through 7 areas: hierarchy, states, user journey, AI-slop risk, design system, responsive/accessibility, and open decisions.\nStakes if we pick wrong: If I narrow the focus, unreviewed areas get built with default guesses, such as a generic three-card grid.\nRecommendation: A because almost every area has a gap and the plan is small enough to cover in full.\nCompleteness: A=10/10, B=6/10\nPros / cons:\nA) Review all 7 areas (recommended)\n \u2705 Every gap is surfaced before implementation, including accessibility and design-system gaps\n \u2705 The final report scores all 7 areas, so you know exactly what is still open\n \u274c More decision questions, roughly one for each gap found across the 7 areas\nB) Focus on hierarchy, states and responsive only\n \u2705 Fewer questions; the gaps that most affect what users see get fixed first\n \u2705 Gets to a plan you can build from faster, with less back-and-forth\n \u274c Accessibility and design-system gaps stay open and become implementer guesses\nNet: full coverage versus fewer questions.": "A) Review all 7 areas (recommended)"
|
||||
},
|
||||
"unansweredQuestionIndices": [],
|
||||
"answeredAt": "2026-10-28T09:31:23.767Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"preReview": true,
|
||||
"observedAtMs": 116281,
|
||||
"call": {
|
||||
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
|
||||
"toolUseId": "toolu_01LxEwUZNTiiwQRydm1C9wCP",
|
||||
"questions": [
|
||||
{
|
||||
"header": "Voices",
|
||||
"question": "Want outside design voices before the detailed review?\nD3. Project/branch/task: /dashboard plan review on main.\nELI10: I can bring in two independent reviewers before the 7 areas: Codex scoring against strict design rules, and a fresh Claude reading for completeness. That's a cross-check on my review. It isn't required.\nStakes if we pick wrong: Without them, all findings come from one reviewer. With them, the review takes a few more minutes.\nRecommendation: No, proceed without, because the plan is small and every gap still gets its own decision from you.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: a cross-model check versus fewer steps.",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "Yes, run outside design voices",
|
||||
"description": "\u2705 Codex hard-rule + litmus scorecard plus an independent Claude completeness read. \u2705 Disagreements surface with both perspectives in the relevant pass. \u274c Adds a few minutes; Codex may be missing here, in which case only the Claude voice runs and it's tagged single-model."
|
||||
},
|
||||
{
|
||||
"label": "No, proceed without (recommended)",
|
||||
"description": "\u2705 Straight into the 7 passes with the approved mockup as anchor. \u2705 Fewest steps to a fixed plan. \u274c Findings rest on one reviewer; no cross-model check on slop risk or hierarchy."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"answered": true,
|
||||
"failed": false,
|
||||
"answers": {
|
||||
"Want outside design voices before the detailed review?\nD3. Project/branch/task: /dashboard plan review on main.\nELI10: I can bring in two independent reviewers before the 7 areas: Codex scoring against strict design rules, and a fresh Claude reading for completeness. That's a cross-check on my review. It isn't required.\nStakes if we pick wrong: Without them, all findings come from one reviewer. With them, the review takes a few more minutes.\nRecommendation: No, proceed without, because the plan is small and every gap still gets its own decision from you.\nNote: options differ in kind, not coverage \u2014 no completeness score.\nNet: a cross-model check versus fewer steps.": "No, proceed without (recommended)"
|
||||
},
|
||||
"unansweredQuestionIndices": [],
|
||||
"answeredAt": "2026-10-28T09:31:53.125Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"preReview": false,
|
||||
"observedAtMs": 148869,
|
||||
"call": {
|
||||
"sessionId": "76e83304-e6ca-4412-97cf-2b95950e09d7",
|
||||
"toolUseId": "toolu_016u5bq59ktJnbdvhE61syx8",
|
||||
"questions": [
|
||||
{
|
||||
"header": "Hierarchy",
|
||||
"question": "D4 (Issue 1): What should users see first, second and third on /dashboard?\nProject/branch/task: /dashboard plan review on main, Pass 1.\nELI10: Right after login, the user scans the page for one obvious thing to do. The plan doesn't rank the three panels, so they will probably ship as three equal boxes and the user has to work out what matters. I'd make unread notifications the main column, put recent activity in a narrower side column, and show the two quick actions as a compact row under the page title.\nStakes if we pick wrong: Users skim past unread items, or the page becomes a generic card grid.\nRecommendation: 1A because notifications are the only panel that needs the user to act (principle 2: every screen has a hierarchy).\nCompleteness: A=10/10, B=9/10, C=4/10\nPros / cons:\nA) Notifications first, activity second, actions row on top (recommended)\n \u2705 Unread items get the widest column and the eye goes to them first\n \u2705 Quick actions sit at the top as the 'where next' step without competing\n \u274c Activity gets a narrower column, so long activity lines truncate sooner\nB) Activity first, notifications in a right rail\n \u2705 A timeline-led page suits users who mainly come back to check what changed\n \u2705 Activity gets full width, so long descriptions stay readable on one line\n \u274c Unread notifications sit in a narrow rail and are easier to skip\nC) Three equal panels, no ranking\n \u2705 Simplest to build, with one shared panel layout reused three times\n \u2705 No product call needed now about which panel matters more than the others\n \u274c Generic SaaS card grid with no first thing to look at, which fails the slop check\nNet: put the panel that needs action first, or put what changed first.",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "1A Notifications first (recommended)",
|
||||
"description": "\u2705 Unread items get the widest column and are seen first. \u2705 Quick actions sit up top as 'where next'. \u274c Activity lines truncate sooner."
|
||||
},
|
||||
{
|
||||
"label": "1B Activity first, notif rail",
|
||||
"description": "\u2705 Timeline-led, for users checking what changed. \u2705 Activity gets full width. \u274c Unread items in a narrow rail are easier to skip."
|
||||
},
|
||||
{
|
||||
"label": "1C Three equal panels",
|
||||
"description": "\u2705 Simplest to build, one layout reused. \u2705 No ranking decision needed now. \u274c Generic card grid with no first thing to look at."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"answered": true,
|
||||
"failed": false,
|
||||
"answers": {
|
||||
"D4 (Issue 1): What should users see first, second and third on /dashboard?\nProject/branch/task: /dashboard plan review on main, Pass 1.\nELI10: Right after login, the user scans the page for one obvious thing to do. The plan doesn't rank the three panels, so they will probably ship as three equal boxes and the user has to work out what matters. I'd make unread notifications the main column, put recent activity in a narrower side column, and show the two quick actions as a compact row under the page title.\nStakes if we pick wrong: Users skim past unread items, or the page becomes a generic card grid.\nRecommendation: 1A because notifications are the only panel that needs the user to act (principle 2: every screen has a hierarchy).\nCompleteness: A=10/10, B=9/10, C=4/10\nPros / cons:\nA) Notifications first, activity second, actions row on top (recommended)\n \u2705 Unread items get the widest column and the eye goes to them first\n \u2705 Quick actions sit at the top as the 'where next' step without competing\n \u274c Activity gets a narrower column, so long activity lines truncate sooner\nB) Activity first, notifications in a right rail\n \u2705 A timeline-led page suits users who mainly come back to check what changed\n \u2705 Activity gets full width, so long descriptions stay readable on one line\n \u274c Unread notifications sit in a narrow rail and are easier to skip\nC) Three equal panels, no ranking\n \u2705 Simplest to build, with one shared panel layout reused three times\n \u2705 No product call needed now about which panel matters more than the others\n \u274c Generic SaaS card grid with no first thing to look at, which fails the slop check\nNet: put the panel that needs action first, or put what changed first.": "1A Notifications first (recommended)"
|
||||
},
|
||||
"unansweredQuestionIndices": [],
|
||||
"answeredAt": "2026-10-28T09:32:19.757Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
{
|
||||
"source": "E2E Evals run 40617200857 slice 8 (paid-slice-8-a1), shared-libs-review-path-eligibility, ignored scenario at tested 39861825: one Bash call returned all of src/retry-worker.ts through cat -n, then a later echo === in the same call failed, so the result carried is_error. Native tool blocks only; sources are the fixture evidence files.",
|
||||
"expected": {
|
||||
"helper": "/__w/gstack/gstack/bin/gstack-review-log",
|
||||
"repo": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo",
|
||||
"state": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state",
|
||||
"slug": "fixture-shared-libs",
|
||||
"directory": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state/projects/fixture-shared-libs/.review-starts",
|
||||
"branch": "feature/a",
|
||||
"wtree": "96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c",
|
||||
"startedAt": "2026-10-28T01:53:45.785Z",
|
||||
"finding": {
|
||||
"severity": "INFORMATIONAL",
|
||||
"confidence": 9,
|
||||
"advisory": true,
|
||||
"path": "src/retry-worker.ts",
|
||||
"line": 2,
|
||||
"category": "shared-libs",
|
||||
"summary": "Use the established Retry-After contract in the changed worker and the authored route sources.",
|
||||
"fix": "Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.",
|
||||
"evidence_paths": [
|
||||
"src/retry-worker.ts",
|
||||
"runtime-local/retry-route.ts",
|
||||
"lib/retry-after.ts"
|
||||
],
|
||||
"helper_target": {
|
||||
"path": "lib/retry-after.ts",
|
||||
"symbol": "retrySeconds"
|
||||
},
|
||||
"fingerprint": "shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881"
|
||||
},
|
||||
"reusable": false,
|
||||
"coveredPaths": [
|
||||
"src/retry-worker.ts",
|
||||
"lib/retry-after.ts"
|
||||
],
|
||||
"sources": {
|
||||
"src/retry-worker.ts": "// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\nexport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n if (value == null || value.trim() === '') return fallback;\n const normalized = value.trim();\n if (/^\\d+$/.test(normalized)) {\n const seconds = Number(normalized);\n if (!Number.isSafeInteger(seconds)) return fallback;\n return Math.min(seconds, 3600);\n }\n const deadline = Date.parse(normalized);\n if (!Number.isFinite(deadline)) return fallback;\n const remaining = Math.ceil((deadline - now) / 1000);\n if (remaining < 0) return 0;\n return Math.min(remaining, 3600);\n}\n",
|
||||
"runtime-local/retry-route.ts": "// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\nexport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n if (value == null || value.trim() === '') return fallback;\n const normalized = value.trim();\n if (/^\\d+$/.test(normalized)) {\n const seconds = Number(normalized);\n if (!Number.isSafeInteger(seconds)) return fallback;\n return Math.min(seconds, 3600);\n }\n const deadline = Date.parse(normalized);\n if (!Number.isFinite(deadline)) return fallback;\n const remaining = Math.ceil((deadline - now) / 1000);\n if (remaining < 0) return 0;\n return Math.min(remaining, 3600);\n}\n\n// Authored caller changed after the prior decision (ignored).\n",
|
||||
"lib/retry-after.ts": "// Proven shared parser already used by the scheduler.\nexport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n if (value == null || value.trim() === '') return fallback;\n const normalized = value.trim();\n if (/^\\d+$/.test(normalized)) {\n const seconds = Number(normalized);\n if (!Number.isSafeInteger(seconds)) return fallback;\n return Math.min(seconds, 3600);\n }\n const deadline = Date.parse(normalized);\n if (!Number.isFinite(deadline)) return fallback;\n const remaining = Math.ceil((deadline - now) / 1000);\n if (remaining < 0) return 0;\n return Math.min(remaining, 3600);\n}\n"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01UXXksGCC1ZEq5bcTpXAVpH",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/review-lifecycle.md"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01UXXksGCC1ZEq5bcTpXAVpH",
|
||||
"type": "tool_result",
|
||||
"content": "1\t---\n2\tname: review\n3\tpreamble-tier: 4\n4\tversion: 1.0.0\n5\tdescription: Pre-landing PR review. (gstack)\n6\tallowed-tools:\n7\t - Bash\n8\t - Read\n9\t - Edit\n10\t - Write\n11\t - Grep\n12\t - Glob\n13\t - Agent\n14\t - AskUserQuestion\n15\t - WebSearch\n16\ttriggers:\n17\t - review this pr\n18\t - code review\n19\t - check my diff\n20\t - pre-landing review\n21\t---\n22\t\n23\t## Step 3: Get the diff\n24\t\n25\tAn invocation is this /review run; a pass reviews one candidate before any fixes.\n26\tOn first entry, initialize one invocation action list and CYCLES=0. Keep both through re-reviews.\n27\t\n28\tEach pass has one direction: collect findings in Steps 3–4.8, approve and apply\n29\tfixes in Step 5, then choose repeat or final persistence in Step 5.8.\n30\tDo not edit reviewed source until Step 5. All readers examine the same candidate.\n31\t\n32\tDiff the working tree against the merge base:\n33\t\n34\t```bash\n35\tDIFF_BASE=$(git merge-base origin/main HEAD)\n36\t/__w/gstack/gstack/bin/gstack-review-log --start review\n37\tgit diff \"$DIFF_BASE\"\n38\t```\n39\t\n40\t1. Save the printed REVIEW_START for this core candidate before reading its diff.\n41\t2. Each re-review captures a new token before reading, never at log time. After an\n42\t edited pass, start the next with `--start review --fix-range`: it prints\n43\t REVIEW_START, then `FIX_BASE=<sha>`. Wherever this pass sets DIFF_BASE outside\n44\t Step 4.8, use FIX_BASE instead; Step 4.8 and every other `<base>` use stay full.\n45\t On refusal, start a full pass.\n46\t3. Native/outside reviewer attempts own separate PASS_START tokens, not REVIEW_START.\n47\t4. Read non-ignored untracked source too (`git ls-files --others --exclude-standard`);\n48\t the captured candidate includes it.\n49\t5. First pass only: emit Step 1.5's final Scope Check; `Diff read` records this read.\n50\t\n51\tKeep the review-record terms separate:\n52\t\n53\t| Value | Purpose and owner |\n54\t|---|---|\n55\t| REVIEW_START / PASS_START | Opaque start receipts from the logger: one for the core pass, one for each other reviewer attempt. |\n56\t| Finding fingerprint | Groups duplicate findings. The installed helper computes shared-code fingerprints; a matching key alone never proves a prior Skip is reusable. |\n57\t| `review_binding` | The logger's proof tying a finished review to its captured candidate, not a finding identifier. |\n58\t| `snapshot_covered_paths` | Supporting advice files the logger proved byte-identical to that candidate. Used by the prior-Skip checker, never supplied by the reviewer. |\n59\t\n60\t## Step 4: Critical pass (core review)\n61\t\n62\t> **STOP.** Before any probe, including plan checks, complete the ordered scope/method Reads in earlier responses. Never batch a probe (capture, guard start, plan check) with a Read.\n63\tStep 4 is read-only: defer charters, setup and probes to Step 4.7.\n64\t\n65\t1. From the installed /review SKILL.md's directory, choose one path:\n66\t- If the caller directory is `review`, Read `../qa/sections/exploratory.md` in full.\n67\t- If the caller directory is prefixed `gstack-review`, use `../gstack-qa/sections/exploratory.md` instead and read it in full.\n68\t- If neither layout applies, report an unresolved QA installation as a setup blocker; do not guess another path.\n69\tUse this host's installation, never the product tree. If missing or unreadable, report a QA setup blocker and its affected probes as blocked; continue other safe probes (independent functional/static checks). Missing/unreadable assets block required QA.\n70\t2. Then Read QA's `sections/scope.md` in full; pick surfaces.\n71\t3. Then Read each listed method in full; the entrypoint alone completes nothing.\n72\t\n73\tQA's `sections/...` and `templates/...` paths resolve from installed QA SKILL.md, not caller/product.\n74\t\n75\tApply both checklist passes in order: CRITICAL, then INFORMATIONAL. Respect its suppressions.\n76\tCRITICAL is the blocking floor (docs/review-policy.md): a finding that would fail a\n77\ttest, break production, or lose data or secrets. Everything else is INFORMATIONAL and\n78\tnever earns another pass. `NO FINDINGS` is a complete result from any reviewer.\n79\t\n80\t**Sideways sweep (every finding; reads code OUTSIDE the diff).** Grep the finding's pattern at every other ingress, command and call site, Read each match and rate severity on the worst sibling; a new enum value, status, tier or type constant is traced through every consumer the same way (checklist: Sideways sweep). Data loss, a trust-boundary bypass or uncapped spend is forced CRITICAL: no lane, specialist, dedup or saved decision downgrades it. Keep findings anchored to changed code.\n81\t\n82\t**Gate Integrity reads Step 3.5's listing.** Carry the `GATE_SUMMARY:` line and every\n83\tread-level hunk (`[<id>] <tag> <path> @<hunk>`) into the checklist's Gate Integrity\n84\tcategory: read each hunk in the diffLine truncated
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01HeRAdTGC3Wwdq9MHgFf4Zk",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/__w/gstack/gstack/review/checklist.md"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01HeRAdTGC3Wwdq9MHgFf4Zk",
|
||||
"type": "tool_result",
|
||||
"content": "1\t# Pre-Landing Review Checklist\n2\t\n3\t## Instructions\n4\t\n5\tReview the merge-base diff from the caller, including its selected uncommitted and new source. Use the caller's detected base, not a hardcoded branch. Cite `file:line` and suggest fixes. Only flag real problems.\n6\t\n7\t**Two-pass review:**\n8\t- **Pass 1 (CRITICAL):** Run SQL & Data Safety, Race Conditions, LLM Output Trust Boundary, Shell Injection, Enum Completeness, and Gate Integrity first. Highest severity.\n9\t- **Pass 2 (INFORMATIONAL):** Run remaining categories below. Lower severity but still actioned.\n10\t- **Specialist categories (handled by parallel subagents, NOT this checklist):** Test Gaps, Dead Code, Magic Numbers, Conditional Side Effects, Performance & Bundle Impact, Crypto & Entropy, Simplification (unrequested structure). See `review/specialists/` for these.\n11\t\n12\tCompleteness Gaps and Simplification are orthogonal, not contradictory: Completeness pushes coverage UP (tests, edge cases, error paths), Simplification pushes unrequested structure DOWN (one-implementation abstractions, hand-rolled stdlib, dead flexibility). The same diff can legitimately receive both.\n13\t\n14\tAll findings get action via Fix-First Review: obvious mechanical fixes are applied automatically,\n15\tgenuinely ambiguous issues are batched into a single user question.\n16\t\n17\t**Output format:**\n18\t\n19\t```\n20\tPre-Landing Review: N issues (X critical, Y informational)\n21\t\n22\t**AUTO-FIXED:**\n23\t- [file:line] Problem → fix applied\n24\t\n25\t**NEEDS INPUT:**\n26\t- [file:line] Problem description\n27\t Recommended fix: suggested fix\n28\t```\n29\t\n30\tIf no issues found: `Pre-Landing Review: No issues found.`\n31\t\n32\tBe terse. For each issue: one line describing the problem, one line with the fix. No preamble, no summaries, no \"looks good overall.\"\n33\t\n34\t---\n35\t\n36\t## Review Categories\n37\t\n38\t### Pass 1 — CRITICAL\n39\t\n40\t#### SQL & Data Safety\n41\t- String interpolation in SQL (even if values are `.to_i`/`.to_f` — use parameterized queries (Rails: sanitize_sql_array/Arel; Node: prepared statements; Python: parameterized queries))\n42\t- TOCTOU races: check-then-set patterns that should be atomic `WHERE` + `update_all`\n43\t- Bypassing model validations for direct DB writes (Rails: update_column; Django: QuerySet.update(); Prisma: raw queries)\n44\t- N+1 queries: Missing eager loading (Rails: .includes(); SQLAlchemy: joinedload(); Prisma: include) for associations used in loops/views\n45\t\n46\t#### Race Conditions & Concurrency\n47\t- Read-check-write without uniqueness constraint or catch duplicate key error and retry (e.g., `where(hash:).first` then `save!` without handling concurrent insert)\n48\t- find-or-create without unique DB index — concurrent calls can create duplicates\n49\t- Status transitions that don't use atomic `WHERE old_status = ? UPDATE SET new_status` — concurrent updates can skip or double-apply transitions\n50\t- Unsafe HTML rendering (Rails: .html_safe/raw(); React: dangerouslySetInnerHTML; Vue: v-html; Django: |safe/mark_safe) on user-controlled data (XSS)\n51\t\n52\t#### LLM Output Trust Boundary\n53\t- LLM-generated values (emails, URLs, names) written to DB or passed to mailers without format validation. Add lightweight guards (`EMAIL_REGEXP`, `URI.parse`, `.strip`) before persisting.\n54\t- Structured tool output (arrays, hashes) accepted without type/shape checks before database writes.\n55\t- LLM-generated URLs fetched without allowlist — SSRF risk if URL points to internal network (Python: `urllib.parse.urlparse` → check hostname against blocklist before `requests.get`/`httpx.get`)\n56\t- LLM output stored in knowledge bases or vector DBs without sanitization — stored prompt injection risk\n57\t\n58\t#### Shell Injection (Python-specific)\n59\t- `subprocess.run()` / `subprocess.call()` / `subprocess.Popen()` with `shell=True` AND f-string/`.format()` interpolation in the command string — use argument arrays instead\n60\t- `os.system()` with variable interpolation — replace with `subprocess.run()` using argument arrays\n61\t- `eval()` / `exec()` on LLM-generated code without sandboxing\n62\t\n63\t#### Enum & Value Completeness\n64\tWhen the diff introduces a new enum value, status string, tier name, or type constant, or loosens what an input accepts (a new MIME type, extension or flag, or a widened validator/parser/guard):\n65\t- **Trace it through every consumer.** Read (don't just grep — READ) each file that switches on, filters by, or displays that value. If any consumer doesn't handle the new value, flag it. Common miss: adding a value to the frontend dropdown but the backend model/compute method doesn't persist it.\n66\t- **Check allowlists/filter arrays.** Search for arrays or `%w[]` lists containing sibling values (e.g., if adding \"revise\" to tiers, find every `%w[quick lfg mega]` and verify \"revise\" is included whereLine truncated
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_012nWx4Je2424rCcxZUaaeUg",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/current-advisory.jsonl"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_012nWx4Je2424rCcxZUaaeUg",
|
||||
"type": "tool_result",
|
||||
"content": "1\t{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"specialist\":\"maintainability\"}\n2\t"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01FiurRTF3YRGrjduMhTi5m8",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/resumed-review-prerequisites.json"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01FiurRTF3YRGrjduMhTi5m8",
|
||||
"type": "tool_result",
|
||||
"content": "1\t{\n2\t \"kind\": \"synthetic-path-review-prerequisites\",\n3\t \"synthetic\": true,\n4\t \"native_coverage\": false,\n5\t \"binding\": {\n6\t \"root\": \"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2\",\n7\t \"repo\": \"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\n8\t \"state\": \"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state\",\n9\t \"branch\": \"feature/a\",\n10\t \"head\": \"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\n11\t \"base\": \"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\n12\t \"wtree\": \"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\n13\t \"index\": \"H 100644 43ac5a38d8386f6a0743f793d7ccee871df272a5 0\\t.gitignore\\nH 100644 965ef910ba60ca16fe356b96ee4cce5162a01a23 0\\tREADME.md\\nH 100644 00dfe6a156bae81aefc94ff1e9db54e32d774c75 0\\tlib/retry-after.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-route.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-worker.ts\\nH 100644 c362fe9878839740d82db85053758be5b95834cd 0\\tsrc/scheduler.ts\\nH 100644 a470f2c203c43e66c47991c64654dd524f9c5c29 0\\ttest/retry-after.test.ts\",\n14\t \"raw\": {\n15\t \".\": \"dir:16877\",\n16\t \".git/config\": \"33188:2550b8371ff38558f3a8c847e5563ca10271d66c0ce1ea8608cc415a959b5854\",\n17\t \".git/info/exclude\": \"33188:6671fe83b7a07c8932ee89164d1f2793b2318058eb8b98dc5c06ee0a5a3b0ec1\",\n18\t \".gitignore\": \"33188:326b615083eaf2671938ccb4ce4dc28b7c07f5687033fa74dc4caa44f866ccb4\",\n19\t \"README.md\": \"33188:94324728d3116382754324b62b8bcf233cf6046df4d1f4f770ecc68df7b10504\",\n20\t \"lib\": \"dir:16877\",\n21\t \"lib/retry-after.ts\": \"33188:67d7124d11830e41baa5fca93bd0d45ce64fe58d92799c7d14635b912c5e5942\",\n22\t \"runtime-local\": \"dir:16877\",\n23\t \"runtime-local/retry-route.ts\": \"33188:db46a502afb0ba90f6e0001a990728f3ae8849be1c75b8ccd616d3ef25353d33\",\n24\t \"src\": \"dir:16877\",\n25\t \"src/retry-route.ts\": \"33188:300feb02d24ed09ca763371d42249f8307dbe9201fb4cddf7aa634a9e341f887\",\n26\t \"src/retry-worker.ts\": \"33188:a17d12e37ae2b07d38dae292156aef6ec83d11b4ec2e295bb14d01f0e8fc9b2e\",\n27\t \"src/scheduler.ts\": \"33188:8a07bfe628ea4f9e679bcab3cfb68482678eebc1e4031887e996d799a6d1198e\",\n28\t \"test\": \"dir:16877\",\n29\t \"test/retry-after.test.ts\": \"33188:653e4090ae4e0d856f4dbf04fb05449d04c44fef599c24ce146d94969a1040a1\"\n30\t }\n31\t },\n32\t \"qa\": {\n33\t \"settled\": true,\n34\t \"required_probes\": [\n35\t {\n36\t \"id\": \"retry-contract\",\n37\t \"status\": \"passed\",\n38\t \"result\": \"Synthetic fixture input: Retry-After seconds/date parsing, ceiling and fallback probes passed.\"\n39\t }\n40\t ],\n41\t \"findings\": []\n42\t },\n43\t \"native_adversarial\": {\n44\t \"settled\": true,\n45\t \"status\": \"completed\",\n46\t \"findings\": [],\n47\t \"result\": \"Synthetic fixture input: native adversarial review returned no findings.\"\n48\t },\n49\t \"structured_review\": {\n50\t \"required\": false,\n51\t \"reason\": \"Tiny diff; no full-review, structured-review or P1 override requested.\"\n52\t }\n53\t}\n54\t"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01Ux4koaFZbNCDB5y1zsdDTK",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/__w/gstack/gstack/review/sections/shared-code-reuse.md"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01Ux4koaFZbNCDB5y1zsdDTK",
|
||||
"type": "tool_result",
|
||||
"content": "1\t<!-- AUTO-GENERATED from shared-code-reuse.md.tmpl — do not edit directly -->\n2\t<!-- Regenerate: bun run gen:skill-docs -->\n3\t**Reuse a skipped shared-code advisory only with complete structural evidence:**\n4\t\n5\t1. **Read the evidence.** Read all supporting callers and the helper destination.\n6\t Establish first-party authored provenance and whether the current extraction\n7\t is worthwhile; the checker cannot decide that. Retain `evidence_paths`/`helper_target`.\n8\t2. **Run the checker.** From the repository root, pass the current finding as\n9\t literal JSON on stdin. Replace REVIEW_START with this pass's captured token\n10\t and the example paths/symbol with actual evidence. Keep the quoted delimiter.\n11\t\n12\t```bash\n13\t\"$HOME/.claude/skills/gstack/bin/gstack-review-log\" --check-shared-libs REVIEW_START <<'GSTACK_SHARED_LIBS_REUSE_JSON'\n14\t{\"advisory\":true,\"severity\":\"INFORMATIONAL\",\"evidence_paths\":[\"src/caller-a.ts\",\"src/caller-b.ts\"],\"helper_target\":{\"path\":\"src/shared.ts\",\"symbol\":\"sharedHelper\"}}\n15\tGSTACK_SHARED_LIBS_REUSE_JSON\n16\t```\n17\t\n18\t3. **Act on its result.** Read the JSON. Only `reusable: true` permits suppression.\n19\t False, command failure or unreadable output requires fresh source review and a\n20\t new decision, never suppression. Do not supply your own snapshot, prior record or coverage.\n21\t4. **Persist through the logger.** The logger recomputes final coverage; never\n22\t supply proof yourself. Real defects retain normal Fix-First handling independently.\n23\t\n24\t**What a reusable result proves (do not reconstruct these checks yourself):**\n25\t- Identity: `sharedLibsFingerprint` plus the actual repo, raw branch and current snapshot.\n26\t The checker reads REVIEW_START without consuming/replacing it. Sanitized branch names are not identity.\n27\t- Prior decision: completed/converged review, verified binding, explicit Skip and\n28\t logger-versioned `snapshot_covered_paths`; older unversioned coverage needs a fresh decision.\n29\t- Source: `canReuseSharedLibsAdvisory` requires every supporting path's raw file\n30\t byte-for-byte with its blob. Exclude assume-unchanged, skip-worktree and sparse index\n31\t entries; symlinks/ancestors, submodules, ignored/outside or unreadable files;\n32\t active/unknown Git filters, encodings and line conversion.\n33\t- Safe inspection: disables fsmonitor and optional locks; never uses external diff/textconv.\n34\t Unknown evidence fails closed.\n35\t"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01HgUDAPUD3QgytJmoHKQMdb",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "git fetch origin 2>&1; echo \"MB=$(git merge-base origin/main HEAD)\"; echo \"HEAD=$(git rev-parse HEAD)\"; echo \"BASE=$(git rev-parse origin/main)\"; git branch --show-current; git status --porcelain=v1 --ignored; echo ---ls-files-v; git ls-files -v | head -50; echo ---debug; git ls-files --debug -- src | head -60; echo ---config; git config --local --list; echo ---attrs; cat .gitattributes .git/info/attributes .git/info/exclude 2>&1; cat .gitignore 2>&1; git config --get core.attributesFile; git config --get core.excludesFile"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Exit code 1\nMB=0065f046b3be1008d3788dcaa596fbdadc1b540b\nHEAD=0065f046b3be1008d3788dcaa596fbdadc1b540b\nBASE=0065f046b3be1008d3788dcaa596fbdadc1b540b\nfeature/a\n M src/retry-worker.ts\n!! runtime-local/\n---ls-files-v\nH .gitignore\nH README.md\nH lib/retry-after.ts\nH src/retry-route.ts\nH src/retry-worker.ts\nH src/scheduler.ts\nH test/retry-after.test.ts\n---debug\nsrc/retry-route.ts\n ctime: 1793152411:639522773\n mtime: 1793152411:639522773\n dev: 45\tino: 9962937\n uid: 1001\tgid: 1001\n size: 51\tflags: 0\nsrc/retry-worker.ts\n ctime: 1793152411:639522773\n mtime: 1793152411:639522773\n dev: 45\tino: 9962936\n uid: 1001\tgid: 1001\n size: 51\tflags: 0\nsrc/scheduler.ts\n ctime: 1793152411:615527724\n mtime: 1793152411:615527724\n dev: 45\tino: 9962940\n uid: 1001\tgid: 1001\n size: 126\tflags: 0\n---config\ncore.repositoryformatversion=0\ncore.filemode=true\ncore.bare=false\ncore.logallrefupdates=true\nuser.name=Shared Libs Fixture\nuser.email=shared-libs@example.invalid\nremote.origin.url=https://github.com/fixture/shared-libs.git\nremote.origin.fetch=+refs/heads/*:refs/remotes/origin/*\n---attrs\ncat: .gitattributes: No such file or directory\ncat: .git/info/attributes: No such file or directory\n# git ls-files --others --exclude-from=.git/info/exclude\n# Lines that start with '#' are comments.\n# For a project mostly in C, the following would be a good set of\n# exclude patterns (uncomment them if you want to use them):\n# *.[oa]\n# *~\n.fixture/\nruntime-local/",
|
||||
"is_error": true,
|
||||
"tool_use_id": "toolu_01HgUDAPUD3QgytJmoHKQMdb"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01XV73n6QkXKEbPiTQbhA5CJ",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "'/__w/gstack/gstack/bin/gstack-review-log' --start review"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01XV73n6QkXKEbPiTQbhA5CJ",
|
||||
"type": "tool_result",
|
||||
"content": "547c0e1f-86d4-40e2-8be6-bf87f439bc75",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01A7hwW89RrEkkidKLpKJB41",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state/projects/fixture-shared-libs/.review-starts/547c0e1f-86d4-40e2-8be6-bf87f439bc75.json"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01A7hwW89RrEkkidKLpKJB41",
|
||||
"type": "tool_result",
|
||||
"content": "1\t{\"skill\":\"review\",\"repo\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\"branch\":\"feature/a\",\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:45.785Z\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01JNnuM42rwXQCenGMNg9Uc6",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "git diff 0065f046b3be1008d3788dcaa596fbdadc1b540b; echo ---untracked; git ls-files --others --exclude-standard; echo ---ignored; git ls-files --others --ignored --exclude-standard; echo ---checkignore; git check-ignore -v runtime-local/retry-route.ts; echo ---gitignore; cat .gitignore"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01JNnuM42rwXQCenGMNg9Uc6",
|
||||
"type": "tool_result",
|
||||
"content": "diff --git a/src/retry-worker.ts b/src/retry-worker.ts\nindex 8ab4b18..8f6dd51 100644\n--- a/src/retry-worker.ts\n+++ b/src/retry-worker.ts\n@@ -1 +1,15 @@\n-export { retrySeconds } from '../lib/retry-after';\n+// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\n+export function retrySeconds(value: string | null, now: number, fallback = 5): number {\n+ if (value == null || value.trim() === '') return fallback;\n+ const normalized = value.trim();\n+ if (/^\\d+$/.test(normalized)) {\n+ const seconds = Number(normalized);\n+ if (!Number.isSafeInteger(seconds)) return fallback;\n+ return Math.min(seconds, 3600);\n+ }\n+ const deadline = Date.parse(normalized);\n+ if (!Number.isFinite(deadline)) return fallback;\n+ const remaining = Math.ceil((deadline - now) / 1000);\n+ if (remaining < 0) return 0;\n+ return Math.min(remaining, 3600);\n+}\n---untracked\n---ignored\nruntime-local/retry-route.ts\n---checkignore\n.gitignore:2:runtime-local/\truntime-local/retry-route.ts\n---gitignore\n.fixture/\nruntime-local/",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01SqU5QcTMtXntb5BsZLwEnj",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "cat -n src/retry-worker.ts; echo ===; cat -n runtime-local/retry-route.ts; echo ===; cat -n lib/retry-after.ts; echo ===; cat -n src/retry-route.ts; echo ===; cat -n src/scheduler.ts; echo ===; cat -n test/retry-after.test.ts; echo ===; cat -n README.md; echo ===; ls -la runtime-local; git ls-files -s runtime-local; git log --oneline -- runtime-local | head"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Exit code 1\n 1\t// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\n 2\texport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n 3\t if (value == null || value.trim() === '') return fallback;\n 4\t const normalized = value.trim();\n 5\t if (/^\\d+$/.test(normalized)) {\n 6\t const seconds = Number(normalized);\n 7\t if (!Number.isSafeInteger(seconds)) return fallback;\n 8\t return Math.min(seconds, 3600);\n 9\t }\n 10\t const deadline = Date.parse(normalized);\n 11\t if (!Number.isFinite(deadline)) return fallback;\n 12\t const remaining = Math.ceil((deadline - now) / 1000);\n 13\t if (remaining < 0) return 0;\n 14\t return Math.min(remaining, 3600);\n 15\t}\n(eval):1: == not found",
|
||||
"is_error": true,
|
||||
"tool_use_id": "toolu_01SqU5QcTMtXntb5BsZLwEnj"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01SG2WWtp5ukb9AXXBzLM52i",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "cat -n runtime-local/retry-route.ts; echo '---'; cat -n lib/retry-after.ts; echo '---'; cat -n src/retry-route.ts; echo '---'; cat -n src/scheduler.ts; echo '---'; cat -n test/retry-after.test.ts; echo '---'; cat -n README.md; echo '---'; ls -la runtime-local; git ls-files -s runtime-local; git log --oneline --all -- runtime-local | head"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01SG2WWtp5ukb9AXXBzLM52i",
|
||||
"type": "tool_result",
|
||||
"content": " 1\t// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\n 2\texport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n 3\t if (value == null || value.trim() === '') return fallback;\n 4\t const normalized = value.trim();\n 5\t if (/^\\d+$/.test(normalized)) {\n 6\t const seconds = Number(normalized);\n 7\t if (!Number.isSafeInteger(seconds)) return fallback;\n 8\t return Math.min(seconds, 3600);\n 9\t }\n 10\t const deadline = Date.parse(normalized);\n 11\t if (!Number.isFinite(deadline)) return fallback;\n 12\t const remaining = Math.ceil((deadline - now) / 1000);\n 13\t if (remaining < 0) return 0;\n 14\t return Math.min(remaining, 3600);\n 15\t}\n 16\t\n 17\t// Authored caller changed after the prior decision (ignored).\n---\n 1\t// Proven shared parser already used by the scheduler.\n 2\texport function retrySeconds(value: string | null, now: number, fallback = 5): number {\n 3\t if (value == null || value.trim() === '') return fallback;\n 4\t const normalized = value.trim();\n 5\t if (/^\\d+$/.test(normalized)) {\n 6\t const seconds = Number(normalized);\n 7\t if (!Number.isSafeInteger(seconds)) return fallback;\n 8\t return Math.min(seconds, 3600);\n 9\t }\n 10\t const deadline = Date.parse(normalized);\n 11\t if (!Number.isFinite(deadline)) return fallback;\n 12\t const remaining = Math.ceil((deadline - now) / 1000);\n 13\t if (remaining < 0) return 0;\n 14\t return Math.min(remaining, 3600);\n 15\t}\n---\n 1\texport { retrySeconds } from '../lib/retry-after';\n---\n 1\timport { retrySeconds } from '../lib/retry-after';\n 2\texport const nextRun = (value: string) => retrySeconds(value, Date.now());\n---\n 1\timport { expect, test } from 'bun:test';\n 2\timport { retrySeconds } from '../lib/retry-after';\n 3\ttest('retry parser contract', () => {\n 4\t expect(retrySeconds(null, 0)).toBe(5);\n 5\t expect(retrySeconds('invalid', 0, 7)).toBe(7);\n 6\t expect(retrySeconds(' 42 ', 0)).toBe(42);\n 7\t expect(retrySeconds('999999', 0)).toBe(3600);\n 8\t expect(retrySeconds('Thu, 01 Jan 1970 00:00:01 GMT', 0)).toBe(1);\n 9\t});\n---\n 1\t# Fixture application\n 2\t\n 3\tThe application deployment supplies authored optional route adapters under runtime-local/. That established mount is intentionally excluded from this repository; runtime-local/retry-route.ts is first-party application code loaded only after the mount is present.\n---\ntotal 12\ndrwxr-xr-x 2 runner runner 4096 Oct 28 01:53 .\ndrwxr-xr-x 7 runner runner 4096 Oct 28 01:53 ..\n-rw-r--r-- 1 runner runner 737 Oct 28 01:53 retry-route.ts",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01CVpwC8ef1niGnBoDL1ppJL",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "'/__w/gstack/gstack/bin/gstack-review-log' --check-shared-libs 547c0e1f-86d4-40e2-8be6-bf87f439bc75 <<'GSTACK_REVALIDATION_FINDING'\n{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"specialist\":\"maintainability\"}\nGSTACK_REVALIDATION_FINDING"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01CVpwC8ef1niGnBoDL1ppJL",
|
||||
"type": "tool_result",
|
||||
"content": "{\"reusable\":false,\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"review_start\":{\"skill\":\"review\",\"repo\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\"branch\":\"feature/a\",\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:45.785Z\"},\"snapshot\":{\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\",\"covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}}",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01CbxQEpFUZwz6eTdFxic3QE",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "'/__w/gstack/gstack/bin/gstack-review-read'"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01CbxQEpFUZwz6eTdFxic3QE",
|
||||
"type": "tool_result",
|
||||
"content": "{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:53:31.755Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\",\"snapshot_covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}],\"completed\":true,\"converged\":true,\"cycles\":0,\"status_source\":\"claimed\",\"commit_full\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"tree\":\"3d257818bedb4ab8770ce806c8b0bfcc45537857\",\"dirty\":true,\"review_binding\":{\"state\":\"verified\",\"start_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"end_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:31.753Z\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\"},\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"shared_libs_coverage_version\":1,\"review_freshness\":{\"status\":\"CURRENT\",\"reason\":\"completed clean pass on unchanged content\"}}\n---CONFIG---\nfalse---HEAD---\n0065f04\n---WTREE---\n96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\n---TREE---\n3d257818bedb4ab8770ce806c8b0bfcc45537857\n---DIRTY---\ntrue",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_013zyFAS1agf999Po7qTodYm",
|
||||
"name": "Grep",
|
||||
"input": {
|
||||
"pattern": "retrySeconds|3600|Retry-After",
|
||||
"path": "/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo",
|
||||
"output_mode": "content"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_013zyFAS1agf999Po7qTodYm",
|
||||
"type": "tool_result",
|
||||
"content": "src/retry-route.ts:1:export { retrySeconds } from '../lib/retry-after';\nsrc/retry-worker.ts:1:// Both callers require Retry-After seconds/date parsing, 3600-second ceiling and caller fallback.\nsrc/retry-worker.ts:2:export function retrySeconds(value: string | null, now: number, fallback = 5): number {\nsrc/retry-worker.ts:8: return Math.min(seconds, 3600);\nsrc/retry-worker.ts:14: return Math.min(remaining, 3600);\nsrc/scheduler.ts:1:import { retrySeconds } from '../lib/retry-after';\nsrc/scheduler.ts:2:export const nextRun = (value: string) => retrySeconds(value, Date.now());\ntest/retry-after.test.ts:2:import { retrySeconds } from '../lib/retry-after';\ntest/retry-after.test.ts:4: expect(retrySeconds(null, 0)).toBe(5);\ntest/retry-after.test.ts:5: expect(retrySeconds('invalid', 0, 7)).toBe(7);\ntest/retry-after.test.ts:6: expect(retrySeconds(' 42 ', 0)).toBe(42);\ntest/retry-after.test.ts:7: expect(retrySeconds('999999', 0)).toBe(3600);\ntest/retry-after.test.ts:8: expect(retrySeconds('Thu, 01 Jan 1970 00:00:01 GMT', 0)).toBe(1);\nlib/retry-after.ts:2:export function retrySeconds(value: string | null, now: number, fallback = 5): number {\nlib/retry-after.ts:8: return Math.min(seconds, 3600);\nlib/retry-after.ts:14: return Math.min(remaining, 3600);"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01Pgoa7ZGB1vUMTZ2QBC3kU2",
|
||||
"name": "AskUserQuestion",
|
||||
"input": {
|
||||
"questions": [
|
||||
{
|
||||
"question": "[ADVISORY] src/retry-worker.ts:2-15 copies lib/retry-after.ts retrySeconds inline, and the copy matches it exactly. The prior Skip can't be reused: the checker returned reusable:false because runtime-local/retry-route.ts is gitignored, isn't covered by the snapshot, and changed after that decision. Proposed fix: put back `export { retrySeconds } from '../lib/retry-after';` in the worker, the same as src/retry-route.ts and src/scheduler.ts. Leave the runtime-local adapter out of the change, since it's a separate deployment mount. Estimated savings: about 15 lines removed, 1 added (net −14). Covered by the existing test/retry-after.test.ts. If the shared helper broke, it would affect the worker, route and scheduler. RECOMMENDATION: A. Note that A edits source, which blocks this no-edit replay, so the review would stay incomplete.",
|
||||
"header": "Retry helper",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{
|
||||
"label": "A) Fix as recommended",
|
||||
"description": "Re-export retrySeconds from lib/retry-after in the worker. Needs a source edit, which blocks this bounded no-edit replay, so the review is reported incomplete."
|
||||
},
|
||||
{
|
||||
"label": "B) Skip",
|
||||
"description": "No code or index change; the Skip is recorded."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Your questions have been answered: \"[ADVISORY] src/retry-worker.ts:2-15 copies lib/retry-after.ts retrySeconds inline, and the copy matches it exactly. The prior Skip can't be reused: the checker returned reusable:false because runtime-local/retry-route.ts is gitignored, isn't covered by the snapshot, and changed after that decision. Proposed fix: put back `export { retrySeconds } from '../lib/retry-after';` in the worker, the same as src/retry-route.ts and src/scheduler.ts. Leave the runtime-local adapter out of the change, since it's a separate deployment mount. Estimated savings: about 15 lines removed, 1 added (net −14). Covered by the existing test/retry-after.test.ts. If the shared helper broke, it would affect the worker, route and scheduler. RECOMMENDATION: A. Note that A edits source, which blocks this no-edit replay, so the review would stay incomplete.\"=\"B) Skip\". You can now continue with these answers in mind.",
|
||||
"tool_use_id": "toolu_01Pgoa7ZGB1vUMTZ2QBC3kU2"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01MZ3iwhrhCqS2NwyehefiSg",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "git rev-parse HEAD origin/main; git branch --show-current; git status --porcelain=v1 --ignored; git ls-files -s; git ls-files -v; sha256sum src/retry-worker.ts lib/retry-after.ts runtime-local/retry-route.ts .git/config .git/info/exclude .gitignore; git config --local --list | md5sum; date -u +%Y-%m-%dT%H:%M:%SZ; git rev-parse --short HEAD"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01MZ3iwhrhCqS2NwyehefiSg",
|
||||
"type": "tool_result",
|
||||
"content": "0065f046b3be1008d3788dcaa596fbdadc1b540b\n0065f046b3be1008d3788dcaa596fbdadc1b540b\nfeature/a\n M src/retry-worker.ts\n!! runtime-local/\n100644 43ac5a38d8386f6a0743f793d7ccee871df272a5 0\t.gitignore\n100644 965ef910ba60ca16fe356b96ee4cce5162a01a23 0\tREADME.md\n100644 00dfe6a156bae81aefc94ff1e9db54e32d774c75 0\tlib/retry-after.ts\n100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\tsrc/retry-route.ts\n100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\tsrc/retry-worker.ts\n100644 c362fe9878839740d82db85053758be5b95834cd 0\tsrc/scheduler.ts\n100644 a470f2c203c43e66c47991c64654dd524f9c5c29 0\ttest/retry-after.test.ts\nH .gitignore\nH README.md\nH lib/retry-after.ts\nH src/retry-route.ts\nH src/retry-worker.ts\nH src/scheduler.ts\nH test/retry-after.test.ts\na17d12e37ae2b07d38dae292156aef6ec83d11b4ec2e295bb14d01f0e8fc9b2e src/retry-worker.ts\n67d7124d11830e41baa5fca93bd0d45ce64fe58d92799c7d14635b912c5e5942 lib/retry-after.ts\ndb46a502afb0ba90f6e0001a990728f3ae8849be1c75b8ccd616d3ef25353d33 runtime-local/retry-route.ts\n2550b8371ff38558f3a8c847e5563ca10271d66c0ce1ea8608cc415a959b5854 .git/config\n6671fe83b7a07c8932ee89164d1f2793b2318058eb8b98dc5c06ee0a5a3b0ec1 .git/info/exclude\n326b615083eaf2671938ccb4ce4dc28b7c07f5687033fa74dc4caa44f866ccb4 .gitignore\nafba8a8c204ce2d12c88f5a21198daad -\n2026-10-28T01:54:34Z\n0065f04",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01JqjkEAXGWYdxnP9Mu7fZ92",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "bun '/__w/gstack/gstack/test/helpers/shared-libs-path-fixture.ts' --check-review-prerequisites '/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/resumed-review-prerequisites.json'"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01JqjkEAXGWYdxnP9Mu7fZ92",
|
||||
"type": "tool_result",
|
||||
"content": "{\"synthetic\":true,\"native_coverage\":false,\"settled\":true,\"current\":true,\"input_sha256\":\"b26272f134145cdb6bd79a2bea545d44e9d84d128c6f81852de73bf1876509a1\",\"context\":{\"kind\":\"synthetic-path-review-prerequisites\",\"synthetic\":true,\"native_coverage\":false,\"binding\":{\"root\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2\",\"repo\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/repo\",\"state\":\"/home/runner/.cache/gstack-paid-shard-gnUfyA/tmp/gstack-shared-path-ignored-PWdhw2/state\",\"branch\":\"feature/a\",\"head\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"base\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"index\":\"H 100644 43ac5a38d8386f6a0743f793d7ccee871df272a5 0\\t.gitignore\\nH 100644 965ef910ba60ca16fe356b96ee4cce5162a01a23 0\\tREADME.md\\nH 100644 00dfe6a156bae81aefc94ff1e9db54e32d774c75 0\\tlib/retry-after.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-route.ts\\nH 100644 8ab4b18d9a2bd337433526c96343d251aaee8539 0\\tsrc/retry-worker.ts\\nH 100644 c362fe9878839740d82db85053758be5b95834cd 0\\tsrc/scheduler.ts\\nH 100644 a470f2c203c43e66c47991c64654dd524f9c5c29 0\\ttest/retry-after.test.ts\",\"raw\":{\".\":\"dir:16877\",\".git/config\":\"33188:2550b8371ff38558f3a8c847e5563ca10271d66c0ce1ea8608cc415a959b5854\",\".git/info/exclude\":\"33188:6671fe83b7a07c8932ee89164d1f2793b2318058eb8b98dc5c06ee0a5a3b0ec1\",\".gitignore\":\"33188:326b615083eaf2671938ccb4ce4dc28b7c07f5687033fa74dc4caa44f866ccb4\",\"README.md\":\"33188:94324728d3116382754324b62b8bcf233cf6046df4d1f4f770ecc68df7b10504\",\"lib\":\"dir:16877\",\"lib/retry-after.ts\":\"33188:67d7124d11830e41baa5fca93bd0d45ce64fe58d92799c7d14635b912c5e5942\",\"runtime-local\":\"dir:16877\",\"runtime-local/retry-route.ts\":\"33188:db46a502afb0ba90f6e0001a990728f3ae8849be1c75b8ccd616d3ef25353d33\",\"src\":\"dir:16877\",\"src/retry-route.ts\":\"33188:300feb02d24ed09ca763371d42249f8307dbe9201fb4cddf7aa634a9e341f887\",\"src/retry-worker.ts\":\"33188:a17d12e37ae2b07d38dae292156aef6ec83d11b4ec2e295bb14d01f0e8fc9b2e\",\"src/scheduler.ts\":\"33188:8a07bfe628ea4f9e679bcab3cfb68482678eebc1e4031887e996d799a6d1198e\",\"test\":\"dir:16877\",\"test/retry-after.test.ts\":\"33188:653e4090ae4e0d856f4dbf04fb05449d04c44fef599c24ce146d94969a1040a1\"}},\"qa\":{\"settled\":true,\"required_probes\":[{\"id\":\"retry-contract\",\"status\":\"passed\",\"result\":\"Synthetic fixture input: Retry-After seconds/date parsing, ceiling and fallback probes passed.\"}],\"findings\":[]},\"native_adversarial\":{\"settled\":true,\"status\":\"completed\",\"findings\":[],\"result\":\"Synthetic fixture input: native adversarial review returned no findings.\"},\"structured_review\":{\"required\":false,\"reason\":\"Tiny diff; no full-review, structured-review or P1 override requested.\"}}}",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01JHrJYtsYw8HnZzznyDcNo6",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": "'/__w/gstack/gstack/bin/gstack-review-log' '{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:54:34Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10.0,\"specialists\":{},\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\"}],\"commit\":\"0065f04\",\"completed\":true,\"converged\":true,\"cycles\":0}' --finish 547c0e1f-86d4-40e2-8be6-bf87f439bc75 && '/__w/gstack/gstack/bin/gstack-review-read'"
|
||||
},
|
||||
"caller": {
|
||||
"type": "direct"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"tool_use_id": "toolu_01JHrJYtsYw8HnZzznyDcNo6",
|
||||
"type": "tool_result",
|
||||
"content": "{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:53:31.755Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\",\"snapshot_covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}],\"completed\":true,\"converged\":true,\"cycles\":0,\"status_source\":\"claimed\",\"commit_full\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"tree\":\"3d257818bedb4ab8770ce806c8b0bfcc45537857\",\"dirty\":true,\"review_binding\":{\"state\":\"verified\",\"start_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"end_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:31.753Z\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\"},\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"shared_libs_coverage_version\":1,\"review_freshness\":{\"status\":\"CURRENT\",\"reason\":\"completed clean pass on unchanged content\"}}\n{\"skill\":\"review\",\"timestamp\":\"2026-10-28T01:54:34Z\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"specialists\":{},\"findings\":[{\"severity\":\"INFORMATIONAL\",\"confidence\":9,\"advisory\":true,\"path\":\"src/retry-worker.ts\",\"line\":2,\"category\":\"shared-libs\",\"summary\":\"Use the established Retry-After contract in the changed worker and the authored route sources.\",\"fix\":\"Share the tested retrySeconds contract, preserving runtime and deployment boundaries for each caller.\",\"evidence_paths\":[\"src/retry-worker.ts\",\"runtime-local/retry-route.ts\",\"lib/retry-after.ts\"],\"helper_target\":{\"path\":\"lib/retry-after.ts\",\"symbol\":\"retrySeconds\"},\"fingerprint\":\"shared-libs:52c933addcfd80ba554222133fe75e4bd06d9dfb5d1bc4484f0460a1517cf881\",\"action\":\"skipped\",\"snapshot_covered_paths\":[\"src/retry-worker.ts\",\"lib/retry-after.ts\"]}],\"commit\":\"0065f04\",\"completed\":true,\"converged\":true,\"cycles\":0,\"status_source\":\"claimed\",\"commit_full\":\"0065f046b3be1008d3788dcaa596fbdadc1b540b\",\"tree\":\"3d257818bedb4ab8770ce806c8b0bfcc45537857\",\"dirty\":true,\"review_binding\":{\"state\":\"verified\",\"start_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"end_wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"started_at\":\"2026-10-28T01:53:45.785Z\",\"branch_id\":\"951d42dc02dc743167ac3dd9d8decc5eee71860498c4b1707e1d38816df1ed1d\"},\"wtree\":\"96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\",\"shared_libs_coverage_version\":1,\"review_freshness\":{\"status\":\"CURRENT\",\"reason\":\"completed clean pass on unchanged content\"}}\n---CONFIG---\nfalse---HEAD---\n0065f04\n---WTREE---\n96f7fc703bfab4db7f3da7cfbfefaf36fa367a4c\n---TREE---\n3d257818bedb4ab8770ce806c8b0bfcc45537857\n---DIRTY---\ntrue",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,13 +15,13 @@ export const docsCapsCommand = (skills: string) => `${skills}/bin/gstack-ship-re
|
||||
export function docsNativeInterface(fixture: Pick<ReturnType<typeof fixtureDocs>, 'home' | 'repo' | 'skills'> & Partial<Pick<ReturnType<typeof fixtureDocs>, 'env'>>, scripts: string[] = [], transport = false, insert = false, file?: string): string {
|
||||
const skills = fixture.skills.split(path.sep).join('/');
|
||||
const state = fixture.env?.GSTACK_HOME ?? path.join(fixture.home, 'state');
|
||||
return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch listings with no operand and only --show-current, -a/--all, -r/--remotes, -v/--verbose, -l/--list, --no-color or --no-abbrev (no --merged, --sort, --format or --contains), describe with only --tags, --always, --abbrev=<n> or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (a --help is no exception, so never add one to a line of reads): the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare <that .json> and --help commands, and /ship Step 14.5's three own-tree housekeeping commands exactly as written, each on its own line: the shared .gstack/tmp exclude line (the one &&-joined housekeeping line this interface admits)
|
||||
return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch listings with no operand and only --show-current, -a/--all, -r/--remotes, -v/--verbose, -l/--list, --no-color or --no-abbrev (no --merged, --sort, --format or --contains), tag listings with no operand and only -l/--list, describe with only --tags, --always, --abbrev=<n> or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (one exception follows the read grammar below): the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare <that .json> and --help commands, and /ship Step 14.5's three own-tree housekeeping commands exactly as written, each on its own line: the shared .gstack/tmp exclude line (the one &&-joined housekeeping line this interface admits)
|
||||
${DOCS_TMP_EXCLUDE}
|
||||
then
|
||||
${DOCS_AUDIT_RESET}
|
||||
and the cap gate, which reads origin/main's policy and whose DOCS_CAP lines and exit status decide it
|
||||
${docsCapsCommand(skills)}
|
||||
(no --json, no other base, no other gstack-ship-receipt subcommand). ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no own-call command ever joins such a line. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs, those private artifacts and Step 14.5's receipt record ${fixture.repo}/${DOCS_AUDIT_RECORD} (the only permitted write under the repository besides the document, excluded from git by the line above); do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations.
|
||||
(no --json, no other base, no other gstack-ship-receipt subcommand). ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. So may the installed ${skills}/bin/gstack-docs-candidate --help, alone or after snapshot or compare, because it only prints usage (the one exception); neither a joined Git read nor that --help may be fed by a pipe. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no other own-call command ever joins such a line. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs, those private artifacts and Step 14.5's receipt record ${fixture.repo}/${DOCS_AUDIT_RECORD} (the only permitted write under the repository besides the document, excluded from git by the line above); do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations.
|
||||
|
||||
The working directory for parent and child Bash calls is already ${fixture.repo}. Run Git reads directly, for example: git status, git diff --cached, git merge-base main HEAD, git rev-parse HEAD. Do not use Git global options such as -C, -c, --git-dir or --work-tree, and do not prepend cd or another shell wrapper. The literal git subcommand must immediately follow git; an absolute owned repository path does not make git -C an allowed command.
|
||||
|
||||
|
||||
@@ -322,9 +322,12 @@ function docsUnrollLoops(text: string): string | null {
|
||||
|
||||
/**
|
||||
* A read-only line the shared grammar cannot see whole: loops are checked as
|
||||
* their unrolled passes, and each declared Git read joined to grammar reads
|
||||
* (never after a cd, never fed by a pipe) is checked literally and then stands
|
||||
* in the line as a neutral read for the grammar.
|
||||
* their unrolled passes, and each declared Git read or installed candidate
|
||||
* --help joined to grammar reads (never after a cd, never fed by a pipe) is
|
||||
* checked literally and then stands in the line as a neutral read for the grammar.
|
||||
* The candidate's --help prints its usage and returns before it touches Git or a
|
||||
* file (bin/gstack-docs-candidate main), so joining it reads nothing more (gates
|
||||
* 40600731931 and 40627127462, screen 40558235014: the parent ended a line of reads with it).
|
||||
*/
|
||||
function docsComposedReadAllowed(text: string, fixture: ReturnType<typeof fixtureDocs>): boolean {
|
||||
const line = docsUnrollLoops(text);
|
||||
@@ -332,13 +335,16 @@ function docsComposedReadAllowed(text: string, fixture: ReturnType<typeof fixtur
|
||||
if (line !== text && readOnlyCommandAllowed(line, docsReadOnlyScope(fixture))) return true;
|
||||
const segments = docsTopLevelSegments(line);
|
||||
if (!segments) return false;
|
||||
const candidate = path.join(fixture.skills, 'bin/gstack-docs-candidate').split(path.sep).join('/');
|
||||
const commands = segments.map(segment => line.slice(segment.start, segment.end).trim());
|
||||
const git = commands.flatMap((command, index) => /^git(?:\s|$)/.test(command) ? [index] : []);
|
||||
if (!git.length || commands.some(command => /^cd(?:\s|$)/.test(command))) return false;
|
||||
const declared = commands.flatMap((command, index) => /^git(?:\s|$)/.test(command) || command.startsWith(`${candidate} `) ? [index] : []);
|
||||
if (!declared.length || commands.some(command => /^cd(?:\s|$)/.test(command))) return false;
|
||||
let neutral = line;
|
||||
for (const index of git.reverse()) {
|
||||
for (const index of declared.reverse()) {
|
||||
const read = unwrapHarmlessRead(commands[index]!, fixture);
|
||||
if (segments[index - 1]?.op === '|' || read === null || !/^git\s/.test(read) || !literalDocsCommandAllowed(read, fixture, [])) return false;
|
||||
const help = read !== null && /^(?:(?:snapshot|compare) )?--help$/.test(read.slice(candidate.length + 1));
|
||||
if (segments[index - 1]?.op === '|' || read === null || !(/^git\s/.test(read) || (read.startsWith(`${candidate} `) && help))
|
||||
|| !literalDocsCommandAllowed(read, fixture, [])) return false;
|
||||
neutral = `${neutral.slice(0, segments[index]!.start)} pwd ${neutral.slice(segments[index]!.end)}`;
|
||||
}
|
||||
return readOnlyCommandAllowed(neutral, docsReadOnlyScope(fixture));
|
||||
@@ -384,6 +390,9 @@ function literalDocsCommandAllowed(text: string, fixture: ReturnType<typeof fixt
|
||||
// A branch listing reads refs (gates 40458489644 and 40477638211: the parent oriented with `git branch -a`);
|
||||
// an operand or any other option creates, moves, deletes or retargets a branch.
|
||||
if (rest[0] === 'branch') return rest.slice(1).every(arg => DOCS_BRANCH_LIST_FLAGS.test(arg));
|
||||
// A tag listing reads refs the same way (gate 40643690306: the parent ended a line of reads with `git tag`);
|
||||
// an operand or any other option creates, deletes, signs or verifies a tag.
|
||||
if (rest[0] === 'tag') return rest.slice(1).every(arg => arg === '-l' || arg === '--list');
|
||||
if (rest[0] === 'describe') {
|
||||
const operands = rest.slice(1).filter(arg => !DOCS_DESCRIBE_FLAGS.test(arg));
|
||||
return operands.length <= 1 && operands.every(arg => !arg.startsWith('-'));
|
||||
|
||||
@@ -85,6 +85,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/design-handoff-q-calls.json': ['test/design-completion-handoff-scored.test.ts'],
|
||||
'test/fixtures/design-md-legacy.md': ['test/design-md.test.ts'],
|
||||
'test/fixtures/design-ui-focus-capture-39199692993.json': ['test/plan-design-with-ui-fixture.test.ts'],
|
||||
'test/fixtures/design-ui-focus-capture-40664336141.json': ['test/plan-design-with-ui-fixture.test.ts'],
|
||||
'test/fixtures/design-ui-focus-capture.json': ['test/plan-design-with-ui-fixture.test.ts'],
|
||||
'test/fixtures/detector-corpora/auto-decide-preserved/**': ['test/detector-corpus-auto-decide-preserved.test.ts'],
|
||||
'test/fixtures/detector-corpora/plan-ceo-section-loading/**': ['test/detector-corpus-plan-ceo-section-loading.test.ts'],
|
||||
@@ -200,6 +201,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/shared-libs-claude-version-probe.json': ['test/shared-libs-fixture.test.ts'],
|
||||
'test/fixtures/shared-libs-index-flags-loop-read-39173752116.json': ['test/shared-libs-checker-interface-evidence.test.ts'],
|
||||
'test/fixtures/shared-libs-no-change-ci-public.json': ['test/shared-libs-fixture.test.ts'],
|
||||
'test/fixtures/shared-libs-path-ignored-errored-read-40617200857.json': ['test/shared-libs-checker-interface-evidence.test.ts'],
|
||||
'test/fixtures/shared-libs-plan-callers-fixes-behavior-local.json': ['test/shared-libs-plan-actor.test.ts'],
|
||||
'test/fixtures/shared-libs-plan-callers-future-risk-37195203538.json': ['test/shared-libs-plan-actor.test.ts'],
|
||||
'test/fixtures/shared-libs-plan-callers-no-hardening-36633323521.json': ['test/shared-libs-plan-actor.test.ts'],
|
||||
|
||||
@@ -10,7 +10,7 @@ import { COOKIE_MANUAL_REVIEW_FILE } from './cookie-workflow-manual-review';
|
||||
// exact historical prompt bytes, including the section's line range.
|
||||
const LATER_BROWSER_BLOCK = /\*\*Windows: Opera and Opera GX\.\*\*[\s\S]*?appear in CLI output\.\n\n/;
|
||||
const LATER_BROWSER_EDITS: Array<[string, string]> = [
|
||||
['The picker recognizes Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet, Arc, and Dia.', 'The picker recognizes Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, and Dia.'],
|
||||
['The picker recognizes Chrome, Chromium, Brave, Edge, Windows-only Opera and Opera GX, and macOS-only Comet and Arc.', 'The picker recognizes Chrome, Chromium, Brave, Edge, and macOS-only Comet, Arc, and Dia.'],
|
||||
[' Opera and Opera GX are Windows-only and read from `%APPDATA%\\Opera Software\\Opera Stable` or `Opera GX Stable`, in `Default` or `Profile N` directories; legacy root-level layouts, Opera side profiles and portable or relocated installs are not detected. Opera has no native extraction, so its App-Bound cookies (if any) need manual sign-in.', ''],
|
||||
];
|
||||
|
||||
@@ -35,6 +35,8 @@ const LATER_WAVE_EDITS: Array<[string, string]> = [
|
||||
"The skill-start output above already ran artifacts sync. Act on its lines:\nGBrain hint text (if present) tells you when to prefer `gbrain` over Grep;\n`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,\n`remote-mode`, or a restore hint naming `gstack-brain-restore`).\n\nThe one-time privacy stop-gate (artifacts-sync consent) arrives as a\n`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending\n\u2014 fire it via AskUserQuestion exactly as the block instructs."],
|
||||
// Oct 8 wave: the tier-1 voice directive gained the reply-language rule (two lines longer).
|
||||
["Reply in the language of the user's latest message unless asked otherwise. Code, commands, paths, identifiers and quoted output stay verbatim.\n\n", ''],
|
||||
// Dia removal (GSTA-143) dropped its platform sentence; same line count.
|
||||
["\nKeychain approval is the user's choice.", "\nDia is macOS-only. Keychain approval is the user's choice."],
|
||||
// #3129 dropped the Claude body's "When to invoke" section above the preamble (seven lines shorter).
|
||||
['<!-- Regenerate: bun run gen:skill-docs -->\n\n## Preamble (run first)\n', '<!-- Regenerate: bun run gen:skill-docs -->\n\n\n## When to invoke this skill\n\nOpens an interactive picker UI where you select which cookie domains to import.\nUse before QA testing authenticated pages. Use when asked to "import cookies",\n"login to the site", or "authenticate the browser".\n\n## Preamble (run first)\n'],
|
||||
];
|
||||
|
||||
@@ -438,9 +438,9 @@ function inspectsFile(source: string, file: string, text: string, expected: Star
|
||||
}
|
||||
|
||||
/** Inspect native public tool blocks only; narration and instruction contents are not evidence. */
|
||||
function nativeToolPairs(events: unknown[]) {
|
||||
function nativeToolPairs(events: unknown[], includeErrors = false) {
|
||||
const pending = new Map<string, { tool: string; input: any; at: number }>();
|
||||
const pairs: { tool: string; input: any; at: number; returnedAt: number; text: string }[] = [];
|
||||
const pairs: { tool: string; input: any; at: number; returnedAt: number; text: string; error: boolean }[] = [];
|
||||
let position = 0;
|
||||
for (const event of events as any[]) {
|
||||
for (const block of Array.isArray(event?.message?.content) ? event.message.content : []) {
|
||||
@@ -450,11 +450,11 @@ function nativeToolPairs(events: unknown[]) {
|
||||
} else if (event.type === 'user' && block.type === 'tool_result') {
|
||||
const call = pending.get(block.tool_use_id);
|
||||
pending.delete(block.tool_use_id);
|
||||
if (!call || block.is_error === true) continue;
|
||||
if (!call || block.is_error === true && !includeErrors) continue;
|
||||
const text = typeof block.content === 'string' ? block.content
|
||||
: Array.isArray(block.content) ? block.content.filter((part: any) => part.type === 'text'
|
||||
&& typeof part.text === 'string').map((part: any) => part.text).join('\n') : '';
|
||||
pairs.push({ ...call, returnedAt: position, text });
|
||||
pairs.push({ ...call, returnedAt: position, text, error: block.is_error === true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,6 +467,8 @@ interface CheckerContext extends StartContext {
|
||||
finding: any;
|
||||
reusable: boolean;
|
||||
coveredPaths: string[];
|
||||
/** Exact evidence sources: an errored Bash read counts only when its output holds the whole source. */
|
||||
sources?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function hasTrustedSharedLibsCheck(events: unknown[], expected: CheckerContext): boolean {
|
||||
@@ -497,8 +499,11 @@ export function hasTrustedSharedLibsCheck(events: unknown[], expected: CheckerCo
|
||||
}).map(call => ({ ...call, last: call === calls.at(-1),
|
||||
words: call.words.map(word => expandVariables(word, call.variables) ?? word) }));
|
||||
};
|
||||
const pairs = nativeToolPairs(events);
|
||||
const bash = pairs.filter(pair => pair.tool === 'Bash' && typeof pair.input?.command === 'string');
|
||||
// Run 40617200857: a later `echo ===` failed in the same call, so the result that
|
||||
// returned the whole first file was marked is_error. Such a read counts only in a
|
||||
// multi-command call whose output holds the exact source. Start, check and finish stay strict.
|
||||
const pairs = nativeToolPairs(events, true);
|
||||
const bash = pairs.filter(pair => !pair.error && pair.tool === 'Bash' && typeof pair.input?.command === 'string');
|
||||
for (const start of bash) {
|
||||
const direct = invocations(start.input.command).some(call => call.last && call.words.length === 3
|
||||
&& call.words[1] === '--start' && call.words[2] === 'review');
|
||||
@@ -546,10 +551,13 @@ export function hasTrustedSharedLibsCheck(events: unknown[], expected: CheckerCo
|
||||
const reads = pairs.filter(pair => pair.at > start.returnedAt && pair.returnedAt < check.at && pair.text.trim());
|
||||
if (!expected.finding.evidence_paths.every((relative: string) => {
|
||||
const file = paths.join(expected.repo, relative);
|
||||
return reads.some(read => read.tool === 'Read' && typeof read.input?.file_path === 'string'
|
||||
const source = expected.sources?.[relative]?.replace(/\n$/, '');
|
||||
return reads.some(read => read.tool === 'Read' && !read.error && typeof read.input?.file_path === 'string'
|
||||
&& literalPath(read.input.file_path, expected.repo, paths) === file
|
||||
|| read.tool === 'Bash' && typeof read.input?.command === 'string'
|
||||
&& inspectsFile(read.input.command, file, read.text, expected));
|
||||
&& inspectsFile(read.input.command, file, read.text, expected)
|
||||
&& (!read.error || !!source && commands(read.input.command).length > 1
|
||||
&& read.text.replace(/^ *\d+\t/gm, '').includes(source)));
|
||||
})) continue;
|
||||
if (bash.some(finish => finish.at > check.returnedAt && invocations(finish.input.command).some(call =>
|
||||
call.words.length === 4 && call.words[2] === '--finish' && call.words[3] === token))) return true;
|
||||
|
||||
@@ -98,10 +98,18 @@ export function ghQueueShim(repo: FixtureRepo, data: { prs: Record<string, unkno
|
||||
return { PATH: `${dir}:${process.env.PATH ?? ''}` };
|
||||
}
|
||||
|
||||
/** A coordinator's separate clone of the fixture origin. */
|
||||
/**
|
||||
* A coordinator's separate clone of the fixture origin, fetched through git's
|
||||
* transport (--no-local) like a real clone of GitHub. A local clone copies
|
||||
* origin.git/objects entry by entry, and every push into the fixture origin
|
||||
* leaves a detached `git maintenance run --auto` there that creates and
|
||||
* deletes objects/maintenance.lock after the push returns: local transport
|
||||
* drops the shard's GIT_CONFIG_COUNT maintenance-off backstop, so it never
|
||||
* reaches that receive-pack.
|
||||
*/
|
||||
export function coordinatorClone(repo: FixtureRepo): string {
|
||||
const dir = path.join(repo.root, 'coordinator');
|
||||
const r = spawnSync('git', ['clone', '-q', repo.origin, dir], { timeout: 60_000, env: { ...process.env, ...repo.env } });
|
||||
const r = spawnSync('git', ['clone', '-q', '--no-local', repo.origin, dir], { timeout: 60_000, env: { ...process.env, ...repo.env } });
|
||||
if (r.status !== 0) throw new Error(`clone failed: ${r.stderr?.toString()}`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as path from 'node:path';
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SUITE = path.join(ROOT, 'test/skill-e2e-office-hours-brain-writeback.test.ts');
|
||||
|
||||
async function probeWritebackEnvironment(option: 'env' | 'extraEnv') {
|
||||
async function probeWritebackEnvironment(option: 'env' | 'extraEnv', put: 'content' | 'stdin' | 'stdin-short' = 'content') {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'office-hours-env-'));
|
||||
const driverBin = path.join(dir, 'bin');
|
||||
const operatorHome = path.join(dir, 'operator-home');
|
||||
@@ -43,7 +43,9 @@ const version = Bun.spawn(['gbrain', '--version'], { env: process.env, stdin: 'i
|
||||
const [versionOut, versionErr, versionCode] = await Promise.all([new Response(version.stdout).text(), new Response(version.stderr).text(), version.exited]);
|
||||
if (versionCode || versionErr || versionOut.trim() !== 'gbrain test-0.41.0') process.exit(44);
|
||||
const payload = '---\\ntitle: Pixel fund\\ntags: [fixture]\\n---\\n' + 'Design detail. '.repeat(25);
|
||||
const put = Bun.spawn(['gbrain', 'put', 'office-hours/pixel-fund', '--content', payload], { env: process.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
||||
const put = ${JSON.stringify(put)} === 'content'
|
||||
? Bun.spawn(['gbrain', 'put', 'office-hours/pixel-fund', '--content', payload], { env: process.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
|
||||
: Bun.spawn(['gbrain', 'put', 'office-hours/pixel-fund'], { env: process.env, stdin: new Blob([${JSON.stringify(put)} === 'stdin' ? payload : 'not a page']), stdout: 'pipe', stderr: 'pipe' });
|
||||
const [putOut, putErr, putCode] = await Promise.all([new Response(put.stdout).text(), new Response(put.stderr).text(), put.exited]);
|
||||
if (putCode || putOut || putErr) process.exit(45);
|
||||
fs.writeFileSync(${JSON.stringify(receipt)}, JSON.stringify({ ...facts, fakeVersionMatches: true, fakePutSucceeded: true,
|
||||
@@ -108,6 +110,21 @@ describe('office-hours writeback child environment', () => {
|
||||
expect(facts.fakePutSucceeded).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
// GSTA-134 b0fba22: failing trials piped the page on stdin, as the SAVE_RESULTS template shows.
|
||||
test('a page piped on stdin reaches the payload the original assertions read', async () => {
|
||||
const { code, output, facts } = await probeWritebackEnvironment('env', 'stdin');
|
||||
expect(code, output).toBe(0);
|
||||
expect(output).toContain('1 pass');
|
||||
expect(facts.fakePutSucceeded).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
test('a stdin body without frontmatter still fails the original payload assertions', async () => {
|
||||
const { code, output, facts } = await probeWritebackEnvironment('env', 'stdin-short');
|
||||
expect(code).toBe(1);
|
||||
expect(output).toContain('Received: "not a page"');
|
||||
expect(facts.fakePutSucceeded).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
test('restoring extraEnv fails before any fallback gbrain executes', async () => {
|
||||
const { code, output, facts } = await probeWritebackEnvironment('extraEnv');
|
||||
expect(code).toBe(1);
|
||||
|
||||
@@ -95,6 +95,10 @@ const captured39363705692 = JSON.parse(fs.readFileSync(${JSON.stringify(path.joi
|
||||
// "completeness") and offered "All 7 passes (recommended)" against "States + layout only".
|
||||
const captured39852003387 = JSON.parse(fs.readFileSync(${JSON.stringify(path.join(ROOT, 'test/fixtures/design-ui-focus-capture-39852003387.json'))}, 'utf8'))
|
||||
.calls.map(({ preReview, observedAtMs, call }) => nativePlanCallFingerprint(call, observedAtMs, preReview));
|
||||
// Run 40664336141 slice 6 (00e71383): Opus 5.5 asked "I rated this plan 4/10 on design completeness.
|
||||
// Should I review all 7 areas or focus on a few?" with lettered options ("A) Review all 7 areas").
|
||||
const captured40664336141 = JSON.parse(fs.readFileSync(${JSON.stringify(path.join(ROOT, 'test/fixtures/design-ui-focus-capture-40664336141.json'))}, 'utf8'))
|
||||
.calls.map(({ preReview, observedAtMs, call }) => nativePlanCallFingerprint(call, observedAtMs, preReview));
|
||||
const unnumberedFinding = fp('unnumbered-finding', "Which region is the dashboard's primary anchor, and how is the page composed?", false);
|
||||
unnumberedFinding.nativeCall.questions[0].options = nativeFinding.nativeCall.questions[0].options;
|
||||
const finding = fp('finding', 'Which loading feedback should Save show?', false);
|
||||
@@ -223,6 +227,20 @@ mock.module(${JSON.stringify(path.join(ROOT, 'test/helpers/claude-pty-runner.ts'
|
||||
expect(opts.isReviewAUQ(captured39852003387[0])).toBe(false);
|
||||
expect(captured39852003387.slice(1, 3).some(call => opts.isLastStep0AUQ(call) || opts.isReviewAUQ(call))).toBe(false);
|
||||
expect(opts.isReviewAUQ(captured39852003387[3])).toBe(true);
|
||||
expect(opts.isLastStep0AUQ(captured40664336141[1])).toBe(true);
|
||||
expect(opts.isReviewAUQ(captured40664336141[1])).toBe(false);
|
||||
expect([0, 2].some(index => opts.isLastStep0AUQ(captured40664336141[index]))).toBe(false);
|
||||
expect(opts.isReviewAUQ(captured40664336141[3])).toBe(true);
|
||||
const lettered = ['A) Review all 7 areas (recommended)', 'B) Focus on hierarchy/states/responsive'];
|
||||
for (const [label, question, labels] of [
|
||||
['title-offer-unrated', 'D2: Should I review all 7 areas or focus on a few?\\nELI10: I will review every area unless you narrow it.', lettered],
|
||||
['title-offer-out-of-seven', 'D2: I rated this plan 4/7 on design completeness. Should I review all 7 areas or focus on a few?', lettered],
|
||||
['title-offer-without-focus', 'D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas?', lettered],
|
||||
['title-offer-other-count', 'D2: I rated this plan 4/10 on design completeness. Should I review all 4 areas or focus on a few?', lettered],
|
||||
['offer-below-title', 'D2: I rated this plan 4/10 on design completeness. Want me to focus?\\nNext I will review all 7 areas.', lettered],
|
||||
['issue-labeled-title-offer', 'D5 (Issue 3): Keyboard focus across all 7 panels? I rated this plan 4/10 on design completeness.', ['3A Trap focus in the modal', '3B Leave focus order as is']],
|
||||
['single-option-title-offer', 'D2: I rated this plan 4/10 on design completeness. Should I review all 7 areas or focus on a few?', lettered.slice(0, 1)],
|
||||
]) expect(opts.isLastStep0AUQ(labeled(label, question, labels)), label).toBe(false);
|
||||
const areas = ['All 7 areas (recommended)', 'Only specific areas'];
|
||||
for (const body of ['ELI10: I rated this plan 4/10 for design completeness.', 'ELI10: This plan is a 3/10 on design completeness.',
|
||||
'ELI10: Design completeness: 6.5/10. The gaps are layout and states.', 'ELI10: I rated this plan 4/10 on design.']) {
|
||||
@@ -298,6 +316,8 @@ mock.module(${JSON.stringify(path.join(ROOT, 'test/helpers/claude-pty-runner.ts'
|
||||
: mode === 'captured-39363705692-no-focus' ? captured39363705692.filter((_, index) => index !== 0)
|
||||
: mode === 'captured-39852003387' ? captured39852003387
|
||||
: mode === 'captured-39852003387-no-focus' ? captured39852003387.filter((_, index) => index !== 0)
|
||||
: mode === 'captured-40664336141' ? captured40664336141
|
||||
: mode === 'captured-40664336141-no-focus' ? captured40664336141.filter((_, index) => index !== 1)
|
||||
: mode === 'captured-focus-after-finding' ? [...captured.filter((_, index) => index !== 1), captured[1]]
|
||||
: mode.startsWith('target-menu') ? [target, fp('other', 'Which artifact should I inspect?', false)]
|
||||
: mode === 'early-exit' ? [] : mode === 'focus-only' ? [chosenFocus]
|
||||
@@ -342,12 +362,12 @@ await import(${JSON.stringify(path.join(ROOT, 'test/skill-e2e-plan-design-with-u
|
||||
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
test.each(['source', 'paraphrase', 'native-sequence', 'native-unnumbered', 'native-passes', 'captured', 'captured-rated', 'captured-39298269902', 'captured-39363705692', 'captured-39852003387'])('UI gate seeds the exact target and accepts Design progress with an unselected no-UI alternative (%s)', mode => {
|
||||
test.each(['source', 'paraphrase', 'native-sequence', 'native-unnumbered', 'native-passes', 'captured', 'captured-rated', 'captured-39298269902', 'captured-39363705692', 'captured-39852003387', 'captured-40664336141'])('UI gate seeds the exact target and accepts Design progress with an unselected no-UI alternative (%s)', mode => {
|
||||
const result = exercise(mode);
|
||||
expect(result.code, result.output).toBe(0);
|
||||
}, 20_000);
|
||||
|
||||
test.each(['target-menu', 'target-menu-design-system', 'focus-only', 'captured-no-focus', 'captured-focus-after-finding', 'captured-rated-no-focus', 'captured-39298269902-no-focus', 'captured-39298269902-focus-only', 'captured-39363705692-no-focus', 'captured-39852003387-no-focus', 'native-setup-only', 'native-voices-only', 'early-exit', 'timeout', 'exited', 'unanswered-finding', 'failed-finding', 'no-native-finding'])('UI gate rejects %s and removes its fixture', mode => {
|
||||
test.each(['target-menu', 'target-menu-design-system', 'focus-only', 'captured-no-focus', 'captured-focus-after-finding', 'captured-rated-no-focus', 'captured-39298269902-no-focus', 'captured-39298269902-focus-only', 'captured-39363705692-no-focus', 'captured-39852003387-no-focus', 'captured-40664336141-no-focus', 'native-setup-only', 'native-voices-only', 'early-exit', 'timeout', 'exited', 'unanswered-finding', 'failed-finding', 'no-native-finding'])('UI gate rejects %s and removes its fixture', mode => {
|
||||
const result = exercise(mode);
|
||||
expect(result.code, result.output).toBe(1);
|
||||
expect(result.output).toContain('plan-design-review with UI scope FAILED');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isPreTurnInfraFailure, recordE2E } from './helpers/e2e-helpers';
|
||||
import { EvalCollector, isFinalizedEvalResultFile, listEvalJsonFiles, type EvalTestEntry } from './helpers/eval-store';
|
||||
import { OFFICE_HOURS_BUN_GRACE_MS, runRecordedOfficeHoursAttempt } from './helpers/office-hours-attempt';
|
||||
import { isPaidTestFile } from './helpers/paid-test-set';
|
||||
import { seedHermeticGstackHome } from './helpers/hermetic-env';
|
||||
import { resolveEvalModel } from '../lib/eval-model';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -21,6 +22,17 @@ const CASE = '/plan-eng-review writes GSTACK REVIEW REPORT to plan file';
|
||||
|
||||
type Scenario = { kind: string; table?: string; exitReason?: string; open?: boolean };
|
||||
|
||||
// Run the copied section's real Outside Voice preflight against a state root.
|
||||
function outsideMode(section: string, home: string, gstackHome?: string): string {
|
||||
const block = section.slice(section.indexOf('**Preflight — decide whether and how the outside voice runs:**')).match(/```bash\n([\s\S]*?)```/)![1];
|
||||
// Git Bash on Windows needs its own spelling of the checkout path.
|
||||
const bin = Bun.spawnSync(['bash', '-c', 'cd "$1" && pwd', '_', path.join(ROOT, 'bin')], { timeout: 10_000 }).stdout.toString().trim();
|
||||
const child = Bun.spawnSync(['bash', '-c', block.replaceAll('"$HOME/.claude/skills/gstack/bin/gstack-config"', '"$GSTACK_CONFIG_BIN"')], {
|
||||
env: { PATH: process.env.PATH ?? '', HOME: home, GSTACK_CONFIG_BIN: `${bin}/gstack-config`,
|
||||
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), ...(gstackHome ? { GSTACK_HOME: gstackHome } : {}) }, timeout: 10_000 });
|
||||
return child.stdout.toString().trim();
|
||||
}
|
||||
|
||||
async function exercise(scenarios: Scenario[], opts: { deadline?: boolean; sibling?: boolean } = {}) {
|
||||
const owned = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-report-recording-free-'));
|
||||
const evalDir = path.join(owned, 'evals');
|
||||
@@ -46,7 +58,7 @@ async function exercise(scenarios: Scenario[], opts: { deadline?: boolean; sibli
|
||||
for (const declaration of source.matchAll(/^import[\s\S]*?;\n/gm)) executable = executable.replace(declaration[0], '');
|
||||
const js = new Bun.Transpiler({ loader: 'ts', target: 'bun' }).transformSync(executable);
|
||||
const args: Record<string, any> = {
|
||||
expect, ROOT, runId: 'public-report-free', evalsEnabled: true, CAPTURE_LONG_MS, resolveEvalModel,
|
||||
expect, ROOT, runId: 'public-report-free', evalsEnabled: true, CAPTURE_LONG_MS, resolveEvalModel, seedHermeticGstackHome,
|
||||
OFFICE_HOURS_BUN_GRACE_MS, EvalCollector: BoundCollector, recordE2E,
|
||||
createEvalCollector: (tier: string) => new BoundCollector(tier),
|
||||
finalizeEvalCollector: async (collector: EvalCollector | null) => {
|
||||
@@ -82,7 +94,10 @@ async function exercise(scenarios: Scenario[], opts: { deadline?: boolean; sibli
|
||||
runSkillTest: async (options: any) => {
|
||||
const file = path.join(options.workingDirectory, 'plan.md');
|
||||
const seed = fs.readFileSync(file, 'utf8');
|
||||
const section = fs.readFileSync(path.join(options.workingDirectory, 'plan-eng-review/sections/review-sections.md'), 'utf8');
|
||||
calls.push({ ...options, seed,
|
||||
outside: outsideMode(section, options.workingDirectory, options.env?.GSTACK_HOME),
|
||||
unseeded: outsideMode(section, options.workingDirectory, fs.mkdtempSync(path.join(owned, 'unseeded-state-'))),
|
||||
main: fs.readFileSync(path.join(options.workingDirectory, 'plan-eng-review/SKILL.md'), 'utf8'),
|
||||
section: fs.readFileSync(path.join(options.workingDirectory, 'plan-eng-review/sections/review-sections.md'), 'utf8'),
|
||||
});
|
||||
@@ -219,7 +234,12 @@ test('canonical reports preserve both successful exits, terminal forms, metadata
|
||||
expect(call.main).toBe(fs.readFileSync(path.join(ROOT, 'plan-eng-review/SKILL.md'), 'utf8'));
|
||||
expect(call.section).toBe(fs.readFileSync(path.join(ROOT, 'plan-eng-review/sections/review-sections.md'), 'utf8'));
|
||||
expect(call.section).toContain('| Review | Trigger | Why | Runs | Status | Findings |');
|
||||
// Gate 40660626349: both reds spent 4 of 21 turns on the default-on outside voice.
|
||||
expect(call.outside).toBe('CODEX_MODE: disabled');
|
||||
expect(call.unseeded).not.toContain('disabled');
|
||||
expect(call.unseeded).toMatch(/^CODEX_MODE: \w+$/);
|
||||
}
|
||||
expect(actual.fixtures.filter(dir => path.basename(dir).startsWith('skill-e2e-review-report-state-'))).toHaveLength(2);
|
||||
for (const outcome of actual.outcomes) {
|
||||
expect(outcome.entries).toHaveLength(1);
|
||||
expect(outcome.entries[0]).toMatchObject({ name: '/plan-review-report', suite: 'Plan Review Report E2E', tier: 'e2e', passed: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
test.each(['success', 'bash-edit', 'max-turns', 'no-edit', 'no-commit', 'runner', 'retry', 'directory', 'recording', 'late-timeout'])
|
||||
test.each(['success', 'bash-edit', 'max-turns', 'no-edit', 'no-commit', 'runner', 'retry', 'directory', 'recording', 'late-timeout', 'slow-setup'])
|
||||
('QA attempt recording and fixture ownership: %s', scenario => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qa-fix-body-'));
|
||||
const script = path.join(dir, 'body.fixture.test.ts');
|
||||
@@ -21,7 +21,10 @@ const scenario = ${JSON.stringify(scenario)};
|
||||
const attempts = [], records = [], servers = [];
|
||||
const late = scenario === 'late-timeout';
|
||||
const workMs = 1000, drainMs = 80;
|
||||
let finalized = false, outerMs;
|
||||
const realNow = Date.now, slowSetup = scenario === 'slow-setup';
|
||||
let finalized = false, outerMs, frozenAt, clock = realNow();
|
||||
if (late) Date.now = () => frozenAt ?? realNow();
|
||||
else if (slowSetup) Date.now = () => clock;
|
||||
const collector = { addTest: entry => {
|
||||
records.push({ ...entry, afterFinalization: finalized });
|
||||
if (scenario === 'recording') throw new Error('QA recorder failure');
|
||||
@@ -36,7 +39,7 @@ Bun.serve = options => {
|
||||
return server;
|
||||
};
|
||||
mock.module(path.join(root, 'test/helpers/aside-available.ts'), () => ({ asideAvailable: () => false }));
|
||||
if (late) mock.module(path.join(root, 'test/helpers/eval-budgets.ts'), () => ({
|
||||
if (late || slowSetup) mock.module(path.join(root, 'test/helpers/eval-budgets.ts'), () => ({
|
||||
JUDGE_MS: 120_000, CAPTURE_MS: 300_000, CAPTURE_LONG_MS: workMs,
|
||||
}));
|
||||
mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({
|
||||
@@ -46,13 +49,13 @@ mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({
|
||||
testConcurrentIfSelected: (id, body, budget) => {
|
||||
if (id !== 'qa-fix-loop') return;
|
||||
outerMs = budget;
|
||||
// Keep the actual native outer deadline, scaling only its 5s recording
|
||||
// margin to80ms. The pre-fix equal work/outer deadline stays equal.
|
||||
const nativeBudget = late ? (budget >= workMs + 5000 ? budget - 5000 + 80 : budget) : 5000;
|
||||
test(id, body, nativeBudget);
|
||||
// The late attempt's clock stands still through its real setup, so the
|
||||
// mocked work budget reaches the capture whole; the deadline is the
|
||||
// registered outer budget.
|
||||
test(id, late ? () => { frozenAt = realNow(); return body(); } : body, late ? budget : 5000);
|
||||
},
|
||||
copyDirSync: (source, target) => fs.cpSync(source, target, { recursive: true }),
|
||||
setupBrowseShims: cwd => fs.mkdirSync(path.join(cwd, 'browse/bin'), { recursive: true }),
|
||||
setupBrowseShims: cwd => { fs.mkdirSync(path.join(cwd, 'browse/bin'), { recursive: true }); if (slowSetup) clock += 400; },
|
||||
logCost: () => {}, createEvalCollector: () => collector, finalizeEvalCollector: async () => {},
|
||||
recordE2E: (_collector, name, suite, result, extra) => collector.addTest({
|
||||
name, suite, exit_reason: result.exitReason, model: result.model,
|
||||
@@ -62,6 +65,7 @@ mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({
|
||||
mock.module(path.join(root, 'test/helpers/session-runner.ts'), () => ({
|
||||
SESSION_DRAIN_GRACE_MS: late ? drainMs : 5000,
|
||||
runSkillTest: async options => {
|
||||
frozenAt = undefined;
|
||||
const cwd = options.workingDirectory;
|
||||
const git = (...args) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 5000 });
|
||||
const initial = fs.readFileSync(path.join(cwd, 'index.html'), 'utf8');
|
||||
@@ -102,6 +106,7 @@ afterAll(async () => {
|
||||
// behind child exit; this reproduces the observed late recording race.
|
||||
if (late) await new Promise(resolve => setTimeout(resolve, 200));
|
||||
fs.writeFileSync(${JSON.stringify(factsPath)}, JSON.stringify({ attempts, records, servers, outerMs }));
|
||||
Date.now = realNow;
|
||||
});
|
||||
await import(path.join(root, 'test/skill-e2e-qa-workflow.test.ts'));
|
||||
`);
|
||||
@@ -152,11 +157,11 @@ await import(path.join(root, 'test/skill-e2e-qa-workflow.test.ts'));
|
||||
}
|
||||
if (scenario === 'late-timeout') {
|
||||
expect(outerMs).toBe(1000 + 80 + 5000);
|
||||
expect(attempts[0].captureBudgetMs).toBeGreaterThan(0);
|
||||
expect(attempts[0].captureBudgetMs).toBeLessThan(1000);
|
||||
expect(attempts[0].captureBudgetMs).toBe(1000);
|
||||
expect(records[0].exit_reason).toBe('timeout');
|
||||
expect(child.stderr).not.toContain('Unhandled error between tests');
|
||||
}
|
||||
if (scenario === 'slow-setup') expect(attempts[0].captureBudgetMs).toBe(1000 - 400);
|
||||
if (scenario === 'directory') {
|
||||
expect(attempts[0].directoryStatus).toBe(404);
|
||||
expect(attempts[0].missingStatus).toBe(404);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { CAPTURE_LONG_MS } from './helpers/eval-budgets';
|
||||
import capturedChecker from './fixtures/shared-libs-index-flags-r59-checker-public.json';
|
||||
import loopRead from './fixtures/shared-libs-index-flags-loop-read-39173752116.json';
|
||||
import erroredRead from './fixtures/shared-libs-path-ignored-errored-read-40617200857.json';
|
||||
|
||||
const helper = path.join(SHARED_LIBS_ROOT, 'bin/gstack-review-log');
|
||||
const fixtures: SharedLibsFixture[] = [];
|
||||
@@ -96,7 +97,8 @@ async function capture(change: string, prepared?: PathEligibilityFixture, declar
|
||||
directory: path.join(f.state, 'projects/fixture-shared-libs/.review-starts'),
|
||||
branch: fixtureGit(f, 'symbolic-ref', '--quiet', '--short', 'HEAD'), wtree: fixtureWorkingTree(f),
|
||||
startedAt: receipt.review_start.started_at, finding: current, reusable: change === 'unchanged',
|
||||
coveredPaths: receipt.snapshot.covered_paths };
|
||||
coveredPaths: receipt.snapshot.covered_paths,
|
||||
...(prepared ? { sources: Object.fromEntries(current!.evidence_paths.map((file: string) => [file, fs.readFileSync(path.join(f.repo, file), 'utf8')])) } : {}) };
|
||||
return { f, current, token, receipt, events, checkAt, finishAt, expected, prepared, resumed, prerequisites, ...(declared ? { questions } : {}) };
|
||||
}
|
||||
|
||||
@@ -687,3 +689,37 @@ describe('literal loop reads from the captured skip-worktree review', () => {
|
||||
expect(hasTrustedSharedLibsCheck(events, loopRead.expected)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Gate 40617200857 slice 8: the ignored-path review returned all of src/retry-worker.ts through
|
||||
// cat -n, then a later `echo ===` in the same call failed, so the result carried is_error.
|
||||
describe('errored Bash reads from the captured ignored-path review', () => {
|
||||
const blocks = (events: any[]) => events.flatMap(event => event.message.content);
|
||||
const read = (events: any[]) => blocks(events).find((block: any) => block.type === 'tool_use'
|
||||
&& block.input?.command?.startsWith('cat -n src/retry-worker.ts; echo ===;'));
|
||||
const output = (events: any[], id: string) => blocks(events).find((block: any) => block.type === 'tool_result' && block.tool_use_id === id);
|
||||
|
||||
test('a whole evidence source returned before a later failure counts as reading it', () => {
|
||||
const events = structuredClone(erroredRead.events);
|
||||
expect(output(events, read(events).id).is_error).toBe(true);
|
||||
expect(hasTrustedSharedLibsCheck(events, erroredRead.expected)).toBe(true);
|
||||
});
|
||||
|
||||
test.each(['no expected sources', 'empty expected source', 'last source line missing', 'missing file', 'errored single read', 'errored native Read', 'errored checker'])('%s is still rejected', shape => {
|
||||
const events = structuredClone(erroredRead.events), call = read(events), result = output(events, call.id);
|
||||
const expected: any = structuredClone(erroredRead.expected);
|
||||
if (shape === 'no expected sources') delete expected.sources;
|
||||
if (shape === 'empty expected source') expected.sources['src/retry-worker.ts'] = '';
|
||||
if (shape === 'last source line missing') result.content = result.content.replace(/\n +15\t\}\n/, '\n');
|
||||
if (shape === 'missing file') result.content = 'Exit code 1\ncat: src/retry-worker.ts: No such file or directory';
|
||||
if (shape === 'errored single read') call.input.command = 'cat -n src/retry-worker.ts';
|
||||
if (shape === 'errored native Read') {
|
||||
call.name = 'Read';
|
||||
call.input = { file_path: 'src/retry-worker.ts' };
|
||||
}
|
||||
if (shape === 'errored checker') {
|
||||
const checker = blocks(events).find((block: any) => block.type === 'tool_use' && block.input?.command?.includes('--check-shared-libs'));
|
||||
output(events, checker.id).is_error = true;
|
||||
}
|
||||
expect(hasTrustedSharedLibsCheck(events, expected)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,15 @@ function publishMain(fx: TrainFixture, prefixCommit: string, version: string, ex
|
||||
return repo.git('rev-parse', 'HEAD');
|
||||
}
|
||||
|
||||
// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/, which a local copy refuses on every run; no_claim=which process removed the loose object those runs named
|
||||
test('the coordinator clone reads origin through git, never by copying its objects directory (GSTA-139)', () => {
|
||||
const fx = make();
|
||||
fs.symlinkSync(path.join(fx.repo.root, 'gone'), path.join(fx.repo.origin, 'objects', 'maintenance.lock'));
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
expect(spawnSync('git', ['-C', coord, 'rev-parse', 'origin/main'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }).stdout.trim()).toBe(fx.repo.git('rev-parse', 'origin/main'));
|
||||
expect(fs.existsSync(path.join(coord, '.git', 'objects', 'maintenance.lock'))).toBe(false);
|
||||
});
|
||||
|
||||
describe('the train command surface', () => {
|
||||
test('train with no subcommand (flags only) prints the train help and exits usage; --help exits 0; bad arguments name what is missing', () => {
|
||||
const fx = make();
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* template on demand (the compact block points to it).
|
||||
* 3. Write a fake `gbrain` shell script into workdir/bin/ with robust
|
||||
* argv quoting (printf %q) so heredoc payloads in --content survive
|
||||
* shell-to-shell. The fake logs every invocation + writes payloads
|
||||
* to a per-slug file for inspection.
|
||||
* shell-to-shell. The fake logs every invocation + writes the --content
|
||||
* or stdin page (the template's form) to a per-slug file for inspection.
|
||||
* 4. Run /office-hours via runSkillTest with workdir/bin/ first on PATH.
|
||||
* Feed a deterministic founder pitch + auto-decide instructions.
|
||||
* 5. Assert the argv log contains `gbrain put office-hours/<slug>`, the
|
||||
@@ -180,16 +180,21 @@ case "$1" in
|
||||
get_page) echo "Unknown command: get_page" >&2; exit 1 ;;
|
||||
put)
|
||||
SLUG="$2"
|
||||
PAYLOAD="${payloadDir}/$SLUG.md"
|
||||
shift 2
|
||||
while [ -n "$1" ]; do
|
||||
if [ "$1" = "--content" ]; then
|
||||
PAYLOAD_DIR="${payloadDir}"
|
||||
mkdir -p "$PAYLOAD_DIR/$(dirname "$SLUG")"
|
||||
printf '%s' "$2" > "$PAYLOAD_DIR/$SLUG.md"
|
||||
break
|
||||
mkdir -p "$(dirname "$PAYLOAD")"
|
||||
printf '%s' "$2" > "$PAYLOAD"
|
||||
exit 0
|
||||
fi
|
||||
shift
|
||||
done
|
||||
# The SAVE_RESULTS template pipes the page: gbrain put "<slug>" < "<page-file>".
|
||||
if [ -p /dev/stdin ] || [ -f /dev/stdin ]; then
|
||||
mkdir -p "$(dirname "$PAYLOAD")"
|
||||
cat > "$PAYLOAD"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -241,7 +246,7 @@ Read pitch.md — that's a founder pitch coming to office hours. Select Startup
|
||||
|
||||
For the diagnostic, assume the founder confirmed Q1 (strongest evidence = "230 from a single tweet + 51 paying creators in 6 weeks"), Q2 (status quo = "creators write ad-hoc checks or use opaque Patreon-style platforms"), and Q3 (forcing question already asked).
|
||||
|
||||
Generate the design doc per Phase 5. The feature-slug value to substitute into the SAVE_RESULTS template's \`<feature-slug>\` placeholder is exactly 'pixel-fund' (no path prefix — the template already provides the prefix). The \`gbrain\` binary is on PATH at ${workDir}/bin/gbrain. Apply the SAVE_RESULTS template literally: the slug should land at \`<prefix>/pixel-fund\` per the resolver shape, with the actual design doc markdown body in the --content payload. Then enrich entity stubs for any named people or companies mentioned in the pitch.
|
||||
Generate the design doc per Phase 5. The feature-slug value to substitute into the SAVE_RESULTS template's \`<feature-slug>\` placeholder is exactly 'pixel-fund' (no path prefix — the template already provides the prefix). The \`gbrain\` binary is on PATH at ${workDir}/bin/gbrain. Apply the SAVE_RESULTS template literally: the slug should land at \`<prefix>/pixel-fund\` per the resolver shape, with the actual design doc markdown body as the page content. Then enrich entity stubs for any named people or companies mentioned in the pitch.
|
||||
|
||||
This is a test of the brain-writeback path. Do NOT skip the gbrain save step under any circumstance — the runtime guard ("skip if gbrain not on PATH") does NOT apply here because gbrain IS available. Do NOT explore gbrain --help; follow the SAVE_RESULTS template's exact CLI shape. If you encounter any AskUserQuestion, auto-decide recommended.`,
|
||||
workingDirectory: workDir,
|
||||
@@ -302,7 +307,7 @@ This is a test of the brain-writeback path. Do NOT skip the gbrain save step und
|
||||
throw new Error(
|
||||
`Agent called gbrain put but no payload file with 'pixel-fund' ` +
|
||||
`in name was written to ${payloadDir}. Check the fake gbrain ` +
|
||||
`--content parser for argv quoting issues.`,
|
||||
`--content and stdin page capture.`,
|
||||
);
|
||||
}
|
||||
const payload = readFileSync(payloadPath, 'utf-8');
|
||||
|
||||
@@ -42,12 +42,15 @@ const designFocusBoundary = (fp: AskUserQuestionFingerprint): boolean =>
|
||||
// on one line before Project/branch/task:), with the step's own rating in the
|
||||
// body and an option that offers all seven dimensions against a narrower one.
|
||||
// The option leads with all seven, whatever it calls them ("All 7 areas", run 39363705692).
|
||||
// Run 40664336141 put the offer in the rated title instead ("Should I review all 7 areas
|
||||
// or focus on a few?") and lettered its options ("A) Review all 7 areas"). Either place
|
||||
// carries the offer; issue-numbered options are a finding, never Step 0D.
|
||||
const lead = text.split(/\r?\n|\s+(?=Project\/branch\/task:)/, 1)[0]!;
|
||||
return /^I(?:['’]ve| have) rated this plan (?:10(?:\.0+)?|[0-9](?:\.\d+)?)\/10 on design completeness\.[\s\S]*\bWant me to focus on specific areas instead(?: of all (?:7|seven))?\?/i.test(text)
|
||||
|| (/\ball (?:7|seven) (?:design )?(?:dimensions|passes)\b/i.test(title) && /\bfocus\b/i.test(title)
|
||||
&& (/\bdesign\b/i.test(title) || rated(text.slice(title.length))) && /\?\s*$/.test(title))
|
||||
|| (/\bfocus\b/i.test(lead) && rated(text) && options.length >= 2
|
||||
&& options.some(({ label }) => /^\s*all (?:7|seven)\s+[a-z]/i.test(label)));
|
||||
|| (/\bfocus\b/i.test(lead) && rated(text) && options.length >= 2 && !issueLabeled(options)
|
||||
&& (/\ball (?:7|seven)\s+[a-z]/i.test(lead) || options.some(({ label }) => /^\s*all (?:7|seven)\s+[a-z]/i.test(label))));
|
||||
});
|
||||
|
||||
// Require a choice about the supplied UI, not a workflow offer after focus.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { JUDGE_MS, CAPTURE_MS, CAPTURE_LONG_MS, PTY_MS } from './helpers/eval-budgets';
|
||||
import { runSkillTest } from './helpers/session-runner';
|
||||
import { seedHermeticGstackHome } from './helpers/hermetic-env';
|
||||
import { resolveEvalModel } from '../lib/eval-model';
|
||||
import { EvalCollector } from './helpers/eval-store';
|
||||
import { OFFICE_HOURS_BUN_GRACE_MS, runRecordedOfficeHoursAttempt } from './helpers/office-hours-attempt';
|
||||
@@ -380,12 +381,19 @@ Focus on architecture, code quality, tests, and performance sections.`,
|
||||
describeIfSelected('Plan Review Report E2E', ['plan-review-report'], () => {
|
||||
test('/plan-eng-review writes GSTACK REVIEW REPORT to plan file', async () => {
|
||||
let planDir: string | undefined;
|
||||
let stateDir: string | undefined;
|
||||
try {
|
||||
await runRecordedOfficeHoursAttempt({
|
||||
collector: evalCollector, name: '/plan-review-report', suite: 'Plan Review Report E2E',
|
||||
model: resolveEvalModel('capture'), budgetMs: CAPTURE_LONG_MS,
|
||||
run: async signal => {
|
||||
planDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-review-report-'));
|
||||
// This case measures the report write within 20 turns. The default-on outside
|
||||
// voice (timer, Agent, TaskStop) took 4 of the 21 turns in both reds of gate
|
||||
// 40660626349, so the case uses the skill's documented off switch.
|
||||
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-review-report-state-'));
|
||||
seedHermeticGstackHome(stateDir);
|
||||
fs.appendFileSync(path.join(stateDir, 'config.yaml'), 'codex_reviews: disabled\n');
|
||||
const run = (cmd: string, args: string[]) => {
|
||||
const result = spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
|
||||
if (result.error || result.status !== 0) {
|
||||
@@ -445,6 +453,7 @@ plan.md is the plan file for this review session; save your review there.`,
|
||||
maxTurns: 20,
|
||||
timeout: CAPTURE_LONG_MS,
|
||||
testName: 'plan-review-report',
|
||||
env: { GSTACK_HOME: stateDir },
|
||||
runId, signal,
|
||||
model: resolveEvalModel('capture'),
|
||||
});
|
||||
@@ -496,6 +505,7 @@ plan.md is the plan file for this review session; save your review there.`,
|
||||
} finally {
|
||||
// Each configured retry owns a pristine plan and finishes cleanup first.
|
||||
if (planDir) try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
|
||||
if (stateDir) try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
}, CAPTURE_LONG_MS + OFFICE_HOURS_BUN_GRACE_MS);
|
||||
});
|
||||
|
||||
@@ -125,6 +125,7 @@ async function exerciseEligibility(testId: string, kinds: PathEligibilityCase[])
|
||||
directory: path.join(f.state, 'projects/fixture-shared-libs/.review-starts'),
|
||||
branch: fixtureGit(f, 'symbolic-ref', '--quiet', '--short', 'HEAD'), wtree: fixtureWorkingTree(f),
|
||||
startedAt: last.review_binding.started_at, finding: prepared.current, reusable: false,
|
||||
sources: Object.fromEntries(sourceBefore),
|
||||
coveredPaths: skipped.find((finding: any) => finding.fingerprint === prepared.current.fingerprint)?.snapshot_covered_paths,
|
||||
})).toBe(true);
|
||||
} else {
|
||||
|
||||
@@ -391,7 +391,6 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
|
||||
'cmd=${*: -1}\n' +
|
||||
'printf \'%s\\n\' "$cmd" >> "$root/ssh.log"\n' +
|
||||
'case "$cmd" in\n' +
|
||||
' *-O*) exit 0 ;;\n' +
|
||||
' *pkill*) echo "$cmd" >> "$root/cancel.log" ;;\n' +
|
||||
' *captured*) cat > "$root/captured" ;;\n' +
|
||||
' *"cat > ~/.ubirun-env"*) cat > "$root/ubirun-env" ;;\n' +
|
||||
@@ -458,6 +457,18 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
|
||||
test('run: the fake ssh dispatches on the remote command, so a checkout whose name holds -O still syncs and runs (GSTA-139)', () => {
|
||||
const f = fakeVm();
|
||||
try {
|
||||
const checkout = join(f.root, 'ubi-run-O1x2y3');
|
||||
mkdirSync(checkout);
|
||||
const r = run(f, ['run', '--src', checkout, '--', 'make test']);
|
||||
expect(r.exitCode, r.stderr.toString()).toBe(0);
|
||||
expect(r.stdout.toString()).toContain('ran: ');
|
||||
expect(f.ssh()).toContain('tar -xzf - -C work/ubi-run-O1x2y3');
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 60_000);
|
||||
|
||||
test('run: --pass forwards a named local variable only; UBICLOUD_* and non-names are refused before any API call', () => {
|
||||
const f = fakeVm();
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* that moved out of it.
|
||||
*/
|
||||
import { expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { collectFreeTestFiles, createFreeCiPlan, curateWindowsSafe, parseCliOptions, validateFreeCiPlan } from '../scripts/test-free-shards';
|
||||
import { windowsCurationLine } from '../scripts/lib/free-ci-health';
|
||||
@@ -113,11 +113,10 @@ test('native qualification is dispatch-only with one exclusive mode per run', ()
|
||||
expect(Object.keys(native.on)).toEqual(['workflow_dispatch']);
|
||||
const mode = native.on.workflow_dispatch.inputs.mode;
|
||||
expect(mode).toMatchObject({ type: 'choice', required: true });
|
||||
expect(mode.options).toEqual(['cookie-native', 'windows-native-diagnostics', 'dia-native', 'dia-launch-comparison', 'dia-gui-readiness']);
|
||||
expect(mode.options).toEqual(['cookie-native', 'windows-native-diagnostics']);
|
||||
expect(native.permissions).toEqual({ contents: 'read' });
|
||||
expect(native.jobs['cookie-native-qualification'].if).toBe("inputs.mode == 'cookie-native'");
|
||||
expect(native.jobs['windows-native-diagnostics'].if).toBe("inputs.mode == 'windows-native-diagnostics'");
|
||||
expect(native.jobs['dia-native-qualification'].if).toBe("startsWith(inputs.mode, 'dia-')");
|
||||
for (const job of ['cookie-native-qualification', 'windows-native-diagnostics']) {
|
||||
const setup = native.jobs[job].steps.find((step: any) => step.uses?.startsWith('actions/setup-node@'));
|
||||
expect(setup.with['node-version']).toBe('24.18.0');
|
||||
@@ -125,6 +124,12 @@ test('native qualification is dispatch-only with one exclusive mode per run', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('the removed Dia qualification harness has no mode and no scripts', () => {
|
||||
expect(Object.keys(native.jobs)).toEqual(['cookie-native-qualification', 'windows-native-diagnostics']);
|
||||
for (const file of [path.join(import.meta.dir, '..', '.github/scripts', 'dia-gui-readiness.c'), path.join(import.meta.dir, '..', '.github/scripts', 'dia-launch-driver.mjs'),
|
||||
path.join(import.meta.dir, '..', '.github/scripts', 'qualify-dia-macos.ts'), path.join(import.meta.dir, '..', '.github/scripts', 'run-dia-native-qualification.ts')]) expect(existsSync(file)).toBe(false);
|
||||
});
|
||||
|
||||
test('focused Windows diagnostics include the repaired lock and close cases without default-profile qualification', () => {
|
||||
const run = native.jobs['windows-native-diagnostics'].steps.find((step: any) => step.name === 'Run focused native launch and credential diagnostics').run;
|
||||
const pattern = run.match(/--test-name-pattern '([^']+)'/)?.[1];
|
||||
@@ -134,35 +139,3 @@ test('focused Windows diagnostics include the repaired lock and close cases with
|
||||
'native Windows process qualification > real Edge synthetic profile: normal-close', 'native Windows process qualification > real Edge synthetic profile: stalled-close']) expect(selected.test(name)).toBe(true);
|
||||
expect(selected.test('native Windows process qualification > an exclusively created default Edge profile persists v20')).toBe(false);
|
||||
});
|
||||
|
||||
test('Dia comparison uses separate pinned runtime jobs and retains diagnostic failures', () => {
|
||||
const job = native.jobs['dia-native-qualification'];
|
||||
expect(job['runs-on']).toBe('macos-15');
|
||||
expect(job.strategy['fail-fast']).toBe(false);
|
||||
expect(job.strategy.matrix.runtime).toBe(`\${{ fromJSON(inputs.mode == 'dia-launch-comparison' && '["bun","node"]' || '["bun"]') }}`);
|
||||
const node = job.steps.find((step: any) => step.uses?.startsWith('actions/setup-node@'));
|
||||
expect(node.if).toBe("inputs.mode == 'dia-launch-comparison'");
|
||||
expect(node.with).toEqual({ 'node-version': '24.18.0', architecture: 'arm64' });
|
||||
const comparison = job.steps.find((step: any) => step.name === 'Compare protected native Dia launch without qualification credit');
|
||||
expect(comparison.if).toBe("inputs.mode == 'dia-launch-comparison'");
|
||||
expect(comparison.run).toContain('--launch-comparison "$COMPARISON_RUNTIME"');
|
||||
expect(comparison['continue-on-error']).toBeUndefined();
|
||||
const upload = job.steps.find((step: any) => step.uses?.startsWith('actions/upload-artifact@'));
|
||||
expect(upload.if).toBe('always()');
|
||||
expect(upload.with.name).toContain("format('dia-launch-comparison-{0}', matrix.runtime)");
|
||||
});
|
||||
|
||||
test('Dia GUI readiness runs alone, without dependency or browser installation', () => {
|
||||
const job = native.jobs['dia-native-qualification'];
|
||||
const probe = job.steps.find((step: any) => step.name === 'Inspect GUI readiness without browser or Keychain access');
|
||||
expect(probe.if).toBe("inputs.mode == 'dia-gui-readiness'");
|
||||
expect(probe.run).toEndWith('.github/scripts/run-dia-native-qualification.ts --gui-readiness-only');
|
||||
expect(probe.env).toEqual({ GSTACK_DIA_NATIVE_QUALIFY: '1' });
|
||||
for (const name of ['Install pinned dependencies', 'Install the synthetic destination browser']) {
|
||||
expect(job.steps.find((step: any) => step.name === name).if).toBe("inputs.mode != 'dia-gui-readiness'");
|
||||
}
|
||||
expect(job.steps.find((step: any) => step.name === 'Qualify native Dia discovery, decryption, and import').if).toBe("inputs.mode == 'dia-native'");
|
||||
const upload = job.steps.find((step: any) => step.uses?.startsWith('actions/upload-artifact@'));
|
||||
expect(upload.with.name).toContain("inputs.mode == 'dia-gui-readiness' && 'dia-gui-readiness'");
|
||||
expect(upload.with.path).toBe('${{ runner.temp }}/dia-native-qualification.json');
|
||||
});
|
||||
Reference in new issue
Block a user