mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-27 23:21:53 +02:00
v1.90.0.0 feat: make browser cookie imports explicit and safe (#2964)
* fix(browse): prepare reliable cookie import wave for validation * ci: sequence quality and behavior for validation branch * fix(browse): isolate Windows qualification and preserve native diagnostics * test(browse): cover cookie workflow quality and isolate Windows user paths * test(browse): trace native member startup and initialize fresh folders * fix(browse): keep Windows member stdin alive through EOF * fix(browse): latch native timeouts and compare contained Edge startup * test(browse): verify native version metadata and actual Windows argv * test(browse): qualify Dia import on isolated macOS CI * fix(browse): require picker origin for session mutations * fix(browse): bound credential reads through stream completion * test(browse): inspect owned Windows process arguments natively * test(evals): preserve passing coverage during cookie repair reruns * test(browse): isolate Dia qualification in a fresh macOS account * test(browse): pass bounded integer timeouts to native Mac probes * test(browse): distinguish Windows profile initialization from containment * test(browse): await descendant pipe readiness before parent exit * test(browse): initialize and restore isolated macOS Keychain state * test(browse): initialize Windows fixture folders before qualification * test(ci): pin the same Node runtime across Windows checks * test(browse): distinguish native macOS browser preflight stages * test(browse): isolate Windows descendant console lifetime * test(browse): preserve native receipts and identify fixture lock holders * test(browse): prepare dependency resolution before native Mac worker startup * test(ci): include lock and close checks in native diagnostics * test(browse): preserve native owner probe stages and subprocess deadlines * fix(browse): classify Chromium profile-in-use exit precisely * test(browse): retain Mac qualification evidence through cleanup failures * test(browse): bound Mac fixture paths and retire its owned user domain * test(browse): accept vanished fixture entries without weakening cleanup * test(browse): identify probe-created macOS user domains safely * test(browse): observe Mac user domains without targeting them first * test(browse): use passive fresh-user ownership throughout Mac qualification * test(browse): distinguish profile and registered-home Keychain lookups * test(browse): qualify Dia under one registered account home * test(browse): identify Dia startup and owned process-group failures * test(browse): classify bounded Dia startup diagnostics without leaking output * fix(test): preserve native Mac sandboxing and reap owned browser children * fix(browse): preserve Chromium sandboxing for native profile imports * test(browse): inspect signed Mach-O architecture without launching Xcode tools * test(browse): sample pending Dia startup and reap on all cleanup paths * test(browse): compare protected Dia launches in fresh Bun and Node accounts * test(browse): inspect isolated Mac GUI readiness without browser access * v1.90.0.0 fix: bind cookie picker actions to their document * test: validate cookie guards and fit nested launch fixtures * ci: configure the bundled Chromium sandbox helper * fix(browse): classify Playwright authentication timeouts * test: retain bounded Windows lifecycle diagnostics * test(cso): reuse bounded NTFS precision candidates * test(review): handle explicit preservation choices safely * test(browse): remove owned fixture directories with explicit primitives * test(review): distinguish descriptive reuse from edit commitments * test: admit only the approved unscored cookie workflow refusal * test: keep the Office Hours judge mock export-complete * fix: keep dependency-free CI planners independent of the model SDK * test: observe the exact holder after a native fixture unlink failure * fix: start seeded PTY observations at owned readiness * test: acquire identity-bound Windows deletion admission before profile resets * test: preserve qualified Git index bits without authorizing mutations
This commit is contained in:
@@ -432,6 +432,14 @@ goes through the Third-Party Web Actions contract, not through here. Rendering l
|
||||
HTML into a PNG or PDF is the render engine's job: use /make-pdf, /diagram, or
|
||||
/design-html for that.
|
||||
|
||||
On the fallback path, `/setup-browser-cookies` asks the user to choose the source
|
||||
browser and account/profile; Dia is macOS-only. Navigate to the matching target
|
||||
before a direct `--domain` import. Cookies copied is **not checked**, not proof
|
||||
of login. `--verify-auth` requires a daemon-configured exact visible identity
|
||||
assertion; HTTP 200 and cookie counts are not enough. Storage stays intact unless
|
||||
the user explicitly approves `--clear-storage` for the captured origin on a Chromium target. Other engines reject reset, not ordinary import or auth checks. Do not
|
||||
publish cookie values, passwords, profile/account text, or session details.
|
||||
|
||||
## Fallback command reference
|
||||
|
||||
The table in the Browser fallback section covers what the cookbook covers. Everything
|
||||
|
||||
@@ -111,6 +111,14 @@ goes through the Third-Party Web Actions contract, not through here. Rendering l
|
||||
HTML into a PNG or PDF is the render engine's job: use /make-pdf, /diagram, or
|
||||
/design-html for that.
|
||||
|
||||
On the fallback path, `/setup-browser-cookies` asks the user to choose the source
|
||||
browser and account/profile; Dia is macOS-only. Navigate to the matching target
|
||||
before a direct `--domain` import. Cookies copied is **not checked**, not proof
|
||||
of login. `--verify-auth` requires a daemon-configured exact visible identity
|
||||
assertion; HTTP 200 and cookie counts are not enough. Storage stays intact unless
|
||||
the user explicitly approves `--clear-storage` for the captured origin on a Chromium target. Other engines reject reset, not ordinary import or auth checks. Do not
|
||||
publish cookie values, passwords, profile/account text, or session details.
|
||||
|
||||
## Fallback command reference
|
||||
|
||||
The table in the Browser fallback section covers what the cookbook covers. Everything
|
||||
|
||||
@@ -37,8 +37,6 @@ bun build "$SRC_DIR/server.ts" \
|
||||
# Step 2: Post-process
|
||||
# Replace import.meta.dir with a resolvable reference
|
||||
perl -pi -e 's/import\.meta\.dir/__browseNodeSrcDir/g' "$DIST_DIR/server-node.mjs"
|
||||
# Stub out bun:sqlite (macOS-only cookie import, not needed on Windows)
|
||||
perl -pi -e 's|import \{ Database \} from "bun:sqlite";|const Database = null; // bun:sqlite stubbed on Node|g' "$DIST_DIR/server-node.mjs"
|
||||
|
||||
# Step 3: Create the final file with polyfill header injected after the first line
|
||||
{
|
||||
|
||||
@@ -93,7 +93,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
| `click <sel>` | Click element |
|
||||
| `cookie <name>=<value>` | Set cookie on current page domain |
|
||||
| `cookie-import <json>` | Import cookies from JSON file |
|
||||
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all]` | Import cookies from installed Chromium-family browsers. Browser names are the installed browser IDs shown by detection; common values include comet, chrome, chromium, edge, brave, arc. With --domain, imports only that domain after current-page domain validation; without --domain, opens the picker UI. --profile defaults to Default; --all imports every non-expired cookie only when explicitly passed. |
|
||||
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. |
|
||||
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
|
||||
| `dialog-dismiss` | Auto-dismiss next dialog |
|
||||
| `fill <sel> <val>` | Fill input |
|
||||
|
||||
+39
-16
@@ -159,10 +159,17 @@ globalThis.Bun = {
|
||||
parseInt(process.env.GSTACK_SPAWN_MAX_BUFFER || '', 10) || 16 * 1024 * 1024,
|
||||
);
|
||||
const drain = (stream) => {
|
||||
if (!stream) return { done: Promise.resolve(), chunks: [], truncated: false };
|
||||
const state = { chunks: [], bytes: 0, truncated: false };
|
||||
if (!stream) return { done: Promise.resolve(), chunks: [], cancel() {} };
|
||||
const state = { chunks: [], bytes: 0, truncated: false, cancelled: false, finished: false };
|
||||
let finish;
|
||||
const done = new Promise((resolve) => {
|
||||
finish = () => {
|
||||
if (state.finished) return;
|
||||
state.finished = true;
|
||||
resolve();
|
||||
};
|
||||
stream.on('data', (chunk) => {
|
||||
if (state.cancelled) return;
|
||||
if (state.bytes >= MAX_BUFFER) { state.truncated = true; return; }
|
||||
if (state.bytes + chunk.length <= MAX_BUFFER) {
|
||||
state.chunks.push(chunk);
|
||||
@@ -177,11 +184,18 @@ globalThis.Bun = {
|
||||
// Any terminal event resolves: 'end' on normal close, 'error' on a
|
||||
// stream-level error, 'close' as the belt-and-suspenders for spawn
|
||||
// failures where Node fires 'close' but neither 'end' nor 'error'.
|
||||
stream.once('end', resolve);
|
||||
stream.once('error', resolve);
|
||||
stream.once('close', resolve);
|
||||
stream.once('end', finish);
|
||||
stream.once('error', finish);
|
||||
stream.once('close', finish);
|
||||
});
|
||||
return { done, chunks: state.chunks };
|
||||
return { done, chunks: state.chunks, cancel() {
|
||||
if (state.cancelled) return;
|
||||
state.cancelled = true;
|
||||
state.chunks.length = 0;
|
||||
state.bytes = 0;
|
||||
finish();
|
||||
stream.destroy();
|
||||
} };
|
||||
};
|
||||
const stdoutDrain = drain(proc.stdout);
|
||||
const stderrDrain = drain(proc.stderr);
|
||||
@@ -218,20 +232,29 @@ globalThis.Bun = {
|
||||
.then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0));
|
||||
});
|
||||
|
||||
// Replay buffered output as a fresh Web ReadableStream. `start()` awaits
|
||||
// Replay buffered output as a fresh Web ReadableStream. `start()` observes
|
||||
// the drain before enqueueing so `new Response(proc.stdout).text()` yields
|
||||
// the complete output regardless of whether the consumer reads before or
|
||||
// after awaiting `proc.exited`. Stream is single-shot (locked after one
|
||||
// read), matching Bun's behavior.
|
||||
const replay = (d) => new ReadableStream({
|
||||
async start(controller) {
|
||||
await d.done;
|
||||
for (const chunk of d.chunks) {
|
||||
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const replay = (d) => {
|
||||
let cancelled = false;
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
d.done.then(() => {
|
||||
if (cancelled) return;
|
||||
for (const chunk of d.chunks) {
|
||||
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
||||
}
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
d.cancel();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
pid: proc.pid,
|
||||
|
||||
+9
-3
@@ -823,7 +823,7 @@ export function extractTabId(args: string[]): { tabId: number | undefined; args:
|
||||
}
|
||||
|
||||
// ─── Command Dispatch ──────────────────────────────────────────
|
||||
async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise<void> {
|
||||
export async function sendCommand(state: ServerState, command: string, args: string[], retries = 0): Promise<void> {
|
||||
// Precedence: CLI --tab-id flag > BROWSE_TAB env var.
|
||||
// make-pdf always passes --tab-id; human users typically rely on BROWSE_TAB
|
||||
// or the active tab.
|
||||
@@ -832,6 +832,7 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||
const envTab = process.env.BROWSE_TAB;
|
||||
const tabId = extracted.tabId ?? (envTab ? parseInt(envTab, 10) : undefined);
|
||||
const body = JSON.stringify({ command, args, ...(tabId !== undefined && !isNaN(tabId) ? { tabId } : {}) });
|
||||
const timeoutMs = command === 'cookie-import-browser' ? 90_000 : 30_000;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
|
||||
@@ -841,10 +842,11 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||
'Authorization': `Bearer ${state.token}`,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
if (command === 'cookie-import-browser') throw new Error('Cookie import authorization changed. Reopen the session and retry manually.');
|
||||
// Token mismatch — server may have restarted
|
||||
console.error('[browse] Auth failed — server may have restarted. Retrying...');
|
||||
const newState = readState();
|
||||
@@ -871,6 +873,10 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (command === 'cookie-import-browser' && (['AbortError', 'TimeoutError'].includes(err.name)
|
||||
|| ['ECONNREFUSED', 'ECONNRESET'].includes(err.code) || err.message?.includes('fetch failed'))) {
|
||||
throw new Error('Cookie import response was lost or timed out. It may have partially completed; inspect the destination before retrying manually. The command was not replayed.');
|
||||
}
|
||||
if (err.name === 'AbortError') {
|
||||
// #1781: a 30s timeout on a heavy page usually means busy, not dead.
|
||||
// Don't kill a live server (that's what triggered the crash-loop) — report
|
||||
@@ -1535,7 +1541,7 @@ Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
|
||||
scroll [sel] | wait <sel|--networkidle|--load> | viewport <WxH>
|
||||
upload <sel> <file1> [file2...]
|
||||
cookie-import <json-file>
|
||||
cookie-import-browser [browser] [--domain <d>]
|
||||
cookie-import-browser [browser] [--domain <d> | --all] [--profile <p>] [--clear-storage] [--verify-auth]
|
||||
Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
||||
console [--clear|--errors] | network [--clear] | dialog [--clear]
|
||||
cookies | storage [set <k> <v>] | perf
|
||||
|
||||
@@ -130,7 +130,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [<WxH>] [--scale <n>]' },
|
||||
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
|
||||
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
|
||||
'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from installed Chromium-family browsers. Browser names are the installed browser IDs shown by detection; common values include comet, chrome, chromium, edge, brave, arc. With --domain, imports only that domain after current-page domain validation; without --domain, opens the picker UI. --profile defaults to Default; --all imports every non-expired cookie only when explicitly passed.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all]' },
|
||||
'cookie-import-browser': { category: 'Interaction', description: 'Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]' },
|
||||
'header': { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header <name>:<value>' },
|
||||
'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
|
||||
'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' },
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { errors, type Page } from 'playwright';
|
||||
import { CookieImportError } from './cookie-import-browser';
|
||||
import { withCdpSession } from './cdp-bridge';
|
||||
|
||||
export interface CookieAuthVerificationOptions {
|
||||
identitySelector?: string;
|
||||
expectedIdentity?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
type VerificationReason = 'verified' | 'not_configured' | 'invalid_configuration'
|
||||
| 'invalid_target' | 'target_closed' | 'target_changed' | 'login_redirect'
|
||||
| 'http_error' | 'no_response' | 'identity_missing' | 'identity_ambiguous'
|
||||
| 'identity_mismatch' | 'timeout' | 'verification_failed';
|
||||
|
||||
type VerificationResult = { verified: boolean; reason: VerificationReason; status?: number };
|
||||
|
||||
const MAX_TIMEOUT_MS = 15_000;
|
||||
const LOGIN_PATH = /(?:^|\/)(?:log[-_]?in|sign[-_]?in|logon)(?:[\/.;]|$)|(?:^|\/)sessions?\/new(?:[\/.;]|$)/i;
|
||||
|
||||
function validateOrigin(expectedOrigin: string): void {
|
||||
try {
|
||||
const target = new URL(expectedOrigin);
|
||||
if ((target.protocol === 'http:' || target.protocol === 'https:') && target.origin === expectedOrigin) return;
|
||||
} catch {}
|
||||
throw new CookieImportError('A captured HTTP(S) target origin is required.', 'invalid_target');
|
||||
}
|
||||
|
||||
export function validateCookieAuthOptions(options: CookieAuthVerificationOptions): void {
|
||||
if (!options || typeof options.identitySelector !== 'string' || !options.identitySelector.trim()
|
||||
|| typeof options.expectedIdentity !== 'string' || !options.expectedIdentity.replace(/\s+/g, ' ').trim()) {
|
||||
throw new CookieImportError('Authentication verification requires an identity selector and expected identity.', 'verification_not_configured');
|
||||
}
|
||||
if (options.timeoutMs !== undefined && (typeof options.timeoutMs !== 'number'
|
||||
|| !Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
|
||||
throw new CookieImportError('Authentication verification requires a positive finite timeout.', 'invalid_verification_config');
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyCookieAuthentication(
|
||||
page: Page,
|
||||
options: CookieAuthVerificationOptions,
|
||||
expectedOrigin: string,
|
||||
): Promise<VerificationResult> {
|
||||
try {
|
||||
validateCookieAuthOptions(options);
|
||||
validateOrigin(expectedOrigin);
|
||||
} catch (error) {
|
||||
const reason = error instanceof CookieImportError && error.code === 'verification_not_configured'
|
||||
? 'not_configured' : error instanceof CookieImportError && error.code === 'invalid_target'
|
||||
? 'invalid_target' : 'invalid_configuration';
|
||||
return { verified: false, reason };
|
||||
}
|
||||
|
||||
const timeoutMs = Math.min(options.timeoutMs ?? MAX_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
const expectedIdentity = options.expectedIdentity!.replace(/\s+/g, ' ').trim();
|
||||
let finished = false;
|
||||
let status: number | undefined;
|
||||
let lastFailure: VerificationReason = 'timeout';
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<VerificationResult>(resolve => {
|
||||
timer = setTimeout(() => {
|
||||
finished = true;
|
||||
resolve({ verified: false, reason: lastFailure, ...(status === undefined ? {} : { status }) });
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([timeout, (async (): Promise<VerificationResult> => {
|
||||
if (page.isClosed()) return { verified: false, reason: 'target_closed' };
|
||||
if (new URL(page.url()).origin !== expectedOrigin) return { verified: false, reason: 'target_changed' };
|
||||
const response = await page.reload({ waitUntil: 'domcontentloaded', timeout: Math.max(1, deadline - performance.now()) });
|
||||
if (finished || performance.now() >= deadline) return { verified: false, reason: 'timeout' };
|
||||
if (page.isClosed()) return { verified: false, reason: 'target_closed' };
|
||||
const loadedUrl = page.url();
|
||||
if (new URL(loadedUrl).origin !== expectedOrigin) return { verified: false, reason: 'target_changed' };
|
||||
if (LOGIN_PATH.test(decodeURIComponent(new URL(loadedUrl).pathname))) return { verified: false, reason: 'login_redirect' };
|
||||
if (!response) return { verified: false, reason: 'no_response' };
|
||||
status = response.status();
|
||||
if (status < 200 || status >= 300) return { verified: false, reason: 'http_error', status };
|
||||
for (let request = response.request(); request; request = request.redirectedFrom()!) {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== expectedOrigin) return { verified: false, reason: 'target_changed', status };
|
||||
if (LOGIN_PATH.test(decodeURIComponent(url.pathname))) return { verified: false, reason: 'login_redirect', status };
|
||||
}
|
||||
if (new URL(response.url()).origin !== expectedOrigin
|
||||
|| new URL(response.url()).href !== new URL(loadedUrl.split('#')[0]).href) {
|
||||
return { verified: false, reason: 'target_changed', status };
|
||||
}
|
||||
|
||||
while (!finished && performance.now() < deadline) {
|
||||
if (page.isClosed()) return { verified: false, reason: 'target_closed', status };
|
||||
if (page.url() !== loadedUrl) return { verified: false, reason: 'target_changed', status };
|
||||
const reason = await page.locator(options.identitySelector!).filter({ visible: true }).evaluateAll((elements, expected) => {
|
||||
if (location.origin !== expected.origin || location.href !== expected.url) return 'target_changed';
|
||||
if (elements.length === 0) return 'identity_missing';
|
||||
if (elements.length !== 1) return 'identity_ambiguous';
|
||||
const element = elements[0];
|
||||
const text = element instanceof HTMLElement ? element.innerText : element.textContent ?? '';
|
||||
return text.replace(/\s+/g, ' ').trim() === expected.identity ? 'verified' : 'identity_mismatch';
|
||||
}, { origin: expectedOrigin, url: loadedUrl, identity: expectedIdentity });
|
||||
if (finished || performance.now() >= deadline) return { verified: false, reason: lastFailure, status };
|
||||
if (page.isClosed()) return { verified: false, reason: 'target_closed', status };
|
||||
if (page.url() !== loadedUrl) return { verified: false, reason: 'target_changed', status };
|
||||
if (reason === 'target_changed' || reason === 'verified') return { verified: reason === 'verified', reason, status };
|
||||
lastFailure = reason;
|
||||
const remaining = deadline - performance.now();
|
||||
if (remaining <= 0) return { verified: false, reason, status };
|
||||
await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining)));
|
||||
}
|
||||
return { verified: false, reason: lastFailure, ...(status === undefined ? {} : { status }) };
|
||||
})()]);
|
||||
} catch (error) {
|
||||
const reason = error instanceof errors.TimeoutError || finished || performance.now() >= deadline ? 'timeout'
|
||||
: page.isClosed() ? 'target_closed' : 'verification_failed';
|
||||
return { verified: false, reason, ...(status === undefined ? {} : { status }) };
|
||||
} finally {
|
||||
finished = true;
|
||||
clearTimeout(timer!);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCookieStorageSupport(page: Page): void {
|
||||
try {
|
||||
if (page.context().browser()?.browserType().name() === 'chromium') return;
|
||||
} catch {}
|
||||
throw new CookieImportError('Storage reset requires a Chromium target. Import cookies without storage reset on other browsers.', 'storage_reset_unsupported');
|
||||
}
|
||||
|
||||
export async function clearCookieTargetStorage(page: Page, expectedOrigin: string): Promise<void> {
|
||||
validateOrigin(expectedOrigin);
|
||||
validateCookieStorageSupport(page);
|
||||
if (page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed');
|
||||
let targetUrl: string;
|
||||
try {
|
||||
targetUrl = page.url();
|
||||
if (new URL(targetUrl).origin !== expectedOrigin) {
|
||||
throw new CookieImportError('The captured target has changed.', 'target_changed');
|
||||
}
|
||||
} catch {
|
||||
throw new CookieImportError('The captured target has changed.', 'target_changed');
|
||||
}
|
||||
|
||||
const deadline = performance.now() + MAX_TIMEOUT_MS;
|
||||
let expired = false;
|
||||
let navigated = false;
|
||||
const frame = page.mainFrame();
|
||||
const navigation = Promise.withResolvers<string>();
|
||||
const onNavigation = (changed: typeof frame) => {
|
||||
if (changed === frame) { navigated = true; navigation.resolve('target_changed'); }
|
||||
};
|
||||
const onClose = () => { navigated = true; navigation.resolve('target_changed'); };
|
||||
page.on('framenavigated', onNavigation);
|
||||
page.on('close', onClose);
|
||||
const cancelled = () => expired || performance.now() >= deadline ? 'storage_reset_timeout'
|
||||
: navigated || page.isClosed() || page.url() !== targetUrl ? 'target_changed' : undefined;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<string>(resolve => {
|
||||
timer = setTimeout(() => {
|
||||
expired = true;
|
||||
resolve('storage_reset_timeout');
|
||||
}, Math.max(0, deadline - performance.now()));
|
||||
});
|
||||
const cancellation = Promise.race([timeout, navigation.promise]);
|
||||
let result: string;
|
||||
try {
|
||||
result = await Promise.race([cancellation, withCdpSession(page, async session => {
|
||||
let isolated: { id: number; uniqueId: string } | undefined;
|
||||
let frameId: string | undefined;
|
||||
const worldName = 'gstack-cookie-storage-reset';
|
||||
const onContext = ({ context }: any) => {
|
||||
if (context.name === worldName && context.auxData?.frameId === frameId && context.auxData?.isDefault === false) isolated = context;
|
||||
};
|
||||
session.on('Runtime.executionContextCreated', onContext);
|
||||
try {
|
||||
return await Promise.race([cancellation, (async () => {
|
||||
if (cancelled()) return cancelled()!;
|
||||
const tree = await session.send('Page.getFrameTree');
|
||||
if (cancelled()) return cancelled()!;
|
||||
frameId = tree.frameTree.frame.id;
|
||||
await session.send('Runtime.enable');
|
||||
if (cancelled()) return cancelled()!;
|
||||
const world = await session.send('Page.createIsolatedWorld', { frameId, worldName, grantUniveralAccess: false });
|
||||
if (cancelled()) return cancelled()!;
|
||||
if (!isolated?.uniqueId || isolated.id !== world.executionContextId) return 'storage_reset_failed';
|
||||
const uniqueContextId = isolated.uniqueId;
|
||||
const clock = await session.send('Runtime.evaluate', { expression: 'performance.now()', uniqueContextId, returnByValue: true, silent: true });
|
||||
const remaining = deadline - performance.now();
|
||||
if (cancelled() || remaining <= 0) return cancelled() ?? 'storage_reset_timeout';
|
||||
if (clock.exceptionDetails || !Number.isFinite(clock.result?.value)) return 'storage_reset_failed';
|
||||
const cleared = await session.send('Runtime.callFunctionOn', {
|
||||
uniqueContextId,
|
||||
functionDeclaration: String(({ origin, url, deadline }: { origin: string; url: string; deadline: number }) => {
|
||||
if (performance.now() >= deadline) return 'storage_reset_timeout';
|
||||
if (location.origin !== origin || location.href !== url) return 'target_changed';
|
||||
localStorage.clear();
|
||||
if (performance.now() >= deadline) return 'storage_reset_timeout';
|
||||
sessionStorage.clear();
|
||||
return 'cleared';
|
||||
}),
|
||||
arguments: [{ value: { origin: expectedOrigin, url: targetUrl, deadline: clock.result.value + remaining } }],
|
||||
returnByValue: true,
|
||||
silent: true,
|
||||
});
|
||||
if (cancelled()) return cancelled()!;
|
||||
if (cleared.exceptionDetails || !['cleared', 'target_changed', 'storage_reset_timeout'].includes(cleared.result?.value)) return 'storage_reset_failed';
|
||||
return cleared.result.value;
|
||||
})()]);
|
||||
} finally {
|
||||
session.off('Runtime.executionContextCreated', onContext);
|
||||
}
|
||||
})]);
|
||||
} catch {
|
||||
result = cancelled() ?? 'storage_reset_failed';
|
||||
} finally {
|
||||
expired = true;
|
||||
clearTimeout(timer!);
|
||||
page.off('framenavigated', onNavigation);
|
||||
page.off('close', onClose);
|
||||
}
|
||||
if (result === 'storage_reset_timeout') throw new CookieImportError('Target storage reset timed out; storage may be partially cleared.', 'storage_reset_timeout');
|
||||
if (result === 'target_changed') throw new CookieImportError('The captured target has changed.', 'target_changed');
|
||||
if (result !== 'cleared') throw new CookieImportError('Target storage reset failed; storage may be partially cleared.', 'storage_reset_failed');
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function databaseError(error: unknown): Error & { code: string } {
|
||||
const detail = error as { errcode?: number; errno?: number; code?: string } | null;
|
||||
const codes: Record<number, string> = {
|
||||
5: 'SQLITE_BUSY', 6: 'SQLITE_LOCKED', 8: 'SQLITE_READONLY', 10: 'SQLITE_IOERR',
|
||||
11: 'SQLITE_CORRUPT', 14: 'SQLITE_CANTOPEN', 26: 'SQLITE_CORRUPT',
|
||||
};
|
||||
const number = detail?.errcode ?? detail?.errno;
|
||||
const code = typeof number === 'number' ? codes[number & 255] ?? 'SQLITE_ERROR'
|
||||
: Object.values(codes).includes(detail?.code ?? '') ? detail!.code! : 'SQLITE_ERROR';
|
||||
const message = code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED'
|
||||
? 'Cookie database is locked. Close the source browser and retry.'
|
||||
: 'Cookie database operation failed (' + code + ').';
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
export function openCookieDatabase(dbPath: string): {
|
||||
query(sql: string): { all(...bindings: any[]): unknown[] };
|
||||
close(): void;
|
||||
} {
|
||||
const isBun = typeof process.versions.bun === 'string';
|
||||
let Database;
|
||||
try {
|
||||
const sqlite = require(isBun ? 'bun:sqlite' : 'node:sqlite');
|
||||
Database = isBun ? sqlite.Database : sqlite.DatabaseSync;
|
||||
if (typeof Database !== 'function') throw new Error();
|
||||
} catch {
|
||||
throw Object.assign(new Error(isBun
|
||||
? 'Cookie import requires a Bun runtime with SQLite support. Upgrade Bun and retry.'
|
||||
: 'Cookie import requires Node.js 22.13 or newer with built-in SQLite enabled. Upgrade Node.js or enable SQLite and retry.'), { code: 'sqlite_unavailable' });
|
||||
}
|
||||
|
||||
let database;
|
||||
try {
|
||||
database = new Database(dbPath, isBun ? { readonly: true, safeIntegers: true } : { readOnly: true });
|
||||
} catch (error) {
|
||||
throw databaseError(error);
|
||||
}
|
||||
|
||||
return {
|
||||
query(sql) {
|
||||
let statement;
|
||||
try {
|
||||
statement = isBun ? database.query(sql) : database.prepare(sql);
|
||||
if (!isBun) statement.setReadBigInts(true);
|
||||
} catch (error) {
|
||||
throw databaseError(error);
|
||||
}
|
||||
return {
|
||||
all(...bindings) {
|
||||
try {
|
||||
return statement.all(...bindings).map((row: Record<string, unknown>) => Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [key,
|
||||
typeof value === 'bigint' && Number.isSafeInteger(Number(value)) ? Number(value) : value]),
|
||||
));
|
||||
} catch (error) {
|
||||
throw databaseError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
close() {
|
||||
try {
|
||||
database.close();
|
||||
} catch (error) {
|
||||
throw databaseError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+164
-346
@@ -35,12 +35,12 @@
|
||||
* └──────────────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { openCookieDatabase } from './cookie-database';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { TEMP_DIR } from './platform';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface ImportResult {
|
||||
count: number;
|
||||
failed: number;
|
||||
domainCounts: Record<string, number>;
|
||||
failureReasons?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface PlaywrightCookie {
|
||||
@@ -109,6 +110,7 @@ const BROWSER_REGISTRY: BrowserInfo[] = [
|
||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/' },
|
||||
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/' },
|
||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||
{ name: 'Dia', dataDir: 'Dia/User Data/', keychainService: 'Dia Safe Storage', aliases: ['dia'] },
|
||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/' },
|
||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/' },
|
||||
];
|
||||
@@ -168,6 +170,11 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
const browserDir = path.join(getBaseDir(platform), dataDir);
|
||||
if (!fs.existsSync(browserDir)) continue;
|
||||
|
||||
let profileNames: Record<string, { name?: unknown }> = {};
|
||||
try {
|
||||
profileNames = JSON.parse(fs.readFileSync(path.join(browserDir, 'Local State'), 'utf-8'))?.profile?.info_cache ?? {};
|
||||
} catch {}
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
||||
@@ -209,6 +216,9 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
// Ignore — fall back to directory name
|
||||
}
|
||||
|
||||
const currentName = profileNames?.[entry.name]?.name;
|
||||
if (typeof currentName === 'string' && currentName.trim()) displayName = currentName.trim();
|
||||
|
||||
profiles.push({ name: entry.name, displayName });
|
||||
}
|
||||
|
||||
@@ -216,7 +226,48 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
if (profiles.length > 0) break;
|
||||
}
|
||||
|
||||
return profiles;
|
||||
return profiles.sort((a, b) => a.name === b.name ? 0 : a.name === 'Default' ? -1 : b.name === 'Default' ? 1 : a.name.localeCompare(b.name, 'en', { numeric: true }));
|
||||
}
|
||||
|
||||
export function normalizeCookieDomain(domain: string): string {
|
||||
if (typeof domain !== 'string' || !domain || domain.length > 254 || /[\s\/@?#\\*]/.test(domain)) {
|
||||
throw new CookieImportError('Invalid cookie domain', 'bad_request');
|
||||
}
|
||||
let host = domain.replace(/^\./, '').replace(/\.$/, '');
|
||||
if (isIP(host) === 6) host = '[' + host + ']';
|
||||
try {
|
||||
if (host.includes(':') && (!host.startsWith('[') || !host.endsWith(']') || isIP(host.slice(1, -1)) !== 6)) throw new Error();
|
||||
const url = new URL('http://' + host);
|
||||
if (!url.hostname || url.hostname.length > 253 || url.port || url.pathname !== '/' || url.hostname.split('.').some(label => !label)) throw new Error();
|
||||
return url.hostname;
|
||||
} catch {
|
||||
throw new CookieImportError('Invalid cookie domain', 'bad_request');
|
||||
}
|
||||
}
|
||||
|
||||
export function cookieDomainMatches(hostname: string, cookieDomain: string): boolean {
|
||||
const host = normalizeCookieDomain(hostname);
|
||||
const domain = normalizeCookieDomain(cookieDomain);
|
||||
const address = isIP(domain.startsWith('[') ? domain.slice(1, -1) : domain);
|
||||
return host === domain || (!address && cookieDomain.startsWith('.') && host.endsWith('.' + domain));
|
||||
}
|
||||
|
||||
export async function withCookieReadRetry<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err: any) {
|
||||
if (attempt < 2 && ['db_locked', 'SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) {
|
||||
await new Promise(resolve => setTimeout(resolve, [150, 500][attempt]));
|
||||
continue;
|
||||
}
|
||||
if (['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) throw new CookieImportError('Cookie database is busy. Close the source browser and retry.', 'db_locked', 'retry');
|
||||
if (err?.code === 'SQLITE_CORRUPT') throw new CookieImportError('Cookie database is corrupt', 'db_corrupt');
|
||||
if (err?.code === 'SQLITE_READONLY') throw new CookieImportError('Cookie database access was denied', 'db_permission');
|
||||
if (typeof err?.code === 'string' && err.code.startsWith('SQLITE_')) throw new CookieImportError('Cookie database could not be read', 'db_read_error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,15 +302,18 @@ export async function importCookies(
|
||||
): Promise<ImportResult> {
|
||||
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||
|
||||
const selectedDomains = [...new Set(domains.flatMap(domain => {
|
||||
const normalized = normalizeCookieDomain(domain);
|
||||
return [normalized, '.' + normalized];
|
||||
}))];
|
||||
const browser = resolveBrowser(browserName);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const derivedKeys = await getDerivedKeys(match);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
|
||||
try {
|
||||
const now = chromiumNow();
|
||||
// Parameterized query — no SQL injection
|
||||
const placeholders = domains.map(() => '?').join(',');
|
||||
const placeholders = selectedDomains.map(() => '?').join(',');
|
||||
const rows = db.query(
|
||||
`SELECT host_key, name, value, encrypted_value, path, expires_utc,
|
||||
is_secure, is_httponly, has_expires, samesite
|
||||
@@ -267,11 +321,15 @@ export async function importCookies(
|
||||
WHERE host_key IN (${placeholders})
|
||||
AND (has_expires = 0 OR expires_utc > ?)
|
||||
ORDER BY host_key, name`
|
||||
).all(...domains, now) as RawCookie[];
|
||||
).all(...selectedDomains, now) as RawCookie[];
|
||||
|
||||
const needsKey = rows.some(row => !row.value && row.encrypted_value.length > 0 && Buffer.from(row.encrypted_value).subarray(0, 3).toString() !== 'v20');
|
||||
const derivedKeys = needsKey ? await getDerivedKeys(match) : new Map<string, Buffer>();
|
||||
|
||||
const cookies: PlaywrightCookie[] = [];
|
||||
let failed = 0;
|
||||
const domainCounts: Record<string, number> = {};
|
||||
const domainCounts: Record<string, number> = Object.create(null);
|
||||
const failureReasons: Record<string, number> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
@@ -279,12 +337,14 @@ export async function importCookies(
|
||||
const cookie = toPlaywrightCookie(row, value);
|
||||
cookies.push(cookie);
|
||||
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
failed++;
|
||||
const reason = err instanceof CookieImportError && err.code === 'v20_encryption' ? 'unsupported_encryption' : 'decryption_failed';
|
||||
failureReasons[reason] = (failureReasons[reason] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { cookies, count: cookies.length, failed, domainCounts };
|
||||
return { cookies, count: cookies.length, failed, domainCounts, failureReasons };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -384,7 +444,7 @@ function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
|
||||
|
||||
// ─── Internal: SQLite Access ────────────────────────────────────
|
||||
|
||||
function openDb(dbPath: string, browserName: string): Database {
|
||||
function openDb(dbPath: string, browserName: string): ReturnType<typeof openCookieDatabase> {
|
||||
// On Windows, Chrome holds exclusive WAL locks even when we open readonly.
|
||||
// The readonly open may "succeed" but return empty results because the WAL
|
||||
// (where all actual data lives) can't be replayed. Always use the copy
|
||||
@@ -393,45 +453,59 @@ function openDb(dbPath: string, browserName: string): Database {
|
||||
return openDbFromCopy(dbPath, browserName);
|
||||
}
|
||||
try {
|
||||
return new Database(dbPath, { readonly: true });
|
||||
return openCookieDatabase(dbPath);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('SQLITE_BUSY') || err.message?.includes('database is locked')) {
|
||||
if (err?.code === 'sqlite_unavailable') throw new CookieImportError(err.message, 'sqlite_unavailable');
|
||||
if (['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) {
|
||||
return openDbFromCopy(dbPath, browserName);
|
||||
}
|
||||
if (err.message?.includes('SQLITE_CORRUPT') || err.message?.includes('malformed')) {
|
||||
if (err?.code === 'SQLITE_CORRUPT') {
|
||||
throw new CookieImportError(
|
||||
`Cookie database for ${browserName} is corrupt`,
|
||||
'db_corrupt',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
throw new CookieImportError('Cookie database could not be read', 'db_read_error');
|
||||
}
|
||||
}
|
||||
|
||||
function openDbFromCopy(dbPath: string, browserName: string): Database {
|
||||
function openDbFromCopy(dbPath: string, browserName: string): ReturnType<typeof openCookieDatabase> {
|
||||
// Use os.tmpdir() instead of hardcoded /tmp for cross-platform support (#708)
|
||||
const tmpPath = path.join(os.tmpdir(), `browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`);
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-cookies-'));
|
||||
const tmpPath = path.join(tmpDir, 'Cookies');
|
||||
try {
|
||||
fs.chmodSync(tmpDir, 0o700);
|
||||
fs.copyFileSync(dbPath, tmpPath);
|
||||
fs.chmodSync(tmpPath, 0o600);
|
||||
// Also copy WAL and SHM if they exist (for consistent reads)
|
||||
const walPath = dbPath + '-wal';
|
||||
const shmPath = dbPath + '-shm';
|
||||
if (fs.existsSync(walPath)) fs.copyFileSync(walPath, tmpPath + '-wal');
|
||||
if (fs.existsSync(shmPath)) fs.copyFileSync(shmPath, tmpPath + '-shm');
|
||||
if (fs.existsSync(walPath)) {
|
||||
fs.copyFileSync(walPath, tmpPath + '-wal');
|
||||
fs.chmodSync(tmpPath + '-wal', 0o600);
|
||||
}
|
||||
if (fs.existsSync(shmPath)) {
|
||||
fs.copyFileSync(shmPath, tmpPath + '-shm');
|
||||
fs.chmodSync(tmpPath + '-shm', 0o600);
|
||||
}
|
||||
|
||||
const db = new Database(tmpPath, { readonly: true });
|
||||
const db = openCookieDatabase(tmpPath);
|
||||
// Schedule cleanup after the DB is closed
|
||||
const origClose = db.close.bind(db);
|
||||
db.close = () => {
|
||||
origClose();
|
||||
try { fs.unlinkSync(tmpPath); } catch {}
|
||||
try { fs.unlinkSync(tmpPath + '-wal'); } catch {}
|
||||
try { fs.unlinkSync(tmpPath + '-shm'); } catch {}
|
||||
try { origClose(); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); }
|
||||
};
|
||||
return db;
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
// Clean up on failure
|
||||
try { fs.unlinkSync(tmpPath); } catch {}
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
if (err?.code === 'sqlite_unavailable') throw new CookieImportError(err.message, 'sqlite_unavailable');
|
||||
if (err?.code === 'SQLITE_CORRUPT') throw new CookieImportError('Cookie database is corrupt', 'db_corrupt');
|
||||
if (err?.code === 'EACCES' || err?.code === 'EPERM') throw new CookieImportError('Cookie database access denied', 'db_permission');
|
||||
if (err?.code === 'ENOENT') throw new CookieImportError('Cookie database no longer exists', 'db_missing');
|
||||
if (!/SQLITE_BUSY|SQLITE_LOCKED|database is locked|EBUSY/.test(String(err?.code) + String(err?.message))) {
|
||||
throw new CookieImportError('Cookie database could not be read', 'db_read_error');
|
||||
}
|
||||
throw new CookieImportError(
|
||||
`Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`,
|
||||
'db_locked',
|
||||
@@ -494,9 +568,8 @@ async function getWindowsAesKey(browser: BrowserInfo): Promise<Buffer> {
|
||||
try {
|
||||
localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? `: ${err.message}` : '';
|
||||
throw new CookieImportError(
|
||||
`Cannot read Local State for ${browser.name} at ${localStatePath}${reason}`,
|
||||
`Cannot read Local State for ${browser.name}`,
|
||||
'keychain_error',
|
||||
);
|
||||
}
|
||||
@@ -532,33 +605,61 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
proc.stdin.write(encryptedBytes.toString('base64'));
|
||||
proc.stdin.end();
|
||||
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
|
||||
}, 10_000),
|
||||
);
|
||||
|
||||
try {
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
proc.stdin.write(encryptedBytes.toString('base64'));
|
||||
proc.stdin.end();
|
||||
const { exitCode, stdout } = await readCredentialProcess(proc, 10_000, () =>
|
||||
new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
throw new CookieImportError(`DPAPI decryption failed: ${stderr.trim()}`, 'keychain_error');
|
||||
throw new CookieImportError('DPAPI decryption failed', 'keychain_error');
|
||||
}
|
||||
return Buffer.from(stdout.trim(), 'base64');
|
||||
} catch (err) {
|
||||
if (err instanceof CookieImportError) throw err;
|
||||
throw new CookieImportError(
|
||||
`DPAPI decryption failed: ${(err as Error).message}`,
|
||||
'DPAPI decryption failed',
|
||||
'keychain_error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readCredentialProcess(
|
||||
proc: { exited: Promise<number>; stdout: ReadableStream<Uint8Array>; stderr: ReadableStream<Uint8Array>; kill(): void },
|
||||
timeoutMs: number,
|
||||
timeoutError: () => Error,
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const readers = [proc.stdout.getReader(), proc.stderr.getReader()];
|
||||
const read = async (reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return Buffer.concat(chunks, bytes).toString('utf8');
|
||||
bytes += value.byteLength;
|
||||
if (bytes > 64 * 1024) throw new Error('Credential process output exceeded the limit');
|
||||
chunks.push(value);
|
||||
}
|
||||
};
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(timeoutError()), timeoutMs);
|
||||
});
|
||||
try {
|
||||
const [exitCode, stdout, stderr] = await Promise.race([
|
||||
Promise.all([proc.exited, read(readers[0]), read(readers[1])]), timeout,
|
||||
]);
|
||||
return { exitCode, stdout, stderr };
|
||||
} catch (error) {
|
||||
try { proc.kill(); } catch {}
|
||||
for (const reader of readers) {
|
||||
try { void reader.cancel().catch(() => {}); } catch {}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer!);
|
||||
}
|
||||
}
|
||||
|
||||
async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
// Use async Bun.spawn with timeout to avoid blocking the event loop.
|
||||
// macOS may show an Allow/Deny dialog that blocks until the user responds.
|
||||
@@ -567,21 +668,13 @@ async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
{ stdout: 'pipe', stderr: 'pipe', windowsHide: true },
|
||||
);
|
||||
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new CookieImportError(
|
||||
try {
|
||||
const { exitCode, stdout, stderr } = await readCredentialProcess(proc, 10_000, () =>
|
||||
new CookieImportError(
|
||||
`macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`,
|
||||
'keychain_timeout',
|
||||
'retry',
|
||||
));
|
||||
}, 10_000),
|
||||
);
|
||||
|
||||
try {
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
if (exitCode !== 0) {
|
||||
// Distinguish denied vs not found vs other
|
||||
@@ -600,7 +693,7 @@ async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
);
|
||||
}
|
||||
throw new CookieImportError(
|
||||
`Could not read Keychain: ${stderr.trim()}`,
|
||||
'Could not read Keychain',
|
||||
'keychain_error',
|
||||
'retry',
|
||||
);
|
||||
@@ -610,7 +703,7 @@ async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
} catch (err) {
|
||||
if (err instanceof CookieImportError) throw err;
|
||||
throw new CookieImportError(
|
||||
`Could not read Keychain: ${(err as Error).message}`,
|
||||
'Could not read Keychain',
|
||||
'keychain_error',
|
||||
'retry',
|
||||
);
|
||||
@@ -640,15 +733,7 @@ async function getLinuxSecretPassword(browser: BrowserInfo): Promise<string | nu
|
||||
async function runPasswordLookup(cmd: string[], timeoutMs: number): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', windowsHide: true });
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error('timeout'));
|
||||
}, timeoutMs),
|
||||
);
|
||||
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const { exitCode, stdout } = await readCredentialProcess(proc, timeoutMs, () => new Error('timeout'));
|
||||
if (exitCode !== 0) return null;
|
||||
|
||||
const password = stdout.trim();
|
||||
@@ -752,295 +837,28 @@ function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' {
|
||||
}
|
||||
|
||||
|
||||
// ─── CDP-based Cookie Extraction (Windows v20 fallback) ────────
|
||||
// When App-Bound Encryption (v20) is detected, we launch Chrome headless
|
||||
// with remote debugging and extract cookies via the DevTools Protocol.
|
||||
// This only works when Chrome is NOT already running (profile lock).
|
||||
|
||||
const CHROME_PATHS_WIN = [
|
||||
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
];
|
||||
|
||||
const EDGE_PATHS_WIN = [
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
||||
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
||||
];
|
||||
|
||||
function findBrowserExe(browserName: string): string | null {
|
||||
const candidates = browserName.toLowerCase().includes('edge') ? EDGE_PATHS_WIN : CHROME_PATHS_WIN;
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBrowserRunning(browserName: string): Promise<boolean> {
|
||||
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
|
||||
return new Promise((resolve) => {
|
||||
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
|
||||
stdout: 'pipe', stderr: 'pipe', windowsHide: true,
|
||||
});
|
||||
proc.exited.then(async () => {
|
||||
const out = await new Response(proc.stdout).text();
|
||||
resolve(out.toLowerCase().includes(exe));
|
||||
}).catch(() => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract cookies via Chrome DevTools Protocol. Launches Chrome headless with
|
||||
* remote debugging on the user's real profile directory. Requires Chrome to be
|
||||
* closed first (profile lock).
|
||||
*
|
||||
* v20 App-Bound Encryption binds decryption keys to the original user-data-dir
|
||||
* path, so a temp copy of the profile won't work — Chrome silently discards
|
||||
* cookies it can't decrypt. We must use the real profile.
|
||||
*/
|
||||
export async function importCookiesViaCdp(
|
||||
browserName: string,
|
||||
domains: string[],
|
||||
profile = 'Default',
|
||||
): Promise<ImportResult> {
|
||||
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||
if (process.platform !== 'win32') {
|
||||
throw new CookieImportError('CDP extraction is only needed on Windows', 'not_supported');
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') throw new CookieImportError('Native extraction is only supported on Windows', 'not_supported');
|
||||
const browser = resolveBrowser(browserName);
|
||||
const exePath = findBrowserExe(browser.name);
|
||||
if (!exePath) {
|
||||
throw new CookieImportError(
|
||||
`Cannot find ${browser.name} executable. Install it or use /connect-chrome.`,
|
||||
'not_installed',
|
||||
);
|
||||
}
|
||||
|
||||
if (await isBrowserRunning(browser.name)) {
|
||||
throw new CookieImportError(
|
||||
`${browser.name} is running. Close it first so we can launch headless with your profile, or use /connect-chrome to control your real browser directly.`,
|
||||
'browser_running',
|
||||
'retry',
|
||||
);
|
||||
}
|
||||
|
||||
// Must use the real user data dir — v20 ABE keys are path-bound
|
||||
validateProfile(profile);
|
||||
const dataDir = getDataDirForPlatform(browser, 'win32');
|
||||
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
|
||||
const userDataDir = path.join(getBaseDir('win32'), dataDir);
|
||||
|
||||
// Launch Chrome headless with remote debugging on the real profile.
|
||||
//
|
||||
// Security posture of the debug port:
|
||||
// - Chrome binds --remote-debugging-port to 127.0.0.1 by default. The
|
||||
// port is NOT exposed to the network. Baseline threat: a local
|
||||
// process running as the same user can connect.
|
||||
// - Port is randomized in [9222, 9321] to avoid collisions with other
|
||||
// Chrome-based tools. Not cryptographic — security relies on
|
||||
// same-user-access baseline, not port secrecy.
|
||||
// - Chrome is always killed in the finally block below (even on crash).
|
||||
//
|
||||
// KNOWN NON-GOAL (tracked as a separate hardening task for the next
|
||||
// security wave):
|
||||
// On Windows 10.15+ with App-Bound Encryption (v20) enabled, a
|
||||
// same-user process that opens the cookie DB directly cannot decrypt
|
||||
// v20 values — the DPAPI context is bound to the browser process.
|
||||
// The CDP port bypasses that: `Network.getAllCookies` runs inside the
|
||||
// browser, so any same-user process that connects to the debug port
|
||||
// before we kill Chrome could exfiltrate decrypted v20 cookies.
|
||||
// Fix direction: switch to `--remote-debugging-pipe` so the CDP
|
||||
// transport is a parent/child stdio pipe, not TCP. Requires
|
||||
// restructuring the extractCookiesViaCdp WebSocket client; deferred
|
||||
// to a follow-up because the transport swap is non-trivial and the
|
||||
// baseline threat is still "attacker already has same-user access."
|
||||
//
|
||||
// Debugging note: if this path starts failing after a Chrome update,
|
||||
// check the Chrome version logged below — Chrome's ABE key format (v20)
|
||||
// or /json/list shape can change between major versions.
|
||||
const debugPort = 9222 + Math.floor(Math.random() * 100);
|
||||
const chromeProc = Bun.spawn([
|
||||
exePath,
|
||||
`--remote-debugging-port=${debugPort}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
`--profile-directory=${profile}`,
|
||||
'--headless=new',
|
||||
'--no-first-run',
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--no-default-browser-check',
|
||||
], { stdout: 'pipe', stderr: 'pipe', windowsHide: true });
|
||||
|
||||
// Wait for Chrome to start, then find a page target's WebSocket URL.
|
||||
// Network.getAllCookies is only available on page targets, not browser.
|
||||
let wsUrl: string | null = null;
|
||||
const startTime = Date.now();
|
||||
let loggedVersion = false;
|
||||
while (Date.now() - startTime < 15_000) {
|
||||
try {
|
||||
// One-time version log for future diagnostics when Chrome changes v20 format.
|
||||
if (!loggedVersion) {
|
||||
try {
|
||||
const versionResp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);
|
||||
if (versionResp.ok) {
|
||||
const v = await versionResp.json() as { Browser?: string };
|
||||
console.log(`[cookie-import] CDP fallback: ${browser.name} ${v.Browser || 'unknown version'}`);
|
||||
loggedVersion = true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const resp = await fetch(`http://127.0.0.1:${debugPort}/json/list`);
|
||||
if (resp.ok) {
|
||||
const targets = await resp.json() as Array<{ type: string; webSocketDebuggerUrl?: string }>;
|
||||
const page = targets.find(t => t.type === 'page');
|
||||
if (page?.webSocketDebuggerUrl) {
|
||||
wsUrl = page.webSocketDebuggerUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not ready yet
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
|
||||
if (!wsUrl) {
|
||||
chromeProc.kill();
|
||||
throw new CookieImportError(
|
||||
`${browser.name} headless did not start within 15s`,
|
||||
'cdp_timeout',
|
||||
'retry',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect via CDP WebSocket
|
||||
const cookies = await extractCookiesViaCdp(wsUrl, domains);
|
||||
|
||||
const domainCounts: Record<string, number> = {};
|
||||
for (const c of cookies) {
|
||||
domainCounts[c.domain] = (domainCounts[c.domain] || 0) + 1;
|
||||
}
|
||||
|
||||
return { cookies, count: cookies.length, failed: 0, domainCounts };
|
||||
} finally {
|
||||
chromeProc.kill();
|
||||
}
|
||||
}
|
||||
|
||||
async function extractCookiesViaCdp(wsUrl: string, domains: string[]): Promise<PlaywrightCookie[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let msgId = 1;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new CookieImportError('CDP cookie extraction timed out', 'cdp_timeout'));
|
||||
}, 10_000);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Enable Network domain first, then request all cookies
|
||||
ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(String(event.data));
|
||||
|
||||
// After Network.enable succeeds, request all cookies
|
||||
if (data.id === 1 && !data.error) {
|
||||
ws.send(JSON.stringify({ id: msgId, method: 'Network.getAllCookies' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.id === msgId && data.result?.cookies) {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
|
||||
// Normalize domain matching: domains like ".example.com" match "example.com" and vice versa
|
||||
const domainSet = new Set<string>();
|
||||
for (const d of domains) {
|
||||
domainSet.add(d);
|
||||
domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d);
|
||||
}
|
||||
|
||||
const matched: PlaywrightCookie[] = [];
|
||||
for (const c of data.result.cookies as CdpCookie[]) {
|
||||
if (!domainSet.has(c.domain)) continue;
|
||||
matched.push({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path || '/',
|
||||
expires: c.expires === -1 ? -1 : c.expires,
|
||||
secure: c.secure,
|
||||
httpOnly: c.httpOnly,
|
||||
sameSite: cdpSameSite(c.sameSite),
|
||||
});
|
||||
}
|
||||
resolve(matched);
|
||||
} else if (data.id === msgId && data.error) {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
reject(new CookieImportError(
|
||||
`CDP error: ${data.error.message}`,
|
||||
'cdp_error',
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new CookieImportError(
|
||||
`CDP WebSocket error: ${(err as any).message || 'unknown'}`,
|
||||
'cdp_error',
|
||||
));
|
||||
};
|
||||
if (!dataDir) throw new CookieImportError('This browser is not supported on Windows', 'not_supported');
|
||||
const { importNativeCookies } = await import('./cookie-import-native');
|
||||
const cookies = await importNativeCookies({
|
||||
browserName: browser.name,
|
||||
userDataDir: path.join(getBaseDir('win32'), dataDir),
|
||||
profile,
|
||||
domains: [...new Set(domains.flatMap(domain => {
|
||||
const normalized = normalizeCookieDomain(domain);
|
||||
return [normalized, '.' + normalized];
|
||||
}))],
|
||||
});
|
||||
}
|
||||
|
||||
interface CdpCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
size: number;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
session: boolean;
|
||||
sameSite: string;
|
||||
}
|
||||
|
||||
function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' {
|
||||
switch (value) {
|
||||
case 'Strict': return 'Strict';
|
||||
case 'Lax': return 'Lax';
|
||||
case 'None': return 'None';
|
||||
default: return 'Lax';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies.
|
||||
* Quick check — reads a small sample, no decryption attempted.
|
||||
*/
|
||||
export function hasV20Cookies(browserName: string, profile = 'Default'): boolean {
|
||||
if (process.platform !== 'win32') return false;
|
||||
try {
|
||||
const browser = resolveBrowser(browserName);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
try {
|
||||
const rows = db.query('SELECT encrypted_value FROM cookies LIMIT 10').all() as Array<{ encrypted_value: Buffer | Uint8Array }>;
|
||||
return rows.some(row => {
|
||||
const ev = Buffer.from(row.encrypted_value);
|
||||
return ev.length >= 3 && ev.slice(0, 3).toString('utf-8') === 'v20';
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const domainCounts: Record<string, number> = Object.create(null);
|
||||
for (const cookie of cookies) domainCounts[cookie.domain] = (domainCounts[cookie.domain] || 0) + 1;
|
||||
return { cookies, count: cookies.length, failed: 0, domainCounts };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const NATIVE_QUALIFICATION_DATA = 'browse/src/cookie-import-native-qualification.json';
|
||||
export const NATIVE_BROWSER_VERSION_COMMAND = '$ErrorActionPreference = "Stop"; [Diagnostics.FileVersionInfo]::GetVersionInfo($env:GSTACK_QUALIFY_BROWSER_EXE).ProductVersion';
|
||||
|
||||
export const NATIVE_CODE_INPUTS = Object.freeze([
|
||||
'browse/src/cookie-import-browser.ts',
|
||||
'browse/src/cookie-database.ts',
|
||||
'browse/src/cookie-import-native.ts',
|
||||
'browse/src/cookie-import-native-integrity.ts',
|
||||
'browse/src/cookie-import-native-job.ts',
|
||||
'browse/src/cookie-import-native-worker.ts',
|
||||
'browse/src/bun-polyfill.cjs',
|
||||
'browse/scripts/build-node-server.sh',
|
||||
'.github/scripts/run-cookie-native-qualification.ps1',
|
||||
'browse/dist/server-node.mjs',
|
||||
'browse/dist/bun-polyfill.cjs',
|
||||
'browse/test/cookie-import-native.test.ts',
|
||||
'browse/test/cookie-import-native-job.test.ts',
|
||||
'browse/test/cookie-import-native-qualification.ts',
|
||||
'browse/test/fixtures/native-cookie-process.cjs',
|
||||
'browse/test/fixtures/native-cookie-launch.cjs',
|
||||
'browse/test/fixtures/native-cookie-process-observer.ts',
|
||||
'browse/test/fixtures/native-cookie-file-owners.ts',
|
||||
'browse/test/fixtures/native-cookie-remove-fixture.cjs',
|
||||
'node_modules/playwright/package.json',
|
||||
'node_modules/playwright/index.js',
|
||||
'node_modules/playwright-core/package.json',
|
||||
'node_modules/playwright-core/index.js',
|
||||
'node_modules/playwright-core/lib/bootstrap.js',
|
||||
'node_modules/playwright-core/lib/coreBundle.js',
|
||||
'node_modules/playwright-core/lib/utilsBundle.js',
|
||||
]);
|
||||
|
||||
export interface NativeQualifiedBuild {
|
||||
browserName: 'Chrome' | 'Chromium' | 'Brave' | 'Edge';
|
||||
architecture: 'x64' | 'arm64';
|
||||
windowsRelease: string;
|
||||
executableSha256: string;
|
||||
nodeVersion: string;
|
||||
bunVersion: string;
|
||||
playwrightVersion: string;
|
||||
sourceHashes: Record<string, string>;
|
||||
}
|
||||
|
||||
async function readBoundedFile(file: string, deadline: number, maximumBytes: number): Promise<Buffer> {
|
||||
if (!Number.isFinite(deadline) || Date.now() >= deadline) throw new Error('native_timeout');
|
||||
const cancellation = new AbortController();
|
||||
const timer = setTimeout(() => cancellation.abort(), Math.max(0, deadline - Date.now()));
|
||||
const stream = createReadStream(file, { signal: cancellation.signal });
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
size += chunk.length;
|
||||
if (size > maximumBytes) throw new Error('native_unqualified');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (Date.now() >= deadline) throw new Error('native_timeout');
|
||||
return Buffer.concat(chunks);
|
||||
} catch (error) {
|
||||
if (cancellation.signal.aborted) throw new Error('native_timeout');
|
||||
throw error;
|
||||
} finally {
|
||||
stream.destroy();
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readNativeQualifications(root: string, deadline: number): Promise<NativeQualifiedBuild[]> {
|
||||
const builds = JSON.parse((await readBoundedFile(path.join(root, NATIVE_QUALIFICATION_DATA), deadline, 1024 * 1024)).toString('utf8'));
|
||||
if (!Array.isArray(builds) || builds.some(build => !build || typeof build !== 'object')) throw new Error('native_unqualified');
|
||||
return builds;
|
||||
}
|
||||
|
||||
export async function hashNativeFile(file: string, deadline: number): Promise<string> {
|
||||
return createHash('sha256').update(await readBoundedFile(file, deadline, 64 * 1024 * 1024)).digest('hex');
|
||||
}
|
||||
|
||||
export async function nativeCodeHashes(root: string, deadline: number): Promise<Record<string, string>> {
|
||||
const hashes: Record<string, string> = {};
|
||||
for (const file of NATIVE_CODE_INPUTS) hashes[file] = await hashNativeFile(path.join(root, file), deadline);
|
||||
return hashes;
|
||||
}
|
||||
|
||||
export function nativeCodeMatches(expected: unknown, actual: Record<string, string>): boolean {
|
||||
if (!expected || typeof expected !== 'object' || Array.isArray(expected) || Object.keys(expected).length !== NATIVE_CODE_INPUTS.length) return false;
|
||||
return NATIVE_CODE_INPUTS.every(file => {
|
||||
const hash = (expected as Record<string, unknown>)[file];
|
||||
return typeof hash === 'string' && /^[0-9a-f]{64}$/.test(hash) && hash === actual[file];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const NATIVE_COOKIE_STAGES = [
|
||||
'platform', 'ffi_import', 'ffi_open', 'job_create', 'job_limits', 'job_open',
|
||||
'process_open', 'job_assign', 'job_assigned', 'job_joined', 'process_close', 'job_query', 'job_terminate', 'job_close',
|
||||
'worker_boot', 'supervisor_input', 'runtime_check', 'member_start', 'member_input', 'member_decoded', 'member_exit',
|
||||
'node_start', 'node_spawned', 'node_exit', 'node_input', 'node_load', 'browser_launch', 'cookie_read', 'browser_close',
|
||||
] as const;
|
||||
|
||||
export interface NativeCookieDiagnostic {
|
||||
stage: typeof NATIVE_COOKIE_STAGES[number];
|
||||
win32Error?: number;
|
||||
lastStage?: typeof NATIVE_COOKIE_STAGES[number];
|
||||
exitCode?: number;
|
||||
nodeExitCode?: number;
|
||||
signal?: string;
|
||||
memberMode?: boolean;
|
||||
stderrBytes?: number;
|
||||
}
|
||||
|
||||
export class NativeCookieJobError extends Error {
|
||||
readonly diagnostic: NativeCookieDiagnostic;
|
||||
|
||||
constructor(stage: NativeCookieDiagnostic['stage'], win32Error?: number) {
|
||||
super(stage === 'platform' ? 'native_supervision_unavailable' : 'native_supervision_failed');
|
||||
this.name = 'NativeCookieJobError';
|
||||
this.diagnostic = { stage, ...(Number.isInteger(win32Error) && win32Error! >= 0 && win32Error! <= 0xffffffff ? { win32Error } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
export function nativeCookieDiagnostic(error: unknown, fallback: NativeCookieDiagnostic['stage']): NativeCookieDiagnostic {
|
||||
return error instanceof NativeCookieJobError ? error.diagnostic : { stage: fallback };
|
||||
}
|
||||
|
||||
export function parseNativeCookieDiagnostic(value: unknown): NativeCookieDiagnostic | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const candidate = value as NativeCookieDiagnostic;
|
||||
if (!NATIVE_COOKIE_STAGES.includes(candidate.stage)) return undefined;
|
||||
const diagnostic = new NativeCookieJobError(candidate.stage, candidate.win32Error).diagnostic;
|
||||
if (NATIVE_COOKIE_STAGES.includes(candidate.lastStage!)) diagnostic.lastStage = candidate.lastStage;
|
||||
for (const field of ['exitCode', 'nodeExitCode', 'stderrBytes'] as const) {
|
||||
const value = candidate[field];
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value >= (field === 'stderrBytes' ? 0 : -0x80000000) && value <= 0xffffffff) diagnostic[field] = value;
|
||||
}
|
||||
if (['SIGTERM', 'SIGKILL', 'SIGINT', 'SIGSEGV', 'SIGABRT', 'SIGBREAK', 'SIGHUP'].includes(candidate.signal!)) diagnostic.signal = candidate.signal;
|
||||
if (typeof candidate.memberMode === 'boolean') diagnostic.memberMode = candidate.memberMode;
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
export interface NativeCookieJob {
|
||||
name: string;
|
||||
terminate(): void;
|
||||
activeProcesses(): number;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export async function createNativeCookieJob(): Promise<NativeCookieJob> {
|
||||
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) {
|
||||
throw new NativeCookieJobError('platform');
|
||||
}
|
||||
const { api, ptr, closeLibrary } = await openKernel();
|
||||
const name = `Local\\gstack-cookie-${randomUUID()}`;
|
||||
const wideName = Buffer.from(`${name}\0`, 'utf16le');
|
||||
let stage: NativeCookieDiagnostic['stage'] = 'job_create';
|
||||
let handle: number | bigint = 0;
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
if (handle && !api.CloseHandle(handle)) throw new NativeCookieJobError('job_close', api.GetLastError());
|
||||
} finally {
|
||||
closeLibrary();
|
||||
}
|
||||
};
|
||||
try {
|
||||
handle = api.CreateJobObjectW(null, ptr(wideName));
|
||||
const createError = api.GetLastError();
|
||||
if (!handle || createError === 183) throw new NativeCookieJobError('job_create', createError);
|
||||
const limits = Buffer.alloc(144);
|
||||
limits.writeUInt32LE(0x2000, 16);
|
||||
stage = 'job_limits';
|
||||
if (!api.SetInformationJobObject(handle, 9, ptr(limits), limits.byteLength)) {
|
||||
throw new NativeCookieJobError(stage, api.GetLastError());
|
||||
}
|
||||
return {
|
||||
name,
|
||||
terminate() {
|
||||
if (closed) throw new NativeCookieJobError('job_terminate');
|
||||
if (!api.TerminateJobObject(handle, 1)) throw new NativeCookieJobError('job_terminate', api.GetLastError());
|
||||
},
|
||||
activeProcesses() {
|
||||
const accounting = Buffer.alloc(48);
|
||||
if (closed) throw new NativeCookieJobError('job_query');
|
||||
if (!api.QueryInformationJobObject(handle, 1, ptr(accounting), accounting.byteLength, null)) throw new NativeCookieJobError('job_query', api.GetLastError());
|
||||
return accounting.readUInt32LE(40);
|
||||
},
|
||||
close,
|
||||
};
|
||||
} catch (error) {
|
||||
try { close(); } catch {}
|
||||
throw error instanceof NativeCookieJobError ? error : new NativeCookieJobError(stage);
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinNativeCookieJob(name: string, observe?: (stage: NativeCookieDiagnostic['stage']) => void): Promise<void> {
|
||||
if (process.platform !== 'win32' || !/^Local\\gstack-cookie-[0-9a-f-]{36}$/.test(name)) {
|
||||
throw new NativeCookieJobError('job_open');
|
||||
}
|
||||
observe?.('ffi_import');
|
||||
const { api, ptr, closeLibrary } = await openKernel();
|
||||
const wideName = Buffer.from(`${name}\0`, 'utf16le');
|
||||
let stage: NativeCookieDiagnostic['stage'] = 'job_open';
|
||||
let handle: number | bigint = 0;
|
||||
let currentProcess: number | bigint = 0;
|
||||
try {
|
||||
observe?.(stage);
|
||||
handle = api.OpenJobObjectW(1, 0, ptr(wideName));
|
||||
if (!handle) throw new NativeCookieJobError(stage, api.GetLastError());
|
||||
stage = 'process_open';
|
||||
observe?.(stage);
|
||||
currentProcess = api.OpenProcess(0x0101, 0, process.pid);
|
||||
if (!currentProcess) throw new NativeCookieJobError(stage, api.GetLastError());
|
||||
stage = 'job_assign';
|
||||
observe?.(stage);
|
||||
if (!api.AssignProcessToJobObject(handle, currentProcess)) throw new NativeCookieJobError(stage, api.GetLastError());
|
||||
observe?.('job_assigned');
|
||||
} catch (error) {
|
||||
throw error instanceof NativeCookieJobError ? error : new NativeCookieJobError(stage);
|
||||
} finally {
|
||||
let closeError: NativeCookieJobError | undefined;
|
||||
observe?.('process_close');
|
||||
if (currentProcess && !api.CloseHandle(currentProcess)) closeError = new NativeCookieJobError('process_close', api.GetLastError());
|
||||
observe?.('job_close');
|
||||
if (handle && !api.CloseHandle(handle)) closeError ??= new NativeCookieJobError('job_close', api.GetLastError());
|
||||
closeLibrary();
|
||||
if (closeError) throw closeError;
|
||||
}
|
||||
}
|
||||
|
||||
async function openKernel() {
|
||||
let stage: NativeCookieDiagnostic['stage'] = 'ffi_import';
|
||||
try {
|
||||
const { dlopen, FFIType, ptr } = await import('bun:ffi');
|
||||
stage = 'ffi_open';
|
||||
const library = dlopen('kernel32.dll', {
|
||||
CreateJobObjectW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.u64 },
|
||||
OpenJobObjectW: { args: [FFIType.u32, FFIType.i32, FFIType.ptr], returns: FFIType.u64 },
|
||||
SetInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
||||
QueryInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
AssignProcessToJobObject: { args: [FFIType.u64, FFIType.u64], returns: FFIType.i32 },
|
||||
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
|
||||
TerminateJobObject: { args: [FFIType.u64, FFIType.u32], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
GetLastError: { args: [], returns: FFIType.u32 },
|
||||
});
|
||||
return { api: library.symbols, ptr, closeLibrary: () => library.close() };
|
||||
} catch {
|
||||
throw new NativeCookieJobError(stage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,267 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { writeSync } from 'node:fs';
|
||||
import { createNativeCookieJob, joinNativeCookieJob, nativeCookieDiagnostic, parseNativeCookieDiagnostic, type NativeCookieDiagnostic, type NativeCookieJob } from './cookie-import-native-job';
|
||||
import type { PlaywrightCookie } from './cookie-import-browser';
|
||||
|
||||
export interface NativeCookieRequest {
|
||||
nodeExecutable: string;
|
||||
nodeArchitecture: string;
|
||||
playwrightEntry: string;
|
||||
executablePath: string;
|
||||
userDataDir: string;
|
||||
profile: string;
|
||||
domains: string[];
|
||||
deadline: number;
|
||||
qualifiedBunVersions: string[];
|
||||
}
|
||||
|
||||
export type NativeCookieReply =
|
||||
| { cookies: PlaywrightCookie[] }
|
||||
| { error: 'native_timeout' | 'native_failed' | 'native_cleanup_failed' | 'native_supervision_failed' | 'browser_running' | 'native_profile_unsupported'; diagnostic?: NativeCookieDiagnostic };
|
||||
|
||||
export interface NativeCookieMember {
|
||||
result: Promise<NativeCookieReply>;
|
||||
closed: Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
const MAX_REPLY_BYTES = 8 * 1024 * 1024;
|
||||
export const NATIVE_PROGRESS_PREFIX = 'GSTACK_NATIVE_PROGRESS ';
|
||||
|
||||
function nativeProgress(diagnostic: NativeCookieDiagnostic): void {
|
||||
try { writeSync(2, NATIVE_PROGRESS_PREFIX + JSON.stringify(diagnostic) + '\n'); } catch {}
|
||||
}
|
||||
|
||||
export function nativeCookieEnvironment(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const allowed = new Set(['systemroot', 'windir', 'temp', 'tmp', 'userprofile', 'localappdata', 'appdata', 'programfiles', 'programfiles(x86)', 'programdata', 'path', 'pathext']);
|
||||
return Object.fromEntries(Object.entries(env).filter(([key, value]) => allowed.has(key.toLowerCase()) && typeof value === 'string')) as Record<string, string>;
|
||||
}
|
||||
|
||||
export const NATIVE_COOKIE_NODE_SCRIPT = String.raw`
|
||||
const fs = require('node:fs');
|
||||
let stage = 'node_input';
|
||||
const progress = () => { try { fs.writeSync(2, 'GSTACK_NATIVE_PROGRESS ' + JSON.stringify({ stage }) + '\n'); } catch {} };
|
||||
progress();
|
||||
(async () => {
|
||||
const request = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
stage = 'node_load';
|
||||
progress();
|
||||
const { chromium } = require(request.playwrightEntry);
|
||||
let context;
|
||||
try {
|
||||
const remaining = request.deadline - Date.now();
|
||||
if (remaining <= 0) throw new Error('native_timeout');
|
||||
stage = 'browser_launch';
|
||||
progress();
|
||||
context = await chromium.launchPersistentContext(request.userDataDir, {
|
||||
executablePath: request.executablePath,
|
||||
args: ['--profile-directory=' + request.profile],
|
||||
headless: true,
|
||||
chromiumSandbox: true,
|
||||
timeout: remaining,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
handleSIGHUP: false,
|
||||
env: process.env,
|
||||
});
|
||||
stage = 'cookie_read';
|
||||
progress();
|
||||
const selected = new Set(request.domains.map(domain => domain.toLowerCase().replace(/^\./, '').replace(/\.$/, '')));
|
||||
const cookies = (await context.cookies()).filter(cookie => selected.has(cookie.domain.toLowerCase().replace(/^\./, '').replace(/\.$/, '')));
|
||||
await new Promise(resolve => process.stdout.write(JSON.stringify({ cookies }) + '\n', resolve));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
const exited = message.match(/<process did exit: exitCode=(-?\d+), signal=(?:null|SIG[A-Z]+)>/);
|
||||
const exitCode = exited ? Number(exited[1]) : undefined;
|
||||
const code = (stage === 'browser_launch' && exitCode === 21) || /ProcessSingleton|profile.*in use|user data directory is already in use|opening in existing browser session/i.test(message)
|
||||
? 'browser_running'
|
||||
: /remote debugging requires a non-default data directory/i.test(message)
|
||||
? 'native_profile_unsupported'
|
||||
: /Timeout|native_timeout/.test(message) ? 'native_timeout' : 'native_failed';
|
||||
await new Promise(resolve => process.stdout.write(JSON.stringify({ error: code, diagnostic: { stage, ...(Number.isInteger(exitCode) ? { exitCode } : {}) } }) + '\n', resolve));
|
||||
} finally {
|
||||
stage = 'browser_close';
|
||||
progress();
|
||||
await context?.close().catch(() => {});
|
||||
}
|
||||
})().catch(() => { process.stdout.write(JSON.stringify({ error: 'native_failed', diagnostic: { stage } }) + '\n'); process.exitCode = 1; });
|
||||
`;
|
||||
|
||||
export async function superviseNativeCookieImport(
|
||||
request: NativeCookieRequest,
|
||||
dependencies: {
|
||||
createJob?: () => Promise<NativeCookieJob>;
|
||||
startMember?: (request: NativeCookieRequest, jobName: string) => NativeCookieMember;
|
||||
now?: () => number;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<NativeCookieReply> {
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const sleep = dependencies.sleep ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)));
|
||||
const deadline = Math.min(request.deadline, now() + 25_000);
|
||||
let job: NativeCookieJob | undefined;
|
||||
let member: NativeCookieMember | undefined;
|
||||
let reply: NativeCookieReply | undefined;
|
||||
let closed = false;
|
||||
try {
|
||||
job = await (dependencies.createJob ?? createNativeCookieJob)();
|
||||
if (now() >= deadline || dependencies.signal?.aborted) return { error: 'native_timeout' };
|
||||
member = (dependencies.startMember ?? startMember)({ ...request, deadline }, job.name);
|
||||
void member.result.then(value => { reply ??= value; }, () => { reply ??= { error: 'native_failed' }; });
|
||||
void member.closed.then(() => { closed = true; }, () => { closed = true; });
|
||||
while (!reply && !closed && now() < deadline && !dependencies.signal?.aborted) await sleep(Math.min(20, deadline - now()));
|
||||
reply ??= { error: now() >= deadline ? 'native_timeout' : 'native_failed' };
|
||||
const cleanupDeadline = now() + 5_000;
|
||||
const graceDeadline = now() + ('cookies' in reply ? 2_000 : 0);
|
||||
while ((!closed || job.activeProcesses() !== 0) && now() < graceDeadline) await sleep(20);
|
||||
if (job.activeProcesses() !== 0) job.terminate();
|
||||
while ((!closed || job.activeProcesses() !== 0) && now() < cleanupDeadline) await sleep(20);
|
||||
if (!closed || job.activeProcesses() !== 0) return { error: 'native_cleanup_failed' };
|
||||
return reply;
|
||||
} catch (error) {
|
||||
return { error: job ? 'native_cleanup_failed' : 'native_supervision_failed', diagnostic: nativeCookieDiagnostic(error, job ? 'job_query' : 'job_create') };
|
||||
} finally {
|
||||
let diagnostic: NativeCookieDiagnostic | undefined;
|
||||
try { job?.close(); } catch (error) { diagnostic = nativeCookieDiagnostic(error, 'job_close'); }
|
||||
try { member?.stop(); } catch (error) { diagnostic ??= nativeCookieDiagnostic(error, 'member_exit'); }
|
||||
if (diagnostic) return { error: 'native_cleanup_failed', diagnostic };
|
||||
}
|
||||
}
|
||||
|
||||
function startMember(request: NativeCookieRequest, jobName: string, mode = '--member'): NativeCookieMember {
|
||||
const child = spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', import.meta.path, mode], {
|
||||
env: nativeCookieEnvironment(process.env),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const closed = new Promise<void>(resolve => child.once('close', () => resolve()));
|
||||
let progressInput = '';
|
||||
let lastStage: NativeCookieDiagnostic['stage'] | undefined;
|
||||
let memberMode: boolean | undefined;
|
||||
let nodeExitCode: number | undefined;
|
||||
let stderrBytes = 0;
|
||||
child.stderr.on('data', chunk => {
|
||||
stderrBytes = Math.min(0xffffffff, stderrBytes + chunk.length);
|
||||
progressInput += chunk.toString('utf8');
|
||||
for (let end = progressInput.indexOf('\n'); end >= 0; end = progressInput.indexOf('\n')) {
|
||||
const line = progressInput.slice(0, end);
|
||||
progressInput = progressInput.slice(end + 1);
|
||||
if (!line.startsWith(NATIVE_PROGRESS_PREFIX)) continue;
|
||||
try {
|
||||
const diagnostic = parseNativeCookieDiagnostic(JSON.parse(line.slice(NATIVE_PROGRESS_PREFIX.length)));
|
||||
if (!diagnostic) continue;
|
||||
lastStage = diagnostic.stage;
|
||||
memberMode ??= diagnostic.memberMode;
|
||||
if (diagnostic.stage === 'node_exit') nodeExitCode = diagnostic.exitCode;
|
||||
} catch {}
|
||||
}
|
||||
if (progressInput.length > 4096) progressInput = '';
|
||||
});
|
||||
const result = new Promise<NativeCookieReply>(resolve => {
|
||||
let output = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stdout.on('data', chunk => {
|
||||
output += chunk;
|
||||
if (Buffer.byteLength(output) > MAX_REPLY_BYTES) {
|
||||
resolve({ error: 'native_failed' });
|
||||
child.stdout.destroy();
|
||||
} else if (output.includes('\n')) {
|
||||
try {
|
||||
const parsed = JSON.parse(output.slice(0, output.indexOf('\n')));
|
||||
const errors = ['native_timeout', 'native_failed', 'native_cleanup_failed', 'native_supervision_failed', 'browser_running', 'native_profile_unsupported'];
|
||||
const diagnostic = parseNativeCookieDiagnostic(parsed.diagnostic);
|
||||
resolve(Array.isArray(parsed.cookies) ? { cookies: parsed.cookies } : { error: errors.includes(parsed.error) ? parsed.error : 'native_failed', ...(diagnostic ? { diagnostic } : {}) });
|
||||
} catch {
|
||||
resolve({ error: 'native_failed' });
|
||||
}
|
||||
}
|
||||
});
|
||||
child.once('error', () => resolve({ error: 'native_failed', diagnostic: { stage: 'member_start' } }));
|
||||
child.once('close', (code, signal) => resolve({ error: 'native_failed', diagnostic: parseNativeCookieDiagnostic({ stage: 'member_exit', exitCode: code, signal, lastStage, memberMode, nodeExitCode, stderrBytes }) }));
|
||||
child.stdin.on('error', () => resolve({ error: 'native_failed', diagnostic: { stage: 'member_input' } }));
|
||||
child.stdin.end(JSON.stringify({ request, jobName }));
|
||||
});
|
||||
return { result, closed, stop: () => { if (child.exitCode === null && child.signalCode === null) child.kill(); } };
|
||||
}
|
||||
|
||||
export async function probeNativeCookieMember(): Promise<NativeCookieReply> {
|
||||
return superviseNativeCookieImport({
|
||||
nodeExecutable: '', nodeArchitecture: process.arch, playwrightEntry: '', executablePath: '',
|
||||
userDataDir: '', profile: '', domains: [], deadline: Date.now() + 5_000, qualifiedBunVersions: [Bun.version],
|
||||
}, { startMember: (request, jobName) => startMember(request, jobName, '--member-smoke') });
|
||||
}
|
||||
|
||||
let mainStage: NativeCookieDiagnostic['stage'] = 'supervisor_input';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.argv[2] === '--member' || process.argv[2] === '--member-smoke') {
|
||||
mainStage = 'member_input';
|
||||
nativeProgress({ stage: mainStage });
|
||||
const serialized = await new Promise<string>((resolve, reject) => {
|
||||
let payload = '';
|
||||
let bytes = 0;
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
bytes += Buffer.byteLength(chunk);
|
||||
if (bytes > 1024 * 1024) {
|
||||
reject(new Error('native_supervision_failed'));
|
||||
process.stdin.destroy();
|
||||
return;
|
||||
}
|
||||
payload += chunk;
|
||||
});
|
||||
process.stdin.once('end', () => resolve(payload));
|
||||
process.stdin.once('error', () => reject(new Error('native_supervision_failed')));
|
||||
process.stdin.once('close', () => reject(new Error('native_supervision_failed')));
|
||||
process.stdin.resume();
|
||||
});
|
||||
const input = JSON.parse(serialized);
|
||||
nativeProgress({ stage: 'member_decoded' });
|
||||
await joinNativeCookieJob(input.jobName, stage => nativeProgress({ stage }));
|
||||
nativeProgress({ stage: 'job_joined' });
|
||||
if (process.argv[2] === '--member-smoke') {
|
||||
process.stdout.write(JSON.stringify({ cookies: [] }) + '\n', () => process.exit(0));
|
||||
return;
|
||||
}
|
||||
mainStage = 'node_start';
|
||||
nativeProgress({ stage: mainStage });
|
||||
const child = spawn(input.request.nodeExecutable, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
|
||||
env: nativeCookieEnvironment(process.env),
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
windowsHide: true,
|
||||
});
|
||||
nativeProgress({ stage: 'node_spawned' });
|
||||
child.stdin.on('error', () => {});
|
||||
child.stdin.end(JSON.stringify(input.request));
|
||||
child.once('error', () => process.stdout.write(JSON.stringify({ error: 'native_failed', diagnostic: { stage: 'node_start' } }) + '\n', () => process.exit(1)));
|
||||
child.once('close', (code, signal) => {
|
||||
nativeProgress(parseNativeCookieDiagnostic({ stage: 'node_exit', exitCode: code, signal })!);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cancellation = new AbortController();
|
||||
const lines = createInterface({ input: process.stdin });
|
||||
lines.once('close', () => cancellation.abort());
|
||||
const input = await new Promise<NativeCookieRequest>((resolve, reject) => {
|
||||
lines.once('line', line => {
|
||||
try { resolve(JSON.parse(line)); } catch { reject(new Error('native_supervision_failed')); }
|
||||
});
|
||||
lines.once('close', () => reject(new Error('native_supervision_failed')));
|
||||
});
|
||||
mainStage = 'runtime_check';
|
||||
if (input.nodeArchitecture !== process.arch || !Array.isArray(input.qualifiedBunVersions) || !input.qualifiedBunVersions.includes(Bun.version)) {
|
||||
throw new Error('native_supervision_failed');
|
||||
}
|
||||
const result = await superviseNativeCookieImport(input, { signal: cancellation.signal });
|
||||
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(0));
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
nativeProgress({ stage: 'worker_boot', memberMode: process.argv[2] === '--member' || process.argv[2] === '--member-smoke' });
|
||||
void main().catch(error => {
|
||||
process.stdout.write(JSON.stringify({ error: 'native_supervision_failed', diagnostic: nativeCookieDiagnostic(error, mainStage) }) + '\n', () => process.exit(1));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { realpathSync, statSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { release } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { CookieImportError, normalizeCookieDomain, type PlaywrightCookie } from './cookie-import-browser';
|
||||
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, readNativeQualifications, type NativeQualifiedBuild } from './cookie-import-native-integrity';
|
||||
|
||||
type BrowserName = 'Chrome' | 'Chromium' | 'Brave' | 'Edge';
|
||||
|
||||
export function nativeBrowserPaths(browserName: string, env: NodeJS.ProcessEnv): {
|
||||
name: BrowserName;
|
||||
userDataDir: string;
|
||||
executables: string[];
|
||||
} {
|
||||
const get = (key: string) => Object.entries(env).find(([name]) => name.toLowerCase() === key.toLowerCase())?.[1];
|
||||
const local = get('LOCALAPPDATA');
|
||||
if (!local || !path.win32.isAbsolute(local)) {
|
||||
throw new CookieImportError('The Windows browser data location is unavailable. Sign in manually in the gstack browser.', 'native_profile_unsupported');
|
||||
}
|
||||
const pf = get('PROGRAMFILES') || 'C:\\Program Files';
|
||||
const pf86 = get('PROGRAMFILES(X86)') || 'C:\\Program Files (x86)';
|
||||
const mappings = {
|
||||
chrome: { name: 'Chrome', root: ['Google', 'Chrome'], exe: 'chrome.exe', installs: [pf, pf86, local] },
|
||||
chromium: { name: 'Chromium', root: ['Chromium'], exe: 'chrome.exe', installs: [local] },
|
||||
brave: { name: 'Brave', root: ['BraveSoftware', 'Brave-Browser'], exe: 'brave.exe', installs: [pf, pf86, local] },
|
||||
edge: { name: 'Edge', root: ['Microsoft', 'Edge'], exe: 'msedge.exe', installs: [pf86, pf, local] },
|
||||
} as const;
|
||||
const key = browserName.toLowerCase();
|
||||
if (!Object.hasOwn(mappings, key)) {
|
||||
throw new CookieImportError('This browser has no supported Windows native-cookie mapping. Sign in manually in the gstack browser.', 'not_supported');
|
||||
}
|
||||
const browser = mappings[key as keyof typeof mappings];
|
||||
return {
|
||||
name: browser.name,
|
||||
userDataDir: path.win32.join(local, ...browser.root, 'User Data'),
|
||||
executables: [...new Set(browser.installs.map(root => path.win32.join(root, ...browser.root, 'Application', browser.exe)))],
|
||||
};
|
||||
}
|
||||
|
||||
export async function importNativeCookies(options: {
|
||||
browserName: string;
|
||||
userDataDir: string;
|
||||
profile: string;
|
||||
domains: string[];
|
||||
}): Promise<PlaywrightCookie[]> {
|
||||
let domains: string[];
|
||||
try {
|
||||
if (!Array.isArray(options.domains)) throw new Error();
|
||||
domains = [...new Set(options.domains.map(normalizeCookieDomain))];
|
||||
} catch {
|
||||
throw new CookieImportError('Native cookie import needs explicit valid domain selections.', 'invalid_domain');
|
||||
}
|
||||
if (!domains.length) return [];
|
||||
if (process.platform !== 'win32' || process.versions.bun) {
|
||||
throw new CookieImportError('Native cookie import requires the Windows Node server. Sign in manually in the gstack browser.', 'not_supported');
|
||||
}
|
||||
if (!/^(Default|Profile [0-9]+)$/.test(options.profile)) {
|
||||
throw new CookieImportError('Select an existing browser profile before importing cookies.', 'invalid_profile');
|
||||
}
|
||||
const browser = nativeBrowserPaths(options.browserName, process.env);
|
||||
if (path.win32.resolve(options.userDataDir).toLowerCase() !== path.win32.resolve(browser.userDataDir).toLowerCase()) {
|
||||
throw new CookieImportError('Native cookie import cannot substitute or copy the selected browser profile. Sign in manually in the gstack browser.', 'native_profile_unsupported');
|
||||
}
|
||||
if (browser.name === 'Chrome') {
|
||||
throw new CookieImportError('Chrome 136 and later block remote debugging of the default user-data directory, including every profile inside it, over both pipe and TCP. Default-directory extraction is unavailable; sign in manually in the gstack browser.', 'native_profile_unsupported');
|
||||
}
|
||||
const deadline = Date.now() + 25_000;
|
||||
let qualified: NativeQualifiedBuild[];
|
||||
try {
|
||||
const root = path.resolve(import.meta.dir, '../..');
|
||||
const builds = await readNativeQualifications(root, deadline);
|
||||
qualified = builds.filter(build => build.browserName === browser.name && build.nodeVersion === process.version && build.architecture === process.arch && build.windowsRelease === release());
|
||||
if (qualified.length) {
|
||||
const hashes = await nativeCodeHashes(root, deadline);
|
||||
qualified = qualified.filter(build => nativeCodeMatches(build.sourceHashes, hashes));
|
||||
}
|
||||
} catch (error) {
|
||||
throw new CookieImportError('The native-cookie qualification inputs could not be verified. Sign in manually in the gstack browser.', error instanceof Error && error.message === 'native_timeout' ? 'native_timeout' : 'native_unqualified');
|
||||
}
|
||||
if (!qualified.length) {
|
||||
throw new CookieImportError('Windows native cookie extraction is disabled until this browser and runtime pass native process-ownership and forced-cleanup qualification. Sign in manually in the gstack browser.', 'native_unqualified');
|
||||
}
|
||||
const executablePath = browser.executables.find(candidate => {
|
||||
try { return statSync(candidate).isFile(); } catch { return false; }
|
||||
});
|
||||
if (!executablePath) throw new CookieImportError('The selected browser executable is not installed.', 'not_installed');
|
||||
try {
|
||||
const profilePath = path.join(options.userDataDir, options.profile);
|
||||
if (realpathSync(options.userDataDir).toLowerCase() !== path.win32.resolve(options.userDataDir).toLowerCase()
|
||||
|| realpathSync(profilePath).toLowerCase() !== path.win32.resolve(profilePath).toLowerCase()
|
||||
|| !statSync(profilePath).isDirectory()) {
|
||||
throw new Error('invalid_profile');
|
||||
}
|
||||
} catch {
|
||||
throw new CookieImportError('The selected source profile is unavailable or redirects to another location.', 'native_profile_unsupported');
|
||||
}
|
||||
const require = createRequire(import.meta.url);
|
||||
let playwrightVersion: string;
|
||||
let playwrightEntry: string;
|
||||
let executableSha256: string;
|
||||
try {
|
||||
playwrightVersion = require('playwright/package.json').version;
|
||||
playwrightEntry = require.resolve('playwright');
|
||||
executableSha256 = await hashNativeFile(executablePath, deadline);
|
||||
} catch (error) {
|
||||
throw new CookieImportError('The native browser build could not be checked safely. Sign in manually in the gstack browser.', error instanceof Error && error.message === 'native_timeout' ? 'native_timeout' : 'native_unqualified');
|
||||
}
|
||||
const bunCandidates = [
|
||||
...(process.env.BUN_INSTALL ? [path.join(process.env.BUN_INSTALL, 'bin', 'bun.exe')] : []),
|
||||
...(process.env.PATH || process.env.Path || '').split(path.delimiter).filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, 'bun.exe')),
|
||||
...(process.env.USERPROFILE ? [path.join(process.env.USERPROFILE, '.bun', 'bin', 'bun.exe')] : []),
|
||||
];
|
||||
const bunExecutable = bunCandidates.find(candidate => {
|
||||
try { return statSync(candidate).isFile(); } catch { return false; }
|
||||
});
|
||||
if (!bunExecutable || !qualified.some(build => build.executableSha256 === executableSha256 && build.playwrightVersion === playwrightVersion)) {
|
||||
throw new CookieImportError('This browser build or supervisor runtime has not passed native qualification. Sign in manually in the gstack browser.', 'native_unqualified');
|
||||
}
|
||||
if (Date.now() >= deadline) throw new CookieImportError('Native cookie import exceeded its operation deadline.', 'native_timeout');
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => /^(systemroot|windir|temp|tmp|userprofile|localappdata|appdata|programfiles|programfiles\(x86\)|programdata|path|pathext)$/i.test(key) && typeof value === 'string'));
|
||||
const worker = spawn(bunExecutable, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.join(import.meta.dir, 'cookie-import-native-worker.ts')], {
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
const timer = setTimeout(() => {
|
||||
worker.kill();
|
||||
reject(new CookieImportError('Native cookie cleanup could not be confirmed within its deadline.', 'native_cleanup_failed'));
|
||||
}, Math.max(0, deadline + 5_000 - Date.now()));
|
||||
worker.stdout.setEncoding('utf8');
|
||||
worker.stdout.on('data', chunk => {
|
||||
output += chunk;
|
||||
if (Buffer.byteLength(output) > 8 * 1024 * 1024) worker.kill();
|
||||
});
|
||||
worker.once('error', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new CookieImportError('Native cookie supervision could not start.', 'native_supervision_failed'));
|
||||
});
|
||||
worker.once('close', () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const result = JSON.parse(output);
|
||||
if (Array.isArray(result.cookies)) resolve(result.cookies);
|
||||
else reject(new CookieImportError('Native cookie import did not complete. Sign in manually in the gstack browser.', ['native_timeout', 'native_failed', 'native_cleanup_failed', 'native_supervision_failed', 'browser_running', 'native_profile_unsupported'].includes(result.error) ? result.error : 'native_failed'));
|
||||
} catch {
|
||||
reject(new CookieImportError('Native cookie supervision did not return a complete result.', 'native_failed'));
|
||||
}
|
||||
});
|
||||
worker.stdin.on('error', () => {});
|
||||
worker.stdin.write(JSON.stringify({
|
||||
nodeExecutable: process.execPath,
|
||||
nodeArchitecture: process.arch,
|
||||
playwrightEntry,
|
||||
executablePath,
|
||||
userDataDir: options.userDataDir,
|
||||
profile: options.profile,
|
||||
domains,
|
||||
deadline,
|
||||
qualifiedBunVersions: qualified.filter(build => build.executableSha256 === executableSha256 && build.playwrightVersion === playwrightVersion).map(build => build.bunVersion),
|
||||
}) + '\n');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { Page } from 'playwright';
|
||||
import {
|
||||
CookieImportError, cookieDomainMatches, importCookies, importCookiesViaCdp,
|
||||
listDomains, listProfiles, normalizeCookieDomain, withCookieReadRetry,
|
||||
type ProfileEntry,
|
||||
} from './cookie-import-browser';
|
||||
import { clearCookieTargetStorage, validateCookieAuthOptions, validateCookieStorageSupport, verifyCookieAuthentication, type CookieAuthVerificationOptions } from './cookie-auth-verification';
|
||||
|
||||
export interface CookieImportTarget {
|
||||
page: Page;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CookieImportOptions {
|
||||
browser: string;
|
||||
domains?: string[];
|
||||
profile?: string;
|
||||
all?: boolean;
|
||||
clearStorage?: boolean;
|
||||
verifyAuth?: boolean;
|
||||
}
|
||||
|
||||
const activeImports = new WeakSet<object>();
|
||||
|
||||
export function parseCookieImportArgs(args: string[]): CookieImportOptions {
|
||||
const options: CookieImportOptions = { browser: 'comet' };
|
||||
let browserSet = false;
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (!arg.startsWith('--')) {
|
||||
if (browserSet) throw new CookieImportError('Specify only one source browser.', 'bad_request');
|
||||
options.browser = arg;
|
||||
browserSet = true;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(arg)) throw new CookieImportError('Duplicate cookie-import option.', 'bad_request');
|
||||
seen.add(arg);
|
||||
if (arg === '--domain' || arg === '--profile') {
|
||||
const value = args[++i];
|
||||
if (!value || value.startsWith('--')) throw new CookieImportError('Cookie-import option requires a value.', 'bad_request');
|
||||
if (arg === '--domain') options.domains = [normalizeCookieDomain(value)];
|
||||
else options.profile = value;
|
||||
} else if (arg === '--all') options.all = true;
|
||||
else if (arg === '--clear-storage') options.clearStorage = true;
|
||||
else if (arg === '--verify-auth') options.verifyAuth = true;
|
||||
else throw new CookieImportError('Unknown cookie-import option.', 'bad_request');
|
||||
}
|
||||
if (options.all && options.domains) throw new CookieImportError('Choose --domain or --all, not both.', 'bad_request');
|
||||
if (options.all && options.clearStorage) throw new CookieImportError('Storage reset requires a single target origin; --all is not supported.', 'bad_request');
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function getCookieProfiles(browser: string, domains: string[] = [], hostname?: string) {
|
||||
const profiles: Array<ProfileEntry & { matches?: boolean; unavailable?: boolean }> = listProfiles(browser);
|
||||
if (domains.length || hostname) {
|
||||
const selected = domains.map(normalizeCookieDomain);
|
||||
let index = 0;
|
||||
const check = async () => {
|
||||
while (index < profiles.length) {
|
||||
const profile = profiles[index++];
|
||||
try {
|
||||
const result = await withCookieReadRetry(() => listDomains(browser, profile.name));
|
||||
profile.matches = result.domains.some(entry => selected.length
|
||||
? selected.includes(normalizeCookieDomain(entry.domain))
|
||||
: cookieDomainMatches(hostname!, entry.domain));
|
||||
} catch (err) {
|
||||
if (err instanceof CookieImportError && err.code === 'sqlite_unavailable') throw err;
|
||||
profile.unavailable = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all([check(), check()]);
|
||||
}
|
||||
const matching = profiles.filter(profile => profile.matches === true);
|
||||
const recommendedProfile = !profiles.some(profile => profile.unavailable) && matching.length === 1
|
||||
? matching[0].name : profiles.length === 1 && profiles[0].matches !== false && !profiles[0].unavailable ? profiles[0].name : undefined;
|
||||
return { profiles, recommendedProfile };
|
||||
}
|
||||
|
||||
export function validateCookieTarget(target: CookieImportTarget): URL {
|
||||
try {
|
||||
const url = new URL(target.url);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
|
||||
if (target.page.isClosed() || target.page.url() !== target.url) throw new Error();
|
||||
return url;
|
||||
} catch {
|
||||
throw new CookieImportError('The captured HTTP(S) target has changed or is unavailable. Reopen the picker on the intended page.', 'target_changed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCookieImport(
|
||||
options: CookieImportOptions,
|
||||
target: CookieImportTarget,
|
||||
trackDomains: (domains: string[]) => void,
|
||||
authOptions: CookieAuthVerificationOptions = {},
|
||||
) {
|
||||
for (const value of [options.all, options.clearStorage, options.verifyAuth]) {
|
||||
if (value !== undefined && typeof value !== 'boolean') throw new CookieImportError('Cookie import options must be booleans.', 'bad_request');
|
||||
}
|
||||
if (typeof options.browser !== 'string' || !options.browser.trim()) throw new CookieImportError('Select a source browser.', 'bad_request');
|
||||
if (options.profile !== undefined && (typeof options.profile !== 'string' || !options.profile)) throw new CookieImportError('Invalid source profile.', 'bad_request');
|
||||
if (options.all && options.domains || options.all && options.clearStorage) throw new CookieImportError('All-domain import cannot be combined with a scoped domain or storage reset.', 'bad_request');
|
||||
if (!options.all && (!Array.isArray(options.domains) || !options.domains.length)) throw new CookieImportError('Select at least one cookie domain.', 'bad_request');
|
||||
const selected = options.domains?.map(normalizeCookieDomain) ?? [];
|
||||
const needsTarget = options.clearStorage || options.verifyAuth;
|
||||
const targetUrl = needsTarget ? validateCookieTarget(target) : undefined;
|
||||
if (options.clearStorage) validateCookieStorageSupport(target.page);
|
||||
if (options.verifyAuth) validateCookieAuthOptions(authOptions);
|
||||
if (targetUrl && selected.length && !selected.some(domain => cookieDomainMatches(targetUrl.hostname, '.' + domain))) {
|
||||
throw new CookieImportError('The selected cookies do not match the captured target origin.', 'target_mismatch');
|
||||
}
|
||||
const context = target.page.context();
|
||||
if (target.page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed');
|
||||
if (activeImports.has(context)) throw new CookieImportError('Another cookie import is still running. Wait before retrying.', 'import_busy', 'retry');
|
||||
activeImports.add(context);
|
||||
try {
|
||||
let profile = options.profile;
|
||||
if (!profile) {
|
||||
const suggestion = await getCookieProfiles(options.browser, selected);
|
||||
profile = suggestion.recommendedProfile;
|
||||
if (!profile) throw new CookieImportError('Choose a source profile explicitly; matching profiles are ambiguous, unavailable, or empty.', 'profile_required');
|
||||
}
|
||||
const domains = options.all
|
||||
? (await withCookieReadRetry(() => listDomains(options.browser, profile!))).domains.map(entry => entry.domain)
|
||||
: selected;
|
||||
let result = await withCookieReadRetry(() => importCookies(options.browser, domains, profile));
|
||||
if (result.count === 0 && result.failureReasons?.unsupported_encryption && process.platform === 'win32') {
|
||||
const failed = result.failed;
|
||||
result = await importCookiesViaCdp(options.browser, domains, profile);
|
||||
result.failed = Math.max(result.failed, failed - result.count);
|
||||
if (result.failed) result.failureReasons = { native_unrecovered: result.failed };
|
||||
}
|
||||
const receipt = {
|
||||
browser: options.browser,
|
||||
profile,
|
||||
imported: 0,
|
||||
failed: result.failed,
|
||||
domainCounts: {} as Record<string, number>,
|
||||
failureReasons: result.failureReasons ?? {},
|
||||
outcome: (result.failed ? 'failed' : 'empty') as 'empty' | 'imported' | 'partial' | 'failed',
|
||||
reset: 'not_requested' as 'not_requested' | 'cleared' | 'failed',
|
||||
verification: { verified: false, reason: 'not_requested' } as { verified: boolean; reason: string; status?: number },
|
||||
message: result.failed ? 'No cookies imported; cookies could not be decrypted.' : 'No matching cookies found.',
|
||||
};
|
||||
if (!result.count) {
|
||||
if (options.verifyAuth) receipt.verification.reason = 'no_cookies_imported';
|
||||
return receipt;
|
||||
}
|
||||
if (targetUrl && !result.cookies.some(cookie => cookieDomainMatches(targetUrl.hostname, cookie.domain))) {
|
||||
throw new CookieImportError('No imported cookies apply to the captured target origin.', 'target_mismatch');
|
||||
}
|
||||
if (target.page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed');
|
||||
if (needsTarget) validateCookieTarget(target);
|
||||
if (options.clearStorage) {
|
||||
try {
|
||||
await clearCookieTargetStorage(target.page, targetUrl!.origin);
|
||||
receipt.reset = 'cleared';
|
||||
} catch {
|
||||
receipt.outcome = 'failed';
|
||||
receipt.reset = 'failed';
|
||||
receipt.message = 'Storage reset did not complete; storage may be partially cleared. No new cookies were applied.';
|
||||
receipt.verification.reason = 'reset_failed';
|
||||
return receipt;
|
||||
}
|
||||
}
|
||||
const appliedDomains = [...new Set(result.cookies.map(cookie => cookie.domain))];
|
||||
try {
|
||||
await context.addCookies(result.cookies);
|
||||
} catch {
|
||||
trackDomains(appliedDomains);
|
||||
receipt.outcome = 'failed';
|
||||
receipt.message = 'Cookie application failed; the browser may contain a partial import. Authentication was not verified.';
|
||||
receipt.verification.reason = 'application_failed';
|
||||
return receipt;
|
||||
}
|
||||
trackDomains(appliedDomains);
|
||||
receipt.imported = result.count;
|
||||
receipt.domainCounts = result.domainCounts;
|
||||
receipt.outcome = result.failed ? 'partial' : 'imported';
|
||||
receipt.message = result.failed ? 'Some cookies could not be decrypted.' : 'Cookie copy complete.';
|
||||
if (options.verifyAuth) {
|
||||
try {
|
||||
validateCookieTarget(target);
|
||||
receipt.verification = await verifyCookieAuthentication(target.page, authOptions, targetUrl!.origin);
|
||||
} catch {
|
||||
receipt.verification = { verified: false, reason: 'target_changed' };
|
||||
}
|
||||
}
|
||||
return receipt;
|
||||
} finally {
|
||||
activeImports.delete(context);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCookieImportResult(result: Awaited<ReturnType<typeof runCookieImport>>): string {
|
||||
return `Imported ${result.imported} cookies from ${result.browser} (profile: ${result.profile}); ${result.failed} failed to decrypt. ${result.message} Storage reset: ${result.reset}. Authentication: ${result.verification.reason}.`;
|
||||
}
|
||||
@@ -19,24 +19,34 @@
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, importCookiesViaCdp, hasV20Cookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
|
||||
import { findInstalledBrowsers, listDomains, withCookieReadRetry, CookieImportError } from './cookie-import-browser';
|
||||
import { getCookiePickerHTML } from './cookie-picker-ui';
|
||||
import { getCookieProfiles, runCookieImport, type CookieImportTarget } from './cookie-import-operation';
|
||||
import { validateCookieStorageSupport } from './cookie-auth-verification';
|
||||
|
||||
// ─── Auth State ─────────────────────────────────────────────────
|
||||
// One-time codes for the cookie picker UI (code → expiry timestamp).
|
||||
// Codes are generated by generatePickerCode() and consumed on first use.
|
||||
const pendingCodes = new Map<string, number>();
|
||||
const CODE_TTL_MS = 30_000; // 30 seconds
|
||||
interface PickerContext {
|
||||
target?: CookieImportTarget;
|
||||
browser?: string;
|
||||
profile?: string;
|
||||
clearStorage?: boolean;
|
||||
verifyAuth?: boolean;
|
||||
}
|
||||
|
||||
const pendingCodes = new Map<string, PickerContext & { expiry: number }>();
|
||||
const CODE_TTL_MS = 5 * 60_000;
|
||||
|
||||
// Session cookies for authenticated picker access (session → expiry timestamp).
|
||||
// Sessions are created after a valid code exchange and last 1 hour.
|
||||
const validSessions = new Map<string, number>();
|
||||
const validSessions = new Map<string, PickerContext & { expiry: number; pickerInstance: string }>();
|
||||
const SESSION_TTL_MS = 3_600_000; // 1 hour
|
||||
|
||||
/** Generate a one-time code for opening the cookie picker UI. */
|
||||
export function generatePickerCode(): string {
|
||||
export function generatePickerCode(context: PickerContext = {}): string {
|
||||
const code = crypto.randomUUID();
|
||||
pendingCodes.set(code, Date.now() + CODE_TTL_MS);
|
||||
pendingCodes.set(code, { ...context, expiry: Date.now() + CODE_TTL_MS });
|
||||
return code;
|
||||
}
|
||||
|
||||
@@ -44,13 +54,13 @@ export function generatePickerCode(): string {
|
||||
export function hasActivePicker(): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [code, expiry] of pendingCodes) {
|
||||
if (expiry > now) return true;
|
||||
for (const [code, context] of pendingCodes) {
|
||||
if (context.expiry > now) return true;
|
||||
pendingCodes.delete(code);
|
||||
}
|
||||
|
||||
for (const [session, expiry] of validSessions) {
|
||||
if (expiry > now) return true;
|
||||
for (const [session, context] of validSessions) {
|
||||
if (context.expiry > now) return true;
|
||||
validSessions.delete(session);
|
||||
}
|
||||
|
||||
@@ -67,9 +77,9 @@ function getSessionFromCookie(req: Request): string | null {
|
||||
|
||||
/** Check if a session cookie value is valid and not expired. */
|
||||
function isValidSession(session: string): boolean {
|
||||
const expiry = validSessions.get(session);
|
||||
if (!expiry) return false;
|
||||
if (Date.now() > expiry) { validSessions.delete(session); return false; }
|
||||
const context = validSessions.get(session);
|
||||
if (!context) return false;
|
||||
if (Date.now() >= context.expiry) { validSessions.delete(session); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -121,7 +131,7 @@ export async function handleCookiePickerRoute(
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': corsOrigin(port),
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Gstack-Picker-Instance',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -133,8 +143,8 @@ export async function handleCookiePickerRoute(
|
||||
|
||||
// Code exchange: validate one-time code, set session cookie, redirect
|
||||
if (code) {
|
||||
const expiry = pendingCodes.get(code);
|
||||
if (!expiry || Date.now() > expiry) {
|
||||
const context = pendingCodes.get(code);
|
||||
if (!context || Date.now() >= context.expiry) {
|
||||
pendingCodes.delete(code);
|
||||
return new Response('Invalid or expired code. Re-run cookie-import-browser.', {
|
||||
status: 403,
|
||||
@@ -143,7 +153,7 @@ export async function handleCookiePickerRoute(
|
||||
}
|
||||
pendingCodes.delete(code); // one-time use
|
||||
const session = crypto.randomUUID();
|
||||
validSessions.set(session, Date.now() + SESSION_TTL_MS);
|
||||
validSessions.set(session, { ...context, expiry: Date.now() + SESSION_TTL_MS, pickerInstance: crypto.randomUUID() });
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
@@ -157,7 +167,26 @@ export async function handleCookiePickerRoute(
|
||||
// Session cookie: serve HTML (no auth token inlined)
|
||||
const session = getSessionFromCookie(req);
|
||||
if (session && isValidSession(session)) {
|
||||
const html = getCookiePickerHTML(port);
|
||||
const context = validSessions.get(session)!;
|
||||
let targetOrigin: string | undefined;
|
||||
let storageResetAvailable = false;
|
||||
try {
|
||||
const url = new URL(context.target?.url ?? '');
|
||||
if (['http:', 'https:'].includes(url.protocol)) targetOrigin = url.origin;
|
||||
} catch {}
|
||||
if (context.target) {
|
||||
try { validateCookieStorageSupport(context.target.page); storageResetAvailable = true; } catch {}
|
||||
}
|
||||
const html = getCookiePickerHTML(port, {
|
||||
pickerInstance: context.pickerInstance,
|
||||
browser: context.browser,
|
||||
profile: context.profile,
|
||||
clearStorage: context.clearStorage,
|
||||
verifyAuth: context.verifyAuth,
|
||||
targetOrigin,
|
||||
storageResetAvailable,
|
||||
verificationAvailable: !!process.env.GSTACK_COOKIE_AUTH_SELECTOR?.trim() && !!process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY?.trim(),
|
||||
});
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
@@ -182,6 +211,13 @@ export async function handleCookiePickerRoute(
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (req.method === 'POST' && !hasBearer && req.headers.get('origin') !== url.origin) {
|
||||
return errorResponse('Cookie picker mutations require a same-origin request.', 'invalid_origin', { port, status: 403 });
|
||||
}
|
||||
const pickerContext = !hasBearer && hasSession ? validSessions.get(sessionId!)! : undefined;
|
||||
if (pickerContext && req.headers.get('X-Gstack-Picker-Instance') !== pickerContext.pickerInstance) {
|
||||
return errorResponse('This picker no longer matches the active picker session. Reopen the picker from the intended page before continuing.', 'picker_changed', { port, status: 403 });
|
||||
}
|
||||
|
||||
// GET /cookie-picker/browsers — list installed browsers
|
||||
if (pathname === '/cookie-picker/browsers' && req.method === 'GET') {
|
||||
@@ -200,8 +236,9 @@ export async function handleCookiePickerRoute(
|
||||
if (!browserName) {
|
||||
return errorResponse("Missing 'browser' parameter", 'missing_param', { port });
|
||||
}
|
||||
const profiles = listProfiles(browserName);
|
||||
return jsonResponse({ profiles }, { port });
|
||||
let hostname: string | undefined;
|
||||
try { hostname = new URL(pickerContext?.target?.url ?? '').hostname || undefined; } catch {}
|
||||
return jsonResponse(await getCookieProfiles(browserName, [], hostname), { port });
|
||||
}
|
||||
|
||||
// GET /cookie-picker/domains?browser=<name>&profile=<profile> — list domains + counts
|
||||
@@ -211,7 +248,7 @@ export async function handleCookiePickerRoute(
|
||||
return errorResponse("Missing 'browser' parameter", 'missing_param', { port });
|
||||
}
|
||||
const profile = url.searchParams.get('profile') || 'Default';
|
||||
const result = listDomains(browserName, profile);
|
||||
const result = await withCookieReadRetry(() => listDomains(browserName, profile));
|
||||
return jsonResponse({
|
||||
browser: result.browser,
|
||||
domains: result.domains,
|
||||
@@ -227,61 +264,27 @@ export async function handleCookiePickerRoute(
|
||||
return errorResponse('Invalid JSON body', 'bad_request', { port });
|
||||
}
|
||||
|
||||
const { browser, domains, profile } = body;
|
||||
const { browser, domains, profile, clearStorage, verifyAuth } = body ?? {};
|
||||
if (!browser) return errorResponse("Missing 'browser' field", 'missing_param', { port });
|
||||
if (!domains || !Array.isArray(domains) || domains.length === 0) {
|
||||
return errorResponse("Missing or empty 'domains' array", 'missing_param', { port });
|
||||
}
|
||||
|
||||
// Decrypt cookies from the browser DB
|
||||
const selectedProfile = profile || 'Default';
|
||||
let result = await importCookies(browser, domains, selectedProfile);
|
||||
|
||||
// If all cookies failed and v20 encryption is detected, try CDP extraction
|
||||
if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, selectedProfile)) {
|
||||
console.log(`[cookie-picker] v20 App-Bound Encryption detected, trying CDP extraction...`);
|
||||
try {
|
||||
result = await importCookiesViaCdp(browser, domains, selectedProfile);
|
||||
} catch (cdpErr: any) {
|
||||
console.log(`[cookie-picker] CDP fallback failed: ${cdpErr.message}`);
|
||||
return jsonResponse({
|
||||
imported: 0,
|
||||
failed: result.failed,
|
||||
domainCounts: {},
|
||||
message: `Cookies use App-Bound Encryption (v20). Close ${browser}, retry, or use /connect-chrome to browse with your real browser directly.`,
|
||||
code: 'v20_encryption',
|
||||
}, { port });
|
||||
}
|
||||
}
|
||||
|
||||
if (result.cookies.length === 0) {
|
||||
return jsonResponse({
|
||||
imported: 0,
|
||||
failed: result.failed,
|
||||
domainCounts: {},
|
||||
message: result.failed > 0
|
||||
? `All ${result.failed} cookies failed to decrypt`
|
||||
: 'No cookies found for the specified domains',
|
||||
}, { port });
|
||||
}
|
||||
|
||||
// Add to Playwright context
|
||||
const page = bm.getActiveSession().getPage();
|
||||
await page.context().addCookies(result.cookies);
|
||||
|
||||
// Track what was imported
|
||||
const page = pickerContext?.target?.page ?? bm.getActiveSession().getPage();
|
||||
const target = pickerContext?.target ?? { page, url: page.url() };
|
||||
const result = await runCookieImport({
|
||||
browser, domains, profile,
|
||||
clearStorage: clearStorage ?? pickerContext?.clearStorage ?? false,
|
||||
verifyAuth: verifyAuth ?? pickerContext?.verifyAuth ?? false,
|
||||
}, target, domains => bm.trackCookieImportDomains(domains), {
|
||||
identitySelector: process.env.GSTACK_COOKIE_AUTH_SELECTOR,
|
||||
expectedIdentity: process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY,
|
||||
});
|
||||
for (const domain of Object.keys(result.domainCounts)) {
|
||||
importedDomains.add(domain);
|
||||
importedCounts.set(domain, (importedCounts.get(domain) || 0) + result.domainCounts[domain]);
|
||||
importedCounts.set(domain, result.domainCounts[domain]);
|
||||
}
|
||||
|
||||
console.log(`[cookie-picker] Imported ${result.count} cookies for ${Object.keys(result.domainCounts).length} domains`);
|
||||
|
||||
return jsonResponse({
|
||||
imported: result.count,
|
||||
failed: result.failed,
|
||||
domainCounts: result.domainCounts,
|
||||
}, { port });
|
||||
return jsonResponse(result, { port });
|
||||
}
|
||||
|
||||
// POST /cookie-picker/remove — clear cookies for domains
|
||||
@@ -334,7 +337,7 @@ export async function handleCookiePickerRoute(
|
||||
if (err instanceof CookieImportError) {
|
||||
return errorResponse(err.message, err.code, { port, status: 400, action: err.action });
|
||||
}
|
||||
console.error(`[cookie-picker] Error: ${err.message}`);
|
||||
return errorResponse(err.message || 'Internal error', 'internal_error', { port, status: 500 });
|
||||
console.error('[cookie-picker] Operation failed');
|
||||
return errorResponse('Cookie picker operation failed. Retry or reopen the picker.', 'internal_error', { port, status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
+297
-276
@@ -7,8 +7,32 @@
|
||||
* No cookie values exposed anywhere.
|
||||
*/
|
||||
|
||||
export function getCookiePickerHTML(serverPort: number): string {
|
||||
export function getCookiePickerHTML(serverPort: number, options: {
|
||||
pickerInstance?: string;
|
||||
browser?: string;
|
||||
profile?: string;
|
||||
targetOrigin?: string;
|
||||
verifyAuth?: boolean;
|
||||
clearStorage?: boolean;
|
||||
verificationAvailable?: boolean;
|
||||
storageResetAvailable?: boolean;
|
||||
} = {}): string {
|
||||
const baseUrl = `http://127.0.0.1:${serverPort}`;
|
||||
let targetOrigin: string | undefined;
|
||||
try {
|
||||
const target = new URL(options.targetOrigin ?? '');
|
||||
if (['http:', 'https:'].includes(target.protocol)) targetOrigin = target.origin;
|
||||
} catch {}
|
||||
const config = JSON.stringify({
|
||||
pickerInstance: options.pickerInstance,
|
||||
browser: options.browser,
|
||||
profile: options.profile,
|
||||
targetOrigin,
|
||||
verifyAuth: options.verifyAuth === true,
|
||||
clearStorage: options.clearStorage === true,
|
||||
verificationAvailable: options.verificationAvailable === true,
|
||||
storageResetAvailable: options.storageResetAvailable !== false,
|
||||
}).replace(/[<>&\u2028\u2029]/g, character => '\\u' + character.charCodeAt(0).toString(16).padStart(4, '0'));
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -24,6 +48,8 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
color: #e0e0e0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Header ──────────────────────────── */
|
||||
@@ -58,7 +84,8 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
/* ─── Layout ──────────────────────────── */
|
||||
.container {
|
||||
display: flex;
|
||||
height: calc(100vh - 53px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.panel {
|
||||
flex: 1;
|
||||
@@ -255,7 +282,7 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
.banner {
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -269,6 +296,26 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
border-bottom: 1px solid #112233;
|
||||
color: #60a5fa;
|
||||
}
|
||||
.banner.warning {
|
||||
background: #241b0a;
|
||||
border-bottom: 1px solid #49330d;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.target-options {
|
||||
border: 0;
|
||||
border-bottom: 1px solid #222;
|
||||
padding: 10px 24px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.target-options legend { padding-top: 10px; color: #ccc; }
|
||||
.target-options label { display: flex; align-items: flex-start; gap: 8px; line-height: 1.5; }
|
||||
.target-options input { margin-top: 3px; accent-color: #60a5fa; }
|
||||
.target-options small { color: #888; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid #60a5fa; outline-offset: 3px; }
|
||||
.banner .banner-text { flex: 1; }
|
||||
.banner .banner-close, .banner .banner-retry {
|
||||
background: none;
|
||||
@@ -309,9 +356,14 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
<span class="port">localhost:${serverPort}</span>
|
||||
</div>
|
||||
|
||||
<p class="subtitle">Select the domains of cookies you want to import to GStack Browser. You'll be able to browse those sites with the same login as your other browser.</p>
|
||||
<p class="subtitle">Copy cookies from the browser and profile you choose to this GStack Browser session. Copying cookies does not prove that you are signed in. Cookies are shared by tabs in this session.</p>
|
||||
|
||||
<div id="banner" class="banner"></div>
|
||||
<fieldset class="target-options">
|
||||
<legend>Captured target: <span id="target-origin">No HTTP(S) target bound</span></legend>
|
||||
<label><input id="clear-storage" type="checkbox"><span>Clear storage for this target origin before importing.<br><small>Chromium targets only. Clears origin localStorage (shared across tabs) and this target tab's sessionStorage only. Other origins and other tabs' sessionStorage are preserved.</small></span></label>
|
||||
<label><input id="verify-auth" type="checkbox"><span>Reload the captured target and verify the configured account identity.<br><small id="verification-help">Requires an explicitly configured identity assertion and an HTTP(S) target.</small></span></label>
|
||||
</fieldset>
|
||||
<div id="banner" class="banner info" role="status" aria-live="polite" aria-atomic="true">Choose a browser and profile to inspect cookie domains.</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Left Panel: Source Browser -->
|
||||
@@ -320,7 +372,7 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
<div id="browser-pills" class="browser-pills"></div>
|
||||
<div id="profile-pills" class="profile-pills" style="display:none"></div>
|
||||
<div class="search-wrap">
|
||||
<input type="text" class="search-input" id="search" placeholder="Search domains..." />
|
||||
<input type="text" class="search-input" id="search" placeholder="Search domains..." aria-label="Search cookie domains" />
|
||||
</div>
|
||||
<div class="domain-list" id="source-domains">
|
||||
<div class="loading-row"><span class="spinner"></span> Detecting browsers...</div>
|
||||
@@ -338,15 +390,42 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="picker-config" type="application/json">${config}</script>
|
||||
<script>
|
||||
(function() {
|
||||
const BASE = '${baseUrl}';
|
||||
const config = JSON.parse(document.getElementById('picker-config').textContent);
|
||||
let activeBrowser = null;
|
||||
let activeProfile = 'Default';
|
||||
let configuredBrowser = null;
|
||||
let activeProfile = null;
|
||||
let allProfiles = [];
|
||||
let allDomains = [];
|
||||
let importedSet = {}; // domain → count
|
||||
let inflight = {}; // domain → true (prevents double-click)
|
||||
let importedSet = Object.create(null);
|
||||
let generation = 0;
|
||||
let mutation = false;
|
||||
const errorMessages = {
|
||||
picker_changed: 'This picker is stale because another picker was opened. Reopen the picker from the intended page before continuing.',
|
||||
keychain_denied: 'Keychain access was denied. Allow access in the OS permission prompt or settings, then retry manually.',
|
||||
keychain_timeout: 'Credential lookup timed out. Check for a pending OS permission prompt, then retry manually.',
|
||||
keychain_error: 'Credential lookup failed. Check the OS credential store or sign in manually in GStack Browser.',
|
||||
db_locked: 'The source cookie database is busy. Close the source browser, then retry manually.',
|
||||
db_corrupt: 'The source cookie database is invalid or corrupt. Choose another profile or sign in manually in GStack Browser.',
|
||||
db_permission: 'Cookie database access was denied. Check source-profile permissions, then retry manually.',
|
||||
db_read_error: 'Cookie data could not be read from this profile. Choose another profile or sign in manually in GStack Browser.',
|
||||
sqlite_unavailable: 'Cookie import needs Node.js 22.13 or newer with built-in SQLite enabled. Upgrade the runtime or sign in manually in GStack Browser.',
|
||||
storage_reset_unsupported: 'Storage reset requires a Chromium target. Import cookies without storage reset on other browsers.',
|
||||
profile_required: 'Choose a source profile explicitly; multiple or unavailable profiles cannot be selected automatically.',
|
||||
target_changed: 'The captured target changed or is unavailable. Reopen the picker from the intended HTTP(S) page.',
|
||||
target_closed: 'The captured target is closed. Reopen the picker from the intended HTTP(S) page.',
|
||||
target_mismatch: 'The selected cookies do not match the captured target. Select its cookie domain or reopen the picker from the intended page.',
|
||||
not_supported: 'Native cookie import is unsupported for this browser or runtime. Sign in manually in GStack Browser.',
|
||||
native_profile_unsupported: 'This browser profile does not support native cookie extraction. Sign in manually in GStack Browser.',
|
||||
native_unqualified: 'Native extraction is disabled because process ownership and cleanup are not qualified for this browser and runtime. Sign in manually in GStack Browser.',
|
||||
native_cleanup_failed: 'Native browser cleanup could not be confirmed. Inspect the source browser before any manual retry, or sign in manually in GStack Browser.',
|
||||
native_supervision_failed: 'Native browser supervision could not start. Sign in manually in GStack Browser.',
|
||||
native_timeout: 'Native cookie extraction timed out. Sign in manually in GStack Browser.',
|
||||
browser_running: 'The source browser is already running. Close it yourself before retrying, or sign in manually in GStack Browser.',
|
||||
};
|
||||
|
||||
const $pills = document.getElementById('browser-pills');
|
||||
const $profilePills = document.getElementById('profile-pills');
|
||||
@@ -357,337 +436,279 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
const $btnImportAll = document.getElementById('btn-import-all');
|
||||
const $importedFooter = document.getElementById('imported-footer');
|
||||
const $banner = document.getElementById('banner');
|
||||
|
||||
// ─── Banner ────────────────────────────
|
||||
function showBanner(msg, type, retryFn) {
|
||||
$banner.className = 'banner ' + type;
|
||||
$banner.style.display = 'flex';
|
||||
let html = '<span class="banner-text">' + escHtml(msg) + '</span>';
|
||||
if (retryFn) {
|
||||
html += '<button class="banner-retry" id="banner-retry">Retry</button>';
|
||||
}
|
||||
html += '<button class="banner-close" id="banner-close">×</button>';
|
||||
$banner.innerHTML = html;
|
||||
document.getElementById('banner-close').onclick = () => { $banner.style.display = 'none'; };
|
||||
if (retryFn) {
|
||||
document.getElementById('banner-retry').onclick = () => {
|
||||
$banner.style.display = 'none';
|
||||
retryFn();
|
||||
};
|
||||
}
|
||||
}
|
||||
const $clearStorage = document.getElementById('clear-storage');
|
||||
const $verifyAuth = document.getElementById('verify-auth');
|
||||
const canVerify = !!config.targetOrigin && config.verificationAvailable;
|
||||
const canReset = !!config.targetOrigin && config.storageResetAvailable;
|
||||
document.getElementById('target-origin').textContent = config.targetOrigin || 'No HTTP(S) target bound';
|
||||
$clearStorage.checked = canReset && config.clearStorage;
|
||||
$clearStorage.disabled = !canReset;
|
||||
$verifyAuth.checked = canVerify && config.verifyAuth;
|
||||
$verifyAuth.disabled = !canVerify;
|
||||
if (canVerify) document.getElementById('verification-help').textContent = 'Optional. Uses the server-configured exact identity assertion; the identity is never displayed here.';
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function errorMessage(error) {
|
||||
return error && Object.hasOwn(errorMessages, error.code) ? errorMessages[error.code]
|
||||
: 'Inspect the source and destination before retrying, or reopen the picker.';
|
||||
}
|
||||
|
||||
function showBanner(message, type, retry) {
|
||||
$banner.className = 'banner ' + type;
|
||||
$banner.innerHTML = '<span class="banner-text">' + escHtml(message) + '</span>';
|
||||
if (retry) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'banner-retry';
|
||||
button.textContent = 'Retry';
|
||||
button.disabled = mutation;
|
||||
button.onclick = () => { if (!mutation) retry(); };
|
||||
$banner.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API ────────────────────────────────
|
||||
async function api(path, opts) {
|
||||
const res = await fetch(BASE + '/cookie-picker' + path, { ...opts, credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || 'Request failed');
|
||||
err.code = data.code;
|
||||
err.action = data.action;
|
||||
throw err;
|
||||
const headers = new Headers(opts && opts.headers);
|
||||
headers.set('X-Gstack-Picker-Instance', config.pickerInstance || '');
|
||||
const response = await fetch(BASE + '/cookie-picker' + path, { ...opts, headers, credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = new Error('Cookie picker request failed.');
|
||||
if (data && typeof data.code === 'string' && Object.hasOwn(errorMessages, data.code)) error.code = data.code;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Init ───────────────────────────────
|
||||
async function init() {
|
||||
try {
|
||||
const [browserData, importedData] = await Promise.all([
|
||||
api('/browsers'),
|
||||
api('/imported'),
|
||||
]);
|
||||
|
||||
// Populate imported state
|
||||
for (const entry of importedData.domains) {
|
||||
importedSet[entry.domain] = entry.count;
|
||||
const [browserData, importedData] = await Promise.all([api('/browsers'), api('/imported')]);
|
||||
for (const entry of importedData.domains || []) {
|
||||
if (Number.isFinite(entry.count) && entry.count > 0) importedSet[entry.domain] = entry.count;
|
||||
}
|
||||
renderImported();
|
||||
|
||||
// Render browser pills
|
||||
const browsers = browserData.browsers;
|
||||
if (browsers.length === 0) {
|
||||
const browsers = browserData.browsers || [];
|
||||
$pills.innerHTML = '';
|
||||
for (const browser of browsers) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'pill';
|
||||
button.dataset.browser = browser.name;
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
button.innerHTML = '<span class="dot"></span>' + escHtml(browser.name);
|
||||
button.onclick = () => selectBrowser(browser.name);
|
||||
$pills.appendChild(button);
|
||||
}
|
||||
if (!browsers.length) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">No Chromium browsers detected</div>';
|
||||
showBanner('No supported source browsers were detected.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
$pills.innerHTML = '';
|
||||
browsers.forEach(b => {
|
||||
const pill = document.createElement('button');
|
||||
pill.className = 'pill';
|
||||
pill.innerHTML = '<span class="dot"></span>' + escHtml(b.name);
|
||||
pill.onclick = () => selectBrowser(b.name);
|
||||
$pills.appendChild(pill);
|
||||
});
|
||||
|
||||
// Auto-select first browser
|
||||
selectBrowser(browsers[0].name);
|
||||
} catch (err) {
|
||||
showBanner(err.message, 'error', init);
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Select Browser ────────────────────
|
||||
async function selectBrowser(name) {
|
||||
activeBrowser = name;
|
||||
activeProfile = 'Default';
|
||||
|
||||
// Update pills
|
||||
$pills.querySelectorAll('.pill').forEach(p => {
|
||||
p.classList.toggle('active', p.textContent === name);
|
||||
});
|
||||
|
||||
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading...</div>';
|
||||
$sourceFooter.textContent = '';
|
||||
$search.value = '';
|
||||
|
||||
try {
|
||||
// Fetch profiles for this browser
|
||||
const profileData = await api('/profiles?browser=' + encodeURIComponent(name));
|
||||
allProfiles = profileData.profiles || [];
|
||||
|
||||
if (allProfiles.length > 1) {
|
||||
// Show profile pills when multiple profiles exist
|
||||
$profilePills.style.display = 'flex';
|
||||
renderProfilePills();
|
||||
// Auto-select profile with the most recent/largest cookie DB, or Default
|
||||
activeProfile = allProfiles[0].name;
|
||||
} else {
|
||||
$profilePills.style.display = 'none';
|
||||
activeProfile = allProfiles.length === 1 ? allProfiles[0].name : 'Default';
|
||||
const selected = config.browser
|
||||
? browsers.find(browser => [browser.name, ...(Array.isArray(browser.aliases) ? browser.aliases : [])]
|
||||
.some(name => typeof name === 'string' && name.toLowerCase() === config.browser.trim().toLowerCase()))
|
||||
: browsers[0];
|
||||
configuredBrowser = config.browser && selected ? selected.name : null;
|
||||
if (selected) await selectBrowser(selected.name);
|
||||
else {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Choose an available source browser</div>';
|
||||
showBanner('The requested browser is unavailable. Choose an available browser explicitly.', 'warning');
|
||||
}
|
||||
|
||||
await loadDomains();
|
||||
} catch (err) {
|
||||
showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null);
|
||||
} catch (error) {
|
||||
showBanner('Could not load the cookie picker. ' + errorMessage(error), 'error', init);
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
|
||||
$profilePills.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render Profile Pills ─────────────
|
||||
function renderProfilePills() {
|
||||
let html = '';
|
||||
for (const p of allProfiles) {
|
||||
const isActive = p.name === activeProfile;
|
||||
const label = p.displayName || p.name;
|
||||
html += '<button class="profile-pill' + (isActive ? ' active' : '') + '" data-profile="' + escHtml(p.name) + '">' + escHtml(label) + '</button>';
|
||||
async function selectBrowser(name) {
|
||||
if (mutation) return;
|
||||
const selection = ++generation;
|
||||
activeBrowser = name;
|
||||
activeProfile = null;
|
||||
allProfiles = [];
|
||||
allDomains = [];
|
||||
$search.value = '';
|
||||
$profilePills.innerHTML = '';
|
||||
$profilePills.style.display = 'none';
|
||||
$btnImportAll.style.display = 'none';
|
||||
$sourceFooter.textContent = '';
|
||||
$pills.querySelectorAll('button').forEach(button => {
|
||||
const active = button.dataset.browser === name;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading profiles...</div>';
|
||||
showBanner('Loading profiles from ' + name + '.', 'info');
|
||||
try {
|
||||
const data = await api('/profiles?browser=' + encodeURIComponent(name));
|
||||
if (selection !== generation) return;
|
||||
allProfiles = data.profiles || [];
|
||||
const explicit = config.profile && configuredBrowser === name;
|
||||
const requested = explicit ? config.profile : data.recommendedProfile;
|
||||
activeProfile = allProfiles.some(profile => profile.name === requested) ? requested : null;
|
||||
renderProfilePills();
|
||||
if (!activeProfile) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">' + (allProfiles.length ? 'Choose a profile to inspect its domains' : 'No profiles found') + '</div>';
|
||||
showBanner(explicit ? 'The requested profile is unavailable. Choose a profile explicitly.'
|
||||
: allProfiles.length ? 'Choose a profile. No unambiguous profile was recommended.' : 'No source profiles were found.', 'warning');
|
||||
return;
|
||||
}
|
||||
await loadDomains(selection, name, activeProfile);
|
||||
} catch (error) {
|
||||
if (selection !== generation) return;
|
||||
showBanner('Could not load profiles for ' + name + '. ' + errorMessage(error), 'error', () => selectBrowser(name));
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load profiles</div>';
|
||||
}
|
||||
$profilePills.innerHTML = html;
|
||||
}
|
||||
|
||||
$profilePills.querySelectorAll('.profile-pill').forEach(btn => {
|
||||
btn.addEventListener('click', () => selectProfile(btn.dataset.profile));
|
||||
function renderProfilePills() {
|
||||
$profilePills.style.display = allProfiles.length ? 'flex' : 'none';
|
||||
$profilePills.innerHTML = allProfiles.map(profile => {
|
||||
const active = profile.name === activeProfile;
|
||||
const label = (profile.displayName || profile.name) + ' (' + profile.name + ')' + (profile.unavailable ? ' — could not inspect' : '');
|
||||
return '<button class="profile-pill' + (active ? ' active' : '') + '" aria-pressed="' + active + '" data-profile="' + escHtml(profile.name) + '"' + (mutation ? ' disabled' : '') + '>' + escHtml(label) + '</button>';
|
||||
}).join('');
|
||||
$profilePills.querySelectorAll('button').forEach(button => {
|
||||
button.onclick = () => selectProfile(button.dataset.profile);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Select Profile ───────────────────
|
||||
async function selectProfile(profileName) {
|
||||
activeProfile = profileName;
|
||||
async function selectProfile(name) {
|
||||
if (mutation || !allProfiles.some(profile => profile.name === name)) return;
|
||||
const selection = ++generation;
|
||||
activeProfile = name;
|
||||
allDomains = [];
|
||||
$search.value = '';
|
||||
renderProfilePills();
|
||||
$profilePills.querySelectorAll('button').forEach(button => { if (button.dataset.profile === name) button.focus(); });
|
||||
await loadDomains(selection, activeBrowser, name);
|
||||
}
|
||||
|
||||
async function loadDomains(selection, browser, profile) {
|
||||
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
|
||||
$sourceFooter.textContent = '';
|
||||
$search.value = '';
|
||||
|
||||
await loadDomains();
|
||||
}
|
||||
|
||||
// ─── Load Domains ─────────────────────
|
||||
async function loadDomains() {
|
||||
$btnImportAll.style.display = 'none';
|
||||
showBanner('Loading domains from ' + browser + ' (' + profile + ').', 'info');
|
||||
try {
|
||||
const data = await api('/domains?browser=' + encodeURIComponent(activeBrowser) + '&profile=' + encodeURIComponent(activeProfile));
|
||||
allDomains = data.domains;
|
||||
const data = await api('/domains?browser=' + encodeURIComponent(browser) + '&profile=' + encodeURIComponent(profile));
|
||||
if (selection !== generation) return;
|
||||
allDomains = data.domains || [];
|
||||
renderSourceDomains();
|
||||
} catch (err) {
|
||||
showBanner(err.message, 'error', err.action === 'retry' ? () => loadDomains() : null);
|
||||
showBanner(allDomains.length ? 'Ready to import from ' + browser + ' (' + profile + '). Authentication has not been checked.'
|
||||
: 'No cookie domains found in ' + browser + ' (' + profile + ').', allDomains.length ? 'info' : 'warning');
|
||||
} catch (error) {
|
||||
if (selection !== generation) return;
|
||||
showBanner('Could not read domains from ' + browser + ' (' + profile + '). ' + errorMessage(error), 'error', () => selectProfile(profile));
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load domains</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render Source Domains ─────────────
|
||||
function renderSourceDomains() {
|
||||
const query = $search.value.toLowerCase();
|
||||
const filtered = query
|
||||
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
|
||||
: allDomains;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">' +
|
||||
(query ? 'No matching domains' : 'No cookie domains found') + '</div>';
|
||||
$sourceFooter.textContent = '';
|
||||
const filtered = allDomains.filter(domain => domain.domain.toLowerCase().includes(query));
|
||||
$btnImportAll.style.display = filtered.length && activeProfile ? '' : 'none';
|
||||
$btnImportAll.disabled = mutation || !activeProfile;
|
||||
$btnImportAll.textContent = 'Import All (' + filtered.length + ')';
|
||||
$sourceFooter.textContent = allDomains.length ? allDomains.length + ' domains · ' + allDomains.reduce((sum, domain) => sum + domain.count, 0).toLocaleString() + ' cookies' : '';
|
||||
if (!filtered.length) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">' + (!activeProfile ? 'Choose a profile to inspect its domains' : query ? 'No matching domains' : 'No cookie domains found') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const d of filtered) {
|
||||
const isImported = d.domain in importedSet;
|
||||
const isInflight = inflight[d.domain];
|
||||
html += '<div class="domain-row">';
|
||||
html += '<span class="domain-name">' + escHtml(d.domain) + '</span>';
|
||||
html += '<span class="domain-count">' + d.count + '</span>';
|
||||
if (isInflight) {
|
||||
html += '<span class="btn-add" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;"></span></span>';
|
||||
} else if (isImported) {
|
||||
html += '<span class="btn-add imported">✓</span>';
|
||||
} else {
|
||||
html += '<button class="btn-add" data-domain="' + escHtml(d.domain) + '" title="Import">+</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$sourceDomains.innerHTML = html;
|
||||
|
||||
// Total counts
|
||||
const totalDomains = allDomains.length;
|
||||
const totalCookies = allDomains.reduce((s, d) => s + d.count, 0);
|
||||
$sourceFooter.textContent = totalDomains + ' domains · ' + totalCookies.toLocaleString() + ' cookies';
|
||||
|
||||
// Show/hide Import All button
|
||||
const unimported = filtered.filter(d => !(d.domain in importedSet) && !inflight[d.domain]);
|
||||
if (unimported.length > 0) {
|
||||
$btnImportAll.style.display = '';
|
||||
$btnImportAll.disabled = false;
|
||||
$btnImportAll.textContent = 'Import All (' + unimported.length + ')';
|
||||
} else {
|
||||
$btnImportAll.style.display = 'none';
|
||||
}
|
||||
|
||||
// Click handlers
|
||||
$sourceDomains.querySelectorAll('.btn-add[data-domain]').forEach(btn => {
|
||||
btn.addEventListener('click', () => importDomain(btn.dataset.domain));
|
||||
$sourceDomains.innerHTML = filtered.map(domain => {
|
||||
const label = (domain.domain in importedSet ? 'Reimport ' : 'Import ') + domain.domain;
|
||||
return '<div class="domain-row"><span class="domain-name">' + escHtml(domain.domain) + '</span><span class="domain-count">' + escHtml(domain.count) + '</span><button class="btn-add" data-domain="' + escHtml(domain.domain) + '" title="' + escHtml(label) + '" aria-label="' + escHtml(label) + '"' + (mutation ? ' disabled' : '') + '>' + (domain.domain in importedSet ? '↻' : '+') + '</button></div>';
|
||||
}).join('');
|
||||
$sourceDomains.querySelectorAll('button').forEach(button => {
|
||||
button.onclick = () => importDomains([button.dataset.domain]);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Import Domain ─────────────────────
|
||||
async function importDomain(domain) {
|
||||
if (inflight[domain] || domain in importedSet) return;
|
||||
inflight[domain] = true;
|
||||
function setMutationBusy(busy) {
|
||||
mutation = busy;
|
||||
$pills.querySelectorAll('button').forEach(button => { button.disabled = busy; });
|
||||
$profilePills.querySelectorAll('button').forEach(button => { button.disabled = busy; });
|
||||
$search.disabled = busy;
|
||||
$clearStorage.disabled = busy || !canReset;
|
||||
$verifyAuth.disabled = busy || !canVerify;
|
||||
renderSourceDomains();
|
||||
renderImported();
|
||||
}
|
||||
|
||||
async function importDomains(domains) {
|
||||
if (mutation || !activeBrowser || !activeProfile || !domains.length) return;
|
||||
const request = { browser: activeBrowser, profile: activeProfile, domains: domains.slice(),
|
||||
clearStorage: canReset && $clearStorage.checked, verifyAuth: canVerify && $verifyAuth.checked };
|
||||
setMutationBusy(true);
|
||||
showBanner('Importing from ' + request.browser + ' (' + request.profile + '). Keep this picker open.', 'info');
|
||||
try {
|
||||
const data = await api('/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ browser: activeBrowser, domains: [domain], profile: activeProfile }),
|
||||
});
|
||||
|
||||
if (data.domainCounts) {
|
||||
for (const [d, count] of Object.entries(data.domainCounts)) {
|
||||
importedSet[d] = (importedSet[d] || 0) + count;
|
||||
}
|
||||
const data = await api('/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) });
|
||||
const imported = Number.isFinite(data.imported) && data.imported > 0 ? data.imported : 0;
|
||||
const failed = Number.isFinite(data.failed) && data.failed > 0 ? data.failed : 0;
|
||||
for (const [domain, count] of Object.entries(data.domainCounts || {})) {
|
||||
if (imported > 0 && Number.isFinite(count) && count > 0) importedSet[domain] = count;
|
||||
}
|
||||
renderImported();
|
||||
} catch (err) {
|
||||
showBanner('Import failed for ' + domain + ': ' + err.message, 'error',
|
||||
err.action === 'retry' ? () => importDomain(domain) : null);
|
||||
const partial = data.outcome === 'partial' || (imported > 0 && failed > 0);
|
||||
let type = data.outcome === 'failed' || data.reset === 'failed' ? 'error' : partial || !imported ? 'warning' : 'info';
|
||||
let message = (data.outcome === 'failed' ? 'Import failed. ' : partial ? 'Partial import. ' : !imported ? 'No cookies imported. ' : 'Cookies imported. ')
|
||||
+ imported + ' imported; ' + failed + ' failed. Source: ' + request.browser + ' (' + request.profile + ').';
|
||||
if (typeof data.message === 'string' && data.message) message += ' ' + data.message;
|
||||
const failureLabels = { unsupported_encryption: 'unsupported encryption', decryption_failed: 'decryption failed', native_unrecovered: 'not recovered by native import' };
|
||||
for (const [reason, count] of Object.entries(data.failureReasons || {})) {
|
||||
if (Object.hasOwn(failureLabels, reason) && Number.isFinite(count) && count > 0) message += ' ' + failureLabels[reason] + ': ' + count + '.';
|
||||
}
|
||||
message += data.reset === 'cleared' ? ' Storage cleared for ' + config.targetOrigin + '.'
|
||||
: data.reset === 'failed' ? ' Storage reset failed; storage may be partially cleared.' : ' Storage preserved.';
|
||||
if (!request.verifyAuth) message += ' Authentication not checked.';
|
||||
else if (imported > 0 && data.verification && data.verification.verified === true) message += ' Authentication verified on the captured target.';
|
||||
else {
|
||||
const reasons = { not_configured: 'identity assertion not configured', no_cookies_imported: 'no cookies imported',
|
||||
identity_missing: 'visible identity missing', identity_ambiguous: 'multiple visible identities', identity_mismatch: 'identity did not match',
|
||||
login_redirect: 'login page detected', target_changed: 'target changed', target_closed: 'target closed',
|
||||
timeout: 'check timed out', http_error: 'unsuccessful HTTP response', reset_failed: 'storage reset failed', application_failed: 'cookie application failed' };
|
||||
const reason = data.verification && data.verification.reason;
|
||||
message += ' Authentication not verified' + (Object.hasOwn(reasons, reason) ? ': ' + reasons[reason] : '') + '.';
|
||||
if (type === 'info') type = 'warning';
|
||||
}
|
||||
showBanner(message, type);
|
||||
} catch (error) {
|
||||
showBanner('Import did not complete for ' + request.browser + ' (' + request.profile + '). ' + errorMessage(error) + ' Authentication was not verified.', 'error');
|
||||
} finally {
|
||||
delete inflight[domain];
|
||||
renderSourceDomains();
|
||||
setMutationBusy(false);
|
||||
if (domains.length === 1) {
|
||||
$sourceDomains.querySelectorAll('button').forEach(button => { if (button.dataset.domain === domains[0]) button.focus(); });
|
||||
} else $btnImportAll.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Import All ───────────────────────
|
||||
async function importAll() {
|
||||
const query = $search.value.toLowerCase();
|
||||
const filtered = query
|
||||
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
|
||||
: allDomains;
|
||||
const toImport = filtered.filter(d => !(d.domain in importedSet) && !inflight[d.domain]);
|
||||
if (toImport.length === 0) return;
|
||||
$btnImportAll.onclick = () => importDomains(allDomains.filter(domain => domain.domain.toLowerCase().includes($search.value.toLowerCase())).map(domain => domain.domain));
|
||||
|
||||
$btnImportAll.disabled = true;
|
||||
$btnImportAll.textContent = 'Importing...';
|
||||
|
||||
const domains = toImport.map(d => d.domain);
|
||||
try {
|
||||
const data = await api('/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ browser: activeBrowser, domains: domains, profile: activeProfile }),
|
||||
});
|
||||
|
||||
if (data.domainCounts) {
|
||||
for (const [d, count] of Object.entries(data.domainCounts)) {
|
||||
importedSet[d] = (importedSet[d] || 0) + count;
|
||||
}
|
||||
}
|
||||
renderImported();
|
||||
} catch (err) {
|
||||
showBanner('Import all failed: ' + err.message, 'error',
|
||||
err.action === 'retry' ? () => importAll() : null);
|
||||
} finally {
|
||||
renderSourceDomains();
|
||||
}
|
||||
}
|
||||
|
||||
$btnImportAll.addEventListener('click', importAll);
|
||||
|
||||
// ─── Render Imported ───────────────────
|
||||
function renderImported() {
|
||||
const entries = Object.entries(importedSet).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
$importedDomains.innerHTML = '<div class="imported-empty">No cookies imported yet</div>';
|
||||
$importedFooter.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const [domain, count] of entries) {
|
||||
const isInflight = inflight['remove:' + domain];
|
||||
html += '<div class="domain-row">';
|
||||
html += '<span class="domain-name">' + escHtml(domain) + '</span>';
|
||||
html += '<span class="domain-count">' + count + '</span>';
|
||||
if (isInflight) {
|
||||
html += '<span class="btn-trash" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;border-top-color:#f87171;"></span></span>';
|
||||
} else {
|
||||
html += '<button class="btn-trash" data-domain="' + escHtml(domain) + '" title="Remove">🗑</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$importedDomains.innerHTML = html;
|
||||
|
||||
const totalCookies = entries.reduce((s, e) => s + e[1], 0);
|
||||
$importedFooter.textContent = entries.length + ' domains · ' + totalCookies.toLocaleString() + ' cookies imported';
|
||||
|
||||
// Click handlers
|
||||
$importedDomains.querySelectorAll('.btn-trash[data-domain]').forEach(btn => {
|
||||
btn.addEventListener('click', () => removeDomain(btn.dataset.domain));
|
||||
});
|
||||
$importedFooter.textContent = entries.length ? entries.length + ' domains · ' + entries.reduce((sum, entry) => sum + entry[1], 0).toLocaleString() + ' cookies imported' : '';
|
||||
$importedDomains.innerHTML = entries.length ? entries.map(([domain, count]) => '<div class="domain-row"><span class="domain-name">' + escHtml(domain) + '</span><span class="domain-count">' + escHtml(count) + '</span><button class="btn-trash" data-domain="' + escHtml(domain) + '" aria-label="' + escHtml('Remove ' + domain) + '" title="Remove"' + (mutation ? ' disabled' : '') + '>🗑</button></div>').join('')
|
||||
: '<div class="imported-empty">No cookies imported yet</div>';
|
||||
$importedDomains.querySelectorAll('button').forEach(button => { button.onclick = () => removeDomain(button.dataset.domain); });
|
||||
}
|
||||
|
||||
// ─── Remove Domain ─────────────────────
|
||||
async function removeDomain(domain) {
|
||||
if (inflight['remove:' + domain]) return;
|
||||
inflight['remove:' + domain] = true;
|
||||
renderImported();
|
||||
|
||||
if (mutation) return;
|
||||
setMutationBusy(true);
|
||||
showBanner('Removing imported cookies for ' + domain + '.', 'info');
|
||||
try {
|
||||
await api('/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domains: [domain] }),
|
||||
});
|
||||
await api('/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domains: [domain] }) });
|
||||
delete importedSet[domain];
|
||||
renderImported();
|
||||
renderSourceDomains(); // update checkmarks
|
||||
} catch (err) {
|
||||
showBanner('Remove failed for ' + domain + ': ' + err.message, 'error',
|
||||
err.action === 'retry' ? () => removeDomain(domain) : null);
|
||||
showBanner('Removed imported cookies for ' + domain + '. Storage was not cleared.', 'info');
|
||||
} catch (error) {
|
||||
showBanner('Cookie removal did not complete. ' + errorMessage(error), 'error');
|
||||
} finally {
|
||||
delete inflight['remove:' + domain];
|
||||
renderImported();
|
||||
setMutationBusy(false);
|
||||
const first = $importedDomains.querySelector('button') || $sourceDomains.querySelector('button');
|
||||
if (first) first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Search ────────────────────────────
|
||||
$search.addEventListener('input', renderSourceDomains);
|
||||
|
||||
// ─── Start ─────────────────────────────
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
|
||||
import type { TabSession } from './tab-session';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, importCookies, importCookiesViaCdp, hasV20Cookies, listSupportedBrowserNames } from './cookie-import-browser';
|
||||
import { CookieImportError, cookieDomainMatches, findInstalledBrowsers, listSupportedBrowserNames } from './cookie-import-browser';
|
||||
import { generatePickerCode } from './cookie-picker-routes';
|
||||
import { formatCookieImportResult, parseCookieImportArgs, runCookieImport, validateCookieTarget } from './cookie-import-operation';
|
||||
import { validateCookieAuthOptions, validateCookieStorageSupport } from './cookie-auth-verification';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import { validateOutputPath, validateReadPath } from './path-security';
|
||||
import { guardScreenshotPath } from './screenshot-size-guard';
|
||||
@@ -687,80 +689,50 @@ export async function handleWriteCommand(
|
||||
}
|
||||
|
||||
case 'cookie-import-browser': {
|
||||
// Two modes:
|
||||
// 1. Direct CLI import: cookie-import-browser <browser> --domain <domain> [--profile <profile>]
|
||||
// Requires --domain (or --all to explicitly import everything).
|
||||
// 2. Open picker UI: cookie-import-browser [browser] (interactive domain selection)
|
||||
const browserArg = args[0];
|
||||
const domainIdx = args.indexOf('--domain');
|
||||
const profileIdx = args.indexOf('--profile');
|
||||
const hasAll = args.includes('--all');
|
||||
const profile = (profileIdx !== -1 && profileIdx + 1 < args.length) ? args[profileIdx + 1] : 'Default';
|
||||
|
||||
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
|
||||
// Direct import mode — scoped to specific domain
|
||||
const domain = args[domainIdx + 1];
|
||||
// Validate --domain against current page hostname to prevent cross-site cookie injection
|
||||
const pageHostname = new URL(page.url()).hostname;
|
||||
const normalizedDomain = domain.startsWith('.') ? domain.slice(1) : domain;
|
||||
if (normalizedDomain !== pageHostname && !pageHostname.endsWith('.' + normalizedDomain)) {
|
||||
throw new Error(`--domain "${domain}" does not match current page domain "${pageHostname}". Navigate to the target site first.`);
|
||||
const options = parseCookieImportArgs(args);
|
||||
const target = { page, url: page.url() };
|
||||
const authOptions = {
|
||||
identitySelector: process.env.GSTACK_COOKIE_AUTH_SELECTOR,
|
||||
expectedIdentity: process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY,
|
||||
};
|
||||
if (options.domains || options.all) {
|
||||
if (options.domains) {
|
||||
const targetUrl = validateCookieTarget(target);
|
||||
if (!options.domains.every(domain => cookieDomainMatches(targetUrl.hostname, '.' + domain))) {
|
||||
throw new CookieImportError('The requested cookie domain does not match the current page. Navigate to the target site first.', 'target_mismatch');
|
||||
}
|
||||
}
|
||||
const browser = browserArg || 'comet';
|
||||
let result = await importCookies(browser, [domain], profile);
|
||||
// If all cookies failed and v20 is detected, try CDP extraction
|
||||
if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, profile)) {
|
||||
result = await importCookiesViaCdp(browser, [domain], profile);
|
||||
const result = await runCookieImport(options, target, domains => bm.trackCookieImportDomains(domains), authOptions);
|
||||
const message = formatCookieImportResult(result);
|
||||
if (result.outcome === 'failed' || options.verifyAuth && !result.verification.verified) {
|
||||
throw new CookieImportError(message, 'cookie_import_incomplete');
|
||||
}
|
||||
if (result.cookies.length > 0) {
|
||||
await page.context().addCookies(result.cookies);
|
||||
bm.trackCookieImportDomains([domain]);
|
||||
}
|
||||
const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`];
|
||||
if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);
|
||||
return msg.join(' ');
|
||||
return message + (options.all ? ' Used --all: all source-profile cookie domains were selected.' : '');
|
||||
}
|
||||
|
||||
if (hasAll) {
|
||||
// Explicit all-cookies import — requires --all flag as a deliberate opt-in.
|
||||
// Imports every non-expired cookie domain from the browser.
|
||||
const browser = browserArg || 'comet';
|
||||
const { listDomains } = await import('./cookie-import-browser');
|
||||
const { domains } = listDomains(browser, profile);
|
||||
const allDomainNames = domains.map((d: any) => d.domain);
|
||||
if (allDomainNames.length === 0) {
|
||||
return `No cookies found in ${browser} (profile: ${profile})`;
|
||||
}
|
||||
const result = await importCookies(browser, allDomainNames, profile);
|
||||
if (result.cookies.length > 0) {
|
||||
await page.context().addCookies(result.cookies);
|
||||
bm.trackCookieImportDomains(allDomainNames);
|
||||
}
|
||||
const msg = [`Imported ${result.count} cookies across ${Object.keys(result.domainCounts).length} domains from ${browser}`];
|
||||
msg.push('(used --all: all browser cookies imported, consider --domain for tighter scoping)');
|
||||
if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);
|
||||
return msg.join(' ');
|
||||
}
|
||||
|
||||
// Picker UI mode — open in user's browser for interactive domain selection
|
||||
const port = bm.serverPort;
|
||||
if (!port) throw new Error('Server port not available');
|
||||
|
||||
if (!port) throw new CookieImportError('Server port not available', 'unavailable');
|
||||
const browsers = findInstalledBrowsers();
|
||||
if (browsers.length === 0) {
|
||||
throw new Error(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`);
|
||||
}
|
||||
|
||||
const code = generatePickerCode();
|
||||
if (browsers.length === 0) throw new CookieImportError(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`, 'not_installed');
|
||||
if (options.clearStorage || options.verifyAuth) validateCookieTarget(target);
|
||||
if (options.clearStorage) validateCookieStorageSupport(page);
|
||||
if (options.verifyAuth) validateCookieAuthOptions(authOptions);
|
||||
const code = generatePickerCode({
|
||||
target,
|
||||
browser: options.browser,
|
||||
profile: options.profile,
|
||||
clearStorage: options.clearStorage,
|
||||
verifyAuth: options.verifyAuth,
|
||||
});
|
||||
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker?code=${code}`;
|
||||
const openCommand = process.platform === 'darwin' ? ['open', pickerUrl]
|
||||
: process.platform === 'win32' ? ['cmd.exe', '/d', '/c', 'start', '', pickerUrl] : ['xdg-open', pickerUrl];
|
||||
try {
|
||||
Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore', windowsHide: true });
|
||||
} catch (err: any) {
|
||||
// open may fail on non-macOS or if 'open' binary is missing — URL is in the message below
|
||||
if (err?.code !== 'ENOENT' && !err?.message?.includes('spawn')) throw err;
|
||||
Bun.spawn(openCommand, { stdout: 'ignore', stderr: 'ignore', windowsHide: true });
|
||||
} catch {
|
||||
throw new CookieImportError('Could not open the cookie picker in a local browser. Retry from a desktop session.', 'picker_open_failed');
|
||||
}
|
||||
|
||||
return `Cookie picker opened at http://127.0.0.1:${port}/cookie-picker\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.\n\nTip: For scripted imports, use --domain <domain> to scope cookies to a single domain.`;
|
||||
return `Cookie picker opened at http://127.0.0.1:${port}/cookie-picker\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect the source profile and domains. Cookies copied does not mean sign-in verified.`;
|
||||
}
|
||||
|
||||
case 'style': {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, test, expect, afterAll, setDefaultTimeout } from 'bun:test';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
|
||||
// Every test here spawnSync's a `node` child; Windows CI cold-start (AV scan,
|
||||
// first-touch of node.exe) alone can blow bun's 5s default — observed 5,007ms
|
||||
@@ -204,6 +206,118 @@ describe('bun-polyfill', () => {
|
||||
expect(result.stdout.toString().trim()).toBe('1048576:0');
|
||||
}, 15000);
|
||||
|
||||
test('cancelled replay readers release inherited pipes after the direct child exits', () => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'polyfill-cancel-')));
|
||||
const marker = path.join(root, 'descendant.pid');
|
||||
const pidPath = path.join(root, 'spawned.pid');
|
||||
expect(fs.realpathSync(root)).toBe(root);
|
||||
try {
|
||||
const descendantScript = `
|
||||
const fs = require('node:fs');
|
||||
fs.writeSync(1, 'fixture-stdout');
|
||||
fs.writeSync(2, 'fixture-stderr');
|
||||
fs.writeFileSync(${JSON.stringify(marker + '.tmp')}, JSON.stringify({ pid: process.pid, stdout: true, stderr: true }));
|
||||
fs.renameSync(${JSON.stringify(marker + '.tmp')}, ${JSON.stringify(marker)});
|
||||
setInterval(() => {}, 1000);
|
||||
`;
|
||||
const childScript = `
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const descendant = spawn(process.execPath, ['-e', ${JSON.stringify(descendantScript)}],
|
||||
{ stdio: ['ignore', 'inherit', 'inherit'], windowsHide: true, detached: process.platform === 'win32' });
|
||||
fs.writeFileSync(${JSON.stringify(pidPath)}, String(descendant.pid));
|
||||
const deadline = Date.now() + 5000;
|
||||
const ready = () => {
|
||||
if (fs.existsSync(${JSON.stringify(marker)})) process.exit(0);
|
||||
if (descendant.exitCode !== null || Date.now() >= deadline) process.exit(1);
|
||||
setTimeout(ready, 10);
|
||||
};
|
||||
ready();
|
||||
`;
|
||||
const script = `
|
||||
const childProcess = require('node:child_process');
|
||||
const originalSpawn = childProcess.spawn;
|
||||
let direct;
|
||||
childProcess.spawn = (...args) => { direct = originalSpawn(...args); return direct; };
|
||||
require(${JSON.stringify(polyfillPath)});
|
||||
let stage = 'spawn';
|
||||
let directExitCode;
|
||||
let markerValid = false;
|
||||
let stdoutAck = false;
|
||||
let stderrAck = false;
|
||||
let descendantAlive = false;
|
||||
let checkErrorCode = null;
|
||||
(async () => {
|
||||
const proc = Bun.spawn([process.execPath, '-e', ${JSON.stringify(childScript)}],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
if (!direct) throw new Error('capture_missing');
|
||||
const stdout = proc.stdout.getReader();
|
||||
const stderr = proc.stderr.getReader();
|
||||
const stdoutRead = stdout.read();
|
||||
const stderrRead = stderr.read();
|
||||
stage = 'direct_exit';
|
||||
directExitCode = await new Promise((resolve, reject) => { direct.once('exit', resolve); direct.once('error', reject); });
|
||||
let readyPid;
|
||||
try {
|
||||
const fs = require('node:fs');
|
||||
const marker = JSON.parse(fs.readFileSync(${JSON.stringify(marker)}, 'utf8'));
|
||||
readyPid = marker.pid;
|
||||
markerValid = Number.isSafeInteger(readyPid) && readyPid > 0
|
||||
&& String(readyPid) === fs.readFileSync(${JSON.stringify(pidPath)}, 'utf8');
|
||||
stdoutAck = marker.stdout === true;
|
||||
stderrAck = marker.stderr === true;
|
||||
} catch (error) { checkErrorCode = typeof error.code === 'string' ? error.code : 'invalid_marker'; }
|
||||
if (markerValid) {
|
||||
try { process.kill(readyPid, 0); descendantAlive = true; }
|
||||
catch (error) { checkErrorCode = typeof error.code === 'string' ? error.code : 'liveness_error'; }
|
||||
}
|
||||
if (!markerValid || !stdoutAck || !stderrAck || !descendantAlive) throw new Error('descendant_not_ready');
|
||||
stage = 'pending_check';
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
let settled = false;
|
||||
proc.exited.then(() => { settled = true; });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (settled) throw new Error('Inherited pipes unexpectedly closed before cancellation');
|
||||
stage = 'cancel';
|
||||
await Promise.all([stdout.cancel(), stderr.cancel()]);
|
||||
const reads = await Promise.all([stdoutRead, stderrRead]);
|
||||
stage = 'await_exited';
|
||||
let timer;
|
||||
const code = await Promise.race([proc.exited, new Promise((_, reject) =>
|
||||
{ timer = setTimeout(() => reject(new Error('cancel did not settle exited')), 5000); })])
|
||||
.finally(() => clearTimeout(timer));
|
||||
console.log(JSON.stringify({ code, directExitCode, reads: reads.map(read => read.done), descendantAlive: (() => {
|
||||
try { process.kill(JSON.parse(require('node:fs').readFileSync(${JSON.stringify(marker)}, 'utf8')).pid, 0); return true; }
|
||||
catch { return false; }
|
||||
})() }));
|
||||
})().catch(error => {
|
||||
const reason = error.message === 'Inherited pipes unexpectedly closed before cancellation' ? 'early_pipes'
|
||||
: error.message === 'cancel did not settle exited' ? 'cancel_stalled'
|
||||
: error.message === 'capture_missing' ? 'capture_missing'
|
||||
: error.message === 'descendant_not_ready' ? 'descendant_not_ready' : 'unexpected';
|
||||
console.error(JSON.stringify({ stage, reason, directExitCode, markerValid, stdoutAck, stderrAck,
|
||||
descendantAlive, checkErrorCode, errorCode: typeof error.code === 'string' ? error.code : null }));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
`;
|
||||
const result = Bun.spawnSync(['node', '-e', script], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
||||
const errorOutput = result.stderr.toString().trim();
|
||||
let diagnostic: object | null = null;
|
||||
try { if (errorOutput) diagnostic = JSON.parse(errorOutput); }
|
||||
catch { diagnostic = { stage: 'unframed', stderrBytes: Buffer.byteLength(errorOutput) }; }
|
||||
expect({ exitCode: result.exitCode, diagnostic }).toEqual({ exitCode: 0, diagnostic: null });
|
||||
expect(JSON.parse(result.stdout.toString())).toEqual({ code: 0, directExitCode: 0, reads: [true, true], descendantAlive: true });
|
||||
} finally {
|
||||
if (fs.existsSync(pidPath)) {
|
||||
const pidText = fs.readFileSync(pidPath, 'utf8');
|
||||
if (/^[1-9]\d*$/.test(pidText)) {
|
||||
try { process.kill(Number(pidText)); } catch (error: any) { if (error.code !== 'ESRCH') throw error; }
|
||||
}
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Bun.serve creates an HTTP server that responds', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require(${JSON.stringify(polyfillPath)});
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { chromium, errors, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { CookieImportError } from '../src/cookie-import-browser';
|
||||
import { clearCookieTargetStorage, validateCookieAuthOptions, validateCookieStorageSupport, verifyCookieAuthentication } from '../src/cookie-auth-verification';
|
||||
|
||||
const identity = 'Synthetic Account 472';
|
||||
const options = { identitySelector: '.identity', expectedIdentity: identity, timeoutMs: 400 };
|
||||
const unitOrigin = 'https://fixture.test';
|
||||
const credentialUrl = new URL(unitOrigin);
|
||||
credentialUrl.username = 'fixture-user';
|
||||
credentialUrl.password = 'fixture';
|
||||
credentialUrl.searchParams.set('token', 'synthetic-token');
|
||||
|
||||
function mockPage() {
|
||||
const evaluateAll = mock(async () => 'verified');
|
||||
const request = { url: () => `${unitOrigin}/protected`, redirectedFrom: () => null };
|
||||
const events = new EventEmitter();
|
||||
const frame = {};
|
||||
const storage = { engine: 'chromium', now: 100, deadline: 0, origin: unitOrigin, url: request.url(),
|
||||
localClear: mock(() => {}), sessionClear: mock(() => {}), nativeNow: mock(() => storage.now) };
|
||||
const cdpEvents = new EventEmitter();
|
||||
const cdp = {
|
||||
on: cdpEvents.on.bind(cdpEvents),
|
||||
off: cdpEvents.off.bind(cdpEvents),
|
||||
detach: mock(async () => {}),
|
||||
send: mock(async (method: string, params?: any): Promise<any> => {
|
||||
if (method === 'Page.getFrameTree') return { frameTree: { frame: { id: 'fixture-frame' } } };
|
||||
if (method === 'Page.createIsolatedWorld') {
|
||||
cdpEvents.emit('Runtime.executionContextCreated', { context: { name: params.worldName, id: 7,
|
||||
uniqueId: 'fixture-unique-world', auxData: { frameId: 'fixture-frame', isDefault: false } } });
|
||||
return { executionContextId: 7 };
|
||||
}
|
||||
if (method === 'Runtime.evaluate') return { result: { value: storage.nativeNow() } };
|
||||
if (method === 'Runtime.callFunctionOn') {
|
||||
const arg = params.arguments[0].value;
|
||||
storage.deadline = arg.deadline;
|
||||
const value = runInNewContext(`(${params.functionDeclaration})(arg)`, { arg,
|
||||
performance: { now: storage.nativeNow }, Date: { now: () => 0 },
|
||||
location: { origin: storage.origin, href: storage.url },
|
||||
localStorage: { clear: storage.localClear }, sessionStorage: { clear: storage.sessionClear },
|
||||
});
|
||||
return { result: { value } };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
const context = {
|
||||
browser: () => ({ browserType: () => ({ name: () => storage.engine }) }),
|
||||
newCDPSession: mock(async () => cdp),
|
||||
};
|
||||
const page = {
|
||||
isClosed: mock(() => false),
|
||||
url: mock(() => `${unitOrigin}/protected`),
|
||||
reload: mock(async () => ({ status: () => 200, url: request.url, request: () => request })),
|
||||
locator: mock(() => ({ filter: () => ({ evaluateAll }) })),
|
||||
evaluate: mock(async () => 'cleared'),
|
||||
context: () => context,
|
||||
mainFrame: () => frame,
|
||||
on: events.on.bind(events),
|
||||
off: events.off.bind(events),
|
||||
};
|
||||
return { page: page as unknown as Page, calls: page, evaluateAll, storage, cdp, context, events, frame, cdpEvents };
|
||||
}
|
||||
|
||||
describe('cookie auth configuration and bounded operations', () => {
|
||||
for (const [index, invalid] of [undefined, {}, { identitySelector: '.identity' }, { expectedIdentity: identity },
|
||||
{ identitySelector: ' ', expectedIdentity: identity }, { identitySelector: '.identity', expectedIdentity: '\n\t' },
|
||||
{ identitySelector: 7, expectedIdentity: identity }, { identitySelector: '.identity', expectedIdentity: [] }].entries()) {
|
||||
test(`rejects incomplete configuration case ${index + 1}`, () => {
|
||||
try {
|
||||
validateCookieAuthOptions(invalid as any);
|
||||
throw new Error('Expected configuration rejection');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect((error as CookieImportError).code).toBe('verification_not_configured');
|
||||
expect(String(error)).not.toContain(identity);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const timeoutMs of [0, -1, NaN, Infinity, '200']) {
|
||||
test(`rejects invalid timeout ${String(timeoutMs)}`, async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect(() => validateCookieAuthOptions({ ...options, timeoutMs } as any)).toThrow(CookieImportError);
|
||||
expect(await verifyCookieAuthentication(page, { ...options, timeoutMs } as any, unitOrigin))
|
||||
.toEqual({ verified: false, reason: 'invalid_configuration' });
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test('requires explicit configuration without reloading or touching storage', async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect(await verifyCookieAuthentication(page, {}, unitOrigin)).toEqual({ verified: false, reason: 'not_configured' });
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect(calls.locator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('caps an oversized requested timeout at fifteen seconds', async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect((await verifyCookieAuthentication(page, { ...options, timeoutMs: 60_000 }, unitOrigin)).verified).toBe(true);
|
||||
expect(calls.reload.mock.calls[0][0].timeout).toBeGreaterThan(0);
|
||||
expect(calls.reload.mock.calls[0][0].timeout).toBeLessThanOrEqual(15_000);
|
||||
});
|
||||
|
||||
test('does not run assertions after a timed-out reload eventually resolves', async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
let release!: (response: any) => void;
|
||||
calls.reload.mockImplementation(() => new Promise(resolve => { release = resolve; }));
|
||||
const result = await verifyCookieAuthentication(page, { ...options, timeoutMs: 30 }, unitOrigin);
|
||||
expect(result).toEqual({ verified: false, reason: 'timeout' });
|
||||
release({ status: () => 200 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(evaluateAll).not.toHaveBeenCalled();
|
||||
expect(calls.locator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const stage of ['reload', 'selector']) {
|
||||
test(`classifies Playwright's ${stage} timeout before the outer timer expires`, async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
const fail = async () => { throw new errors.TimeoutError(`${credentialUrl.href} ${identity}`); };
|
||||
if (stage === 'reload') calls.reload.mockImplementation(fail);
|
||||
else evaluateAll.mockImplementation(fail);
|
||||
const clock = spyOn(performance, 'now').mockReturnValue(0);
|
||||
try {
|
||||
const result = await verifyCookieAuthentication(page, options, unitOrigin);
|
||||
expect(result).toEqual({ verified: false, reason: 'timeout', ...(stage === 'selector' ? { status: 200 } : {}) });
|
||||
expect(JSON.stringify(result)).not.toContain(identity);
|
||||
expect(JSON.stringify(result)).not.toContain(credentialUrl.href);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('does not classify generic error text or a copied name as a Playwright timeout', async () => {
|
||||
const { page, calls } = mockPage();
|
||||
calls.reload.mockImplementation(async () => { throw Object.assign(new Error('synthetic timeout exceeded'), { name: 'TimeoutError' }); });
|
||||
expect(await verifyCookieAuthentication(page, options, unitOrigin)).toEqual({ verified: false, reason: 'verification_failed' });
|
||||
});
|
||||
|
||||
test('shares one deadline between reload and the identity assertion', async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
const response = await calls.reload();
|
||||
let now = 0;
|
||||
let expire!: () => void;
|
||||
const clock = spyOn(performance, 'now').mockImplementation(() => now);
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void, timeout: number) => {
|
||||
expect(timeout).toBe(100);
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
try {
|
||||
calls.reload.mockImplementation(async () => {
|
||||
now = 70;
|
||||
return response;
|
||||
});
|
||||
evaluateAll.mockImplementation(() => new Promise(() => {}));
|
||||
const result = verifyCookieAuthentication(page, { ...options, timeoutMs: 100 }, unitOrigin);
|
||||
await Promise.resolve();
|
||||
expect(evaluateAll).toHaveBeenCalledTimes(1);
|
||||
now = 100;
|
||||
expire();
|
||||
expect(await result).toEqual({ verified: false, reason: 'timeout', status: 200 });
|
||||
expect(timer).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns only safe reasons for secret-bearing reload and selector failures', async () => {
|
||||
for (const stage of ['reload', 'selector']) {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
const fail = async () => { throw new Error(`${credentialUrl.href} ${identity}`); };
|
||||
if (stage === 'reload') calls.reload.mockImplementation(fail);
|
||||
else evaluateAll.mockImplementation(fail);
|
||||
const result = await verifyCookieAuthentication(page, options, unitOrigin);
|
||||
expect(result.reason).toBe('verification_failed');
|
||||
expect(JSON.stringify(result)).not.toMatch(/synthetic-token|fixture|Synthetic Account/);
|
||||
}
|
||||
});
|
||||
|
||||
test('requires a successful reload response even with a positive assertion', async () => {
|
||||
const { page, calls, evaluateAll } = mockPage();
|
||||
calls.reload.mockImplementation(async () => null as any);
|
||||
expect(await verifyCookieAuthentication(page, options, unitOrigin)).toEqual({ verified: false, reason: 'no_response' });
|
||||
expect(evaluateAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects a closed or changed target before reload', async () => {
|
||||
for (const state of ['closed', 'changed']) {
|
||||
const { page, calls } = mockPage();
|
||||
if (state === 'closed') calls.isClosed.mockReturnValue(true);
|
||||
else calls.url.mockReturnValue('https://other.test/protected');
|
||||
expect((await verifyCookieAuthentication(page, options, unitOrigin)).reason).toBe(`target_${state}`);
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
for (const expectedOrigin of ['about:blank', 'file:///tmp/fixture', 'data:text/html,fixture', 'invalid',
|
||||
'https://fixture.test/path', credentialUrl.href, 'https://fixture.test/?token=synthetic-token']) {
|
||||
test(`rejects a non-origin target ${expectedOrigin.split(':')[0]}`, async () => {
|
||||
const { page, calls } = mockPage();
|
||||
expect(await verifyCookieAuthentication(page, options, expectedOrigin)).toEqual({ verified: false, reason: 'invalid_target' });
|
||||
expect(await clearCookieTargetStorage(page, expectedOrigin).then(() => null, error => error)).toMatchObject({ code: 'invalid_target' });
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test('storage reset sanitizes even a typed exception with a secret-bearing message', async () => {
|
||||
const { page, cdp } = mockPage();
|
||||
cdp.send.mockImplementation(async () => {
|
||||
throw new CookieImportError(`synthetic-token ${identity}`, 'target_changed');
|
||||
});
|
||||
try {
|
||||
await clearCookieTargetStorage(page, unitOrigin);
|
||||
throw new Error('Expected reset failure');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect((error as CookieImportError).code).toBe('storage_reset_failed');
|
||||
expect(String(error)).not.toMatch(/synthetic-token|Synthetic Account/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a reset dispatched after its timeout does no destructive work', async () => {
|
||||
const { page, cdp, storage } = mockPage();
|
||||
let runLate!: () => void;
|
||||
let expire!: () => void;
|
||||
const dispatched = Promise.withResolvers<void>();
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method !== 'Runtime.callFunctionOn') return send(method, params);
|
||||
return new Promise(resolve => {
|
||||
runLate = () => {
|
||||
storage.now = params.arguments[0].value.deadline;
|
||||
resolve(send(method, params));
|
||||
};
|
||||
dispatched.resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await dispatched.promise;
|
||||
expire();
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
runLate();
|
||||
await Promise.resolve();
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not clear session storage if the local-storage operation consumed the deadline', async () => {
|
||||
const { page, storage } = mockPage();
|
||||
storage.nativeNow.mockImplementation(() => storage.localClear.mock.calls.length ? storage.deadline : storage.now);
|
||||
expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
expect(storage.localClear).toHaveBeenCalledTimes(1);
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const engine of ['firefox', 'webkit']) {
|
||||
test(`rejects reset support before protocol work on ${engine} without disabling authentication checks`, async () => {
|
||||
const { page, storage, context, calls } = mockPage();
|
||||
storage.engine = engine;
|
||||
expect(() => validateCookieStorageSupport(page)).toThrow(CookieImportError);
|
||||
expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'storage_reset_unsupported' });
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect((await verifyCookieAuthentication(page, options, unitOrigin)).verified).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('Chromium support preflight is side-effect free', () => {
|
||||
const { page, context, calls, cdp } = mockPage();
|
||||
validateCookieStorageSupport(page);
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
expect(cdp.send).not.toHaveBeenCalled();
|
||||
expect(calls.evaluate).not.toHaveBeenCalled();
|
||||
expect(calls.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const phase of ['attachment', 'Page.getFrameTree', 'Runtime.enable', 'Page.createIsolatedWorld', 'Runtime.evaluate']) {
|
||||
test(`does not dispatch destructive work when timeout wins during ${phase}`, async () => {
|
||||
const { page, cdp, context, storage } = mockPage();
|
||||
const reached = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
let expire!: () => void;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
if (phase === 'attachment') context.newCDPSession.mockImplementation(async () => {
|
||||
reached.resolve();
|
||||
await release.promise;
|
||||
return cdp;
|
||||
});
|
||||
else cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method === phase) { reached.resolve(); await release.promise; }
|
||||
return send(method, params);
|
||||
});
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await reached.promise;
|
||||
expire();
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
release.resolve();
|
||||
for (let tick = 0; tick < 10; tick++) await Promise.resolve();
|
||||
expect(cdp.send.mock.calls.some(([method]) => method === 'Runtime.callFunctionOn')).toBe(false);
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
expect(cdp.detach).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release.resolve();
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('calibrates a conservative isolated deadline using host time after the clock response', async () => {
|
||||
const { page, cdp, storage } = mockPage();
|
||||
let now = 0;
|
||||
const clock = spyOn(performance, 'now').mockImplementation(() => now);
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
const result = await send(method, params);
|
||||
if (method === 'Runtime.evaluate') { now = 4_000; storage.now = 4_100; }
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
await clearCookieTargetStorage(page, unitOrigin);
|
||||
const sample = cdp.send.mock.calls.find(([method]) => method === 'Runtime.evaluate')![1];
|
||||
const mutation = cdp.send.mock.calls.find(([method]) => method === 'Runtime.callFunctionOn')![1];
|
||||
expect(mutation.arguments[0].value.deadline).toBe(11_100);
|
||||
expect(sample.uniqueContextId).toBe('fixture-unique-world');
|
||||
expect(mutation.uniqueContextId).toBe(sample.uniqueContextId);
|
||||
expect(mutation.executionContextId).toBeUndefined();
|
||||
expect(storage.localClear).toHaveBeenCalledTimes(1);
|
||||
expect(storage.sessionClear).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not let pending CDP detach prolong the reported timeout', async () => {
|
||||
const { page, cdp, storage, events, cdpEvents } = mockPage();
|
||||
const detaching = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
let expire!: () => void;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
|
||||
expire = callback;
|
||||
return 1;
|
||||
}) as any);
|
||||
cdp.detach.mockImplementation(async () => { detaching.resolve(); await release.promise; });
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await detaching.promise;
|
||||
expire();
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
expect(storage.localClear).toHaveBeenCalledTimes(1);
|
||||
expect(storage.sessionClear).toHaveBeenCalledTimes(1);
|
||||
expect(events.listenerCount('framenavigated')).toBe(0);
|
||||
expect(cdpEvents.listenerCount('Runtime.executionContextCreated')).toBe(0);
|
||||
} finally {
|
||||
release.resolve();
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('aborts same-URL target navigation before destructive dispatch', async () => {
|
||||
const { page, cdp, storage, events, frame } = mockPage();
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
events.emit('framenavigated', frame);
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(cdp.send.mock.calls.some(([method]) => method === 'Runtime.callFunctionOn')).toBe(false);
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const event of ['framenavigated', 'close']) {
|
||||
test(`settles ${event} cancellation without waiting for a stalled CDP operation`, async () => {
|
||||
const { page, cdp, events, frame, storage } = mockPage();
|
||||
const reached = Promise.withResolvers<void>();
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method === 'Runtime.callFunctionOn') { reached.resolve(); return new Promise(() => {}); }
|
||||
return send(method, params);
|
||||
});
|
||||
const reset = clearCookieTargetStorage(page, unitOrigin);
|
||||
await reached.promise;
|
||||
events.emit(event, frame);
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
expect(cdp.detach).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const changed of ['origin', 'url']) {
|
||||
test(`checks the captured ${changed} inside the isolated destructive operation`, async () => {
|
||||
const { page, cdp, storage } = mockPage();
|
||||
const send = cdp.send.getMockImplementation()!;
|
||||
cdp.send.mockImplementation(async (method, params) => {
|
||||
if (method === 'Runtime.callFunctionOn') storage[changed] = changed === 'origin' ? 'https://other.test' : `${unitOrigin}/other-path`;
|
||||
return send(method, params);
|
||||
});
|
||||
expect(await clearCookieTargetStorage(page, unitOrigin).then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(storage.localClear).not.toHaveBeenCalled();
|
||||
expect(storage.sessionClear).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('cookie authentication and storage with isolated Chromium profiles', () => {
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let otherServer: ReturnType<typeof Bun.serve>;
|
||||
let origin: string;
|
||||
let otherOrigin: string;
|
||||
let releaseSlowResponse: (() => void) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const html = (body: string, status = 200) => new Response(body, { status, headers: { 'Content-Type': 'text/html' } });
|
||||
const positive = `<div class="identity">${identity}</div>`;
|
||||
otherServer = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
|
||||
if (new URL(request.url).pathname === '/back') return Response.redirect(`${origin}/identity`, 302);
|
||||
return html(positive);
|
||||
} });
|
||||
otherOrigin = `http://127.0.0.1:${otherServer.port}`;
|
||||
server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/slow') return new Promise<Response>(resolve => {
|
||||
releaseSlowResponse = () => resolve(html(positive));
|
||||
});
|
||||
if (url.pathname === '/protected') {
|
||||
if (!request.headers.get('cookie')?.includes('fixture_session=approved')) return Response.redirect(`${origin}/login`, 302);
|
||||
return html(positive);
|
||||
}
|
||||
if (url.pathname === '/public-login') return html('<form><input name="email"><button>Sign in</button></form>');
|
||||
if (url.pathname === '/wrong') return html(`<div class="identity">${identity} extra account</div>`);
|
||||
if (url.pathname === '/normalized') return html('<div class="identity">\n Synthetic Account\n 472 </div>');
|
||||
if (url.pathname === '/hidden') return html(`<div class="identity" hidden>${identity}</div>`);
|
||||
if (url.pathname === '/duplicate') return html(positive + positive);
|
||||
if (url.pathname === '/one-visible') return html(positive + `<div class="identity" hidden>${identity}</div>`);
|
||||
if (url.pathname === '/hidden-child') return html(`<div class="identity">${identity}<span hidden>not visible</span></div>`);
|
||||
if (url.pathname === '/delayed') return html(`<script>setTimeout(() => document.body.innerHTML = ${JSON.stringify(positive)}, 100)</script>`);
|
||||
if (url.pathname === '/login' && url.searchParams.has('continue')) return Response.redirect(`${origin}/identity`, 302);
|
||||
if (url.pathname === '/login-hop') return Response.redirect(`${origin}/login?continue=1`, 302);
|
||||
if (url.pathname === '/redirect-cross') return Response.redirect(`${otherOrigin}/identity`, 302);
|
||||
if (url.pathname === '/roundtrip') return Response.redirect(`${otherOrigin}/back`, 302);
|
||||
if (url.pathname.startsWith('/status/')) return html(positive, Number(url.pathname.split('/')[2]));
|
||||
return html(positive);
|
||||
} });
|
||||
origin = `http://127.0.0.1:${server.port}`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.close();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
server?.stop(true);
|
||||
otherServer?.stop(true);
|
||||
});
|
||||
|
||||
test('verifies a reloaded protected page only with an exact visible identity and synthetic session', async () => {
|
||||
await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]);
|
||||
await page.goto(`${origin}/protected`);
|
||||
expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: true, reason: 'verified', status: 200 });
|
||||
});
|
||||
|
||||
for (const path of ['/normalized', '/one-visible', '/hidden-child', '/delayed']) {
|
||||
test(`accepts one normalized visible identity at ${path}`, async () => {
|
||||
await page.goto(`${origin}${path}`);
|
||||
const result = await verifyCookieAuthentication(page, { ...options, expectedIdentity: ` \n${identity} `, timeoutMs: 1000 }, origin);
|
||||
expect(result).toEqual({ verified: true, reason: 'verified', status: 200 });
|
||||
expect(JSON.stringify(result)).not.toContain(identity);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [path, reason] of [
|
||||
['/wrong', 'identity_mismatch'], ['/hidden', 'identity_missing'], ['/duplicate', 'identity_ambiguous'],
|
||||
['/public-login', 'identity_missing'], ['/login', 'login_redirect'], ['/sign-in', 'login_redirect'],
|
||||
['/account/LOGIN', 'login_redirect'], ['/login-hop', 'login_redirect'],
|
||||
]) {
|
||||
test(`rejects ${path} with a safe ${reason} result`, async () => {
|
||||
await page.goto(`${origin}${path === '/login-hop' ? '/identity' : path}`);
|
||||
if (path === '/login-hop') await page.evaluate(() => history.replaceState({}, '', '/login-hop'));
|
||||
const result = await verifyCookieAuthentication(page, options, origin);
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.reason).toBe(reason);
|
||||
expect(JSON.stringify(result)).not.toMatch(/Synthetic Account|127\.0\.0\.1/);
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects an unauthenticated 200 login redirect even when login markup contains the expected identity', async () => {
|
||||
await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]);
|
||||
await page.goto(`${origin}/protected`);
|
||||
await context.clearCookies();
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('login_redirect');
|
||||
});
|
||||
|
||||
for (const status of [401, 403, 500, 503]) {
|
||||
test(`rejects HTTP ${status} even with a positive identity`, async () => {
|
||||
await page.goto(`${origin}/status/${status}`);
|
||||
expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: false, reason: 'http_error', status });
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects a cross-origin redirect and a redirect that comes back to the original origin', async () => {
|
||||
for (const destination of ['/redirect-cross', '/roundtrip']) {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(path => history.replaceState({}, '', path), destination);
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects same-origin navigation during a delayed identity assertion', async () => {
|
||||
await page.goto(`${origin}/public-login`);
|
||||
const evaluateAll = page.locator.bind(page);
|
||||
const locator = spyOn(page, 'locator').mockImplementation((selector: string) => {
|
||||
const value = evaluateAll(selector);
|
||||
const filter = value.filter.bind(value);
|
||||
spyOn(value, 'filter').mockImplementation((filterOptions: any) => {
|
||||
const filtered = filter(filterOptions);
|
||||
const evaluate = filtered.evaluateAll.bind(filtered);
|
||||
spyOn(filtered, 'evaluateAll').mockImplementation(async (...args: any[]) => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
return evaluate(...args as [any, any]);
|
||||
});
|
||||
return filtered;
|
||||
});
|
||||
return value;
|
||||
});
|
||||
try {
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed');
|
||||
} finally {
|
||||
locator.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('times out an actual Chromium reload without asserting a login', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => history.replaceState({}, '', '/slow'));
|
||||
try {
|
||||
expect(await verifyCookieAuthentication(page, { ...options, timeoutMs: 100 }, origin))
|
||||
.toEqual({ verified: false, reason: 'timeout' });
|
||||
} finally {
|
||||
releaseSlowResponse?.();
|
||||
}
|
||||
});
|
||||
|
||||
test('sanitizes invalid selector errors and handles a closed target', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
const result = await verifyCookieAuthentication(page, { ...options, identitySelector: '[synthetic-token' }, origin);
|
||||
expect(result.reason).toBe('verification_failed');
|
||||
expect(JSON.stringify(result)).not.toContain('synthetic-token');
|
||||
await page.close();
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_closed');
|
||||
expect(await clearCookieTargetStorage(page, origin).then(() => null, error => error)).toMatchObject({ code: 'target_closed' });
|
||||
});
|
||||
|
||||
test('preserves all storage during an explicit authentication check', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
});
|
||||
expect((await verifyCookieAuthentication(page, options, origin)).verified).toBe(true);
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')]))
|
||||
.toEqual(['original', 'original']);
|
||||
});
|
||||
|
||||
test('clears only exact-origin local storage and target-tab session storage', async () => {
|
||||
const sibling = await context.newPage();
|
||||
const other = await context.newPage();
|
||||
const otherHost = await context.newPage();
|
||||
const independentProfile = await browser.newContext();
|
||||
try {
|
||||
const independent = await independentProfile.newPage();
|
||||
const pages = [page, sibling, other, otherHost, independent];
|
||||
await Promise.all(pages.map((tab, index) => tab.goto(index === 2 ? `${otherOrigin}/identity`
|
||||
: index === 3 ? `http://localhost:${server.port}/identity` : `${origin}/identity`)));
|
||||
for (const tab of pages) await tab.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
});
|
||||
await clearCookieTargetStorage(page, origin);
|
||||
const storage = await Promise.all(pages.map(tab => tab.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])));
|
||||
expect(storage).toEqual([[null, null], [null, 'original'], ['original', 'original'], ['original', 'original'], ['original', 'original']]);
|
||||
} finally {
|
||||
await independentProfile.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a different scheme, host, or port without clearing storage', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => localStorage.setItem('local', 'original'));
|
||||
for (const expectedOrigin of [origin.replace('http:', 'https:'), otherOrigin, `http://localhost:${server.port}`]) {
|
||||
expect(await clearCookieTargetStorage(page, expectedOrigin).then(() => null, error => error)).toMatchObject({ code: 'target_changed' });
|
||||
expect(await page.evaluate(() => localStorage.getItem('local'))).toBe('original');
|
||||
}
|
||||
});
|
||||
|
||||
test('checks target origin and URL inside the storage operation after dispatch races', async () => {
|
||||
for (const destination of [`${otherOrigin}/identity`, `${origin}/other-path`]) {
|
||||
const target = await context.newPage();
|
||||
await target.goto(`${origin}/identity`);
|
||||
const other = await context.newPage();
|
||||
await other.goto(destination);
|
||||
await other.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
});
|
||||
const connect = context.newCDPSession.bind(context);
|
||||
let navigation: Promise<void> | undefined;
|
||||
const raced = spyOn(context, 'newCDPSession').mockImplementation(async attachedTarget => {
|
||||
const session = await connect(attachedTarget);
|
||||
const send = session.send.bind(session);
|
||||
spyOn(session, 'send').mockImplementation(async (method: any, params: any) => {
|
||||
if (method === 'Runtime.callFunctionOn') {
|
||||
navigation = (async () => {
|
||||
await target.goto(destination);
|
||||
await target.evaluate(() => sessionStorage.setItem('session', 'original'));
|
||||
})();
|
||||
await navigation;
|
||||
}
|
||||
return send(method, params);
|
||||
});
|
||||
return session;
|
||||
});
|
||||
try {
|
||||
const error = await clearCookieTargetStorage(target, origin).then(() => null, error => error);
|
||||
expect(error).toMatchObject({ code: 'target_changed' });
|
||||
expect(navigation).toBeDefined();
|
||||
await navigation;
|
||||
} finally {
|
||||
raced.mockRestore();
|
||||
}
|
||||
expect(await target.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']);
|
||||
await target.close();
|
||||
await other.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('reports a partial reset safely when session storage clearing fails', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
Object.defineProperty(sessionStorage, 'clear', { value() { throw new Error('synthetic-token'); } });
|
||||
});
|
||||
const connect = context.newCDPSession.bind(context);
|
||||
const failing = spyOn(context, 'newCDPSession').mockImplementation(async target => {
|
||||
const session = await connect(target);
|
||||
const send = session.send.bind(session);
|
||||
spyOn(session, 'send').mockImplementation(async (method: any, params: any) => {
|
||||
if (method === 'Runtime.callFunctionOn') await send('Runtime.evaluate', {
|
||||
uniqueContextId: params.uniqueContextId,
|
||||
expression: "Object.defineProperty(sessionStorage, 'clear', { value() { throw new Error('synthetic-token'); } })",
|
||||
returnByValue: true, silent: true,
|
||||
});
|
||||
return send(method, params);
|
||||
});
|
||||
return session;
|
||||
});
|
||||
try {
|
||||
await clearCookieTargetStorage(page, origin);
|
||||
throw new Error('Expected reset failure');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect((error as CookieImportError).code).toBe('storage_reset_failed');
|
||||
expect(String(error)).not.toContain('synthetic-token');
|
||||
} finally {
|
||||
failing.mockRestore();
|
||||
}
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual([null, 'original']);
|
||||
});
|
||||
|
||||
test('uses a native isolated clock despite main-world Date and performance tampering', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
Date.now = () => 0;
|
||||
Object.defineProperty(performance, 'now', { value: () => 0 });
|
||||
Object.defineProperty(performance, 'timeOrigin', { value: 0 });
|
||||
});
|
||||
await clearCookieTargetStorage(page, origin);
|
||||
expect(await page.evaluate(() => [Date.now(), performance.now(), performance.timeOrigin])).toEqual([0, 0, 0]);
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual([null, null]);
|
||||
});
|
||||
|
||||
test('a real late isolated dispatch cannot clear storage after the host timeout even with forged page clocks', async () => {
|
||||
await page.goto(`${origin}/identity`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('local', 'original');
|
||||
sessionStorage.setItem('session', 'original');
|
||||
Date.now = () => 0;
|
||||
Object.defineProperty(performance, 'now', { value: () => 0 });
|
||||
Object.defineProperty(performance, 'timeOrigin', { value: 0 });
|
||||
});
|
||||
const connect = context.newCDPSession.bind(context);
|
||||
const observer = await connect(page);
|
||||
await observer.send('Runtime.enable');
|
||||
const tree = await observer.send('Page.getFrameTree');
|
||||
await observer.send('Page.createIsolatedWorld', { frameId: tree.frameTree.frame.id, worldName: 'gstack-cookie-storage-reset', grantUniveralAccess: false });
|
||||
const dispatched = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<any>();
|
||||
let delayedParameters: any;
|
||||
const delaying = spyOn(context, 'newCDPSession').mockImplementation(async target => {
|
||||
const session = await connect(target);
|
||||
const send = session.send.bind(session);
|
||||
spyOn(session, 'send').mockImplementation(async (method: any, params: any) => {
|
||||
if (method === 'Runtime.callFunctionOn') {
|
||||
delayedParameters = params;
|
||||
dispatched.resolve();
|
||||
return release.promise;
|
||||
}
|
||||
return send(method, params);
|
||||
});
|
||||
return session;
|
||||
});
|
||||
try {
|
||||
const reset = clearCookieTargetStorage(page, origin);
|
||||
await dispatched.promise;
|
||||
expect(await reset.then(() => null, error => error)).toMatchObject({ code: 'storage_reset_timeout' });
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']);
|
||||
const late = await observer.send('Runtime.callFunctionOn', delayedParameters);
|
||||
expect(late.result.value).toBe('storage_reset_timeout');
|
||||
expect(await page.evaluate(() => [Date.now(), performance.now(), performance.timeOrigin])).toEqual([0, 0, 0]);
|
||||
expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])).toEqual(['original', 'original']);
|
||||
release.resolve(late);
|
||||
} finally {
|
||||
release.resolve({ result: { value: 'storage_reset_timeout' } });
|
||||
delaying.mockRestore();
|
||||
await observer.detach();
|
||||
}
|
||||
}, 25_000);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { CookieImportError, importCookies } from '../src/cookie-import-browser';
|
||||
|
||||
let home: string;
|
||||
let homedir: ReturnType<typeof spyOn>;
|
||||
let platform: PropertyDescriptor;
|
||||
let spawn: typeof Bun.spawn;
|
||||
|
||||
function fixture(encrypted: Buffer, hostPlatform: 'darwin' | 'linux' | 'win32' = 'darwin') {
|
||||
const folder = hostPlatform === 'darwin' ? 'Library/Application Support/Dia/User Data/Default'
|
||||
: hostPlatform === 'linux' ? '.config/chromium/Default' : 'AppData/Local/Chromium/User Data/Default';
|
||||
const dir = path.join(home, folder);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(home) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run("INSERT INTO cookies VALUES ('.fixture.test', 'session', '', ?, '/', 0, 1, 1, 0, 1)", [encrypted]);
|
||||
db.close();
|
||||
}
|
||||
|
||||
function cbcCookie(password: string, prefix = 'v10') {
|
||||
const key = crypto.pbkdf2Sync(password, 'saltysalt', prefix === 'v11' ? 1 : 1003, 16, 'sha1');
|
||||
const cipher = crypto.createCipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20));
|
||||
return Buffer.concat([Buffer.from(prefix), cipher.update(Buffer.concat([Buffer.alloc(32), Buffer.from('fixture-value')])), cipher.final()]);
|
||||
}
|
||||
|
||||
function pipe(content?: string): { stream: ReadableStream<Uint8Array>; close(): void; cancelled(): boolean } {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array>;
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value;
|
||||
if (content) controller.enqueue(Buffer.from(content));
|
||||
},
|
||||
cancel() { cancelled = true; },
|
||||
});
|
||||
return { stream, close: () => controller.close(), cancelled: () => cancelled };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-credential-deadline-'));
|
||||
homedir = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
spawn = Bun.spawn;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Bun.spawn = spawn;
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
homedir.mockRestore();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('credential subprocess whole-operation deadline', () => {
|
||||
for (const held of ['stdout', 'stderr'] as const) {
|
||||
test(`rejects an exited Keychain process when ${held} remains open`, async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(Buffer.from('v10synthetic'));
|
||||
const stdout = pipe(held === 'stdout' ? 'fixture-password' : undefined);
|
||||
const stderr = pipe(held === 'stderr' ? 'private-error-detail' : undefined);
|
||||
if (held !== 'stdout') stdout.close();
|
||||
if (held !== 'stderr') stderr.close();
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream, kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 19 as any; });
|
||||
try {
|
||||
const result = importCookies('dia', ['fixture.test']);
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
await expect(result).rejects.toMatchObject({ code: 'keychain_timeout', action: 'retry' });
|
||||
expect(killed).toBe(1);
|
||||
expect(held === 'stdout' ? stdout.cancelled() : stderr.cancelled()).toBe(true);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('bounds a process that never exits', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(Buffer.from('v10synthetic'));
|
||||
const stdout = pipe();
|
||||
const stderr = pipe();
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: new Promise<number>(() => {}), stdout: stdout.stream, stderr: stderr.stream, kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 20 as any; });
|
||||
try {
|
||||
const result = importCookies('dia', ['fixture.test']);
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
await expect(result).rejects.toMatchObject({ code: 'keychain_timeout' });
|
||||
expect(killed).toBe(1);
|
||||
expect(stdout.cancelled()).toBe(true);
|
||||
expect(stderr.cancelled()).toBe(true);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('caps credential output and does not expose it in errors', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(Buffer.from('v10synthetic'));
|
||||
const privateValue = 'private-credential-output';
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob([privateValue.repeat(4_000)]).stream(),
|
||||
stderr: new Blob([]).stream(), kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let error: any;
|
||||
try { await importCookies('dia', ['fixture.test']); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('keychain_error');
|
||||
expect(error.message).not.toContain(privateValue);
|
||||
expect(killed).toBe(1);
|
||||
});
|
||||
|
||||
test('preserves Keychain success and denied/nonexistent policy', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
fixture(cbcCookie('fixture-password'));
|
||||
for (const [code, stderr, expected] of [[0, '', 'fixture-value'], [1, 'user canceled private-detail', 'keychain_denied'], [1, 'could not be found private-detail', 'keychain_not_found']] as const) {
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(code), stdout: new Blob([code ? '' : 'fixture-password\n']).stream(),
|
||||
stderr: new Blob([stderr]).stream(), kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn;
|
||||
if (!code) expect((await importCookies('dia', ['fixture.test'])).cookies[0].value).toBe(expected);
|
||||
else await expect(importCookies('dia', ['fixture.test'])).rejects.toMatchObject({ code: expected });
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves Linux secret lookup through concurrent pipe draining', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||
fixture(cbcCookie('test-linux-secret', 'v11'), 'linux');
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob(['test-linux-secret\n']).stream(),
|
||||
stderr: new Blob([]).stream(), kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn;
|
||||
expect((await importCookies('chromium', ['fixture.test'])).cookies[0].value).toBe('fixture-value');
|
||||
});
|
||||
|
||||
test('bounds a Linux secret lookup with an exited child and inherited pipe', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||
fixture(cbcCookie('fixture-other-password', 'v11'), 'linux');
|
||||
const stdout = pipe();
|
||||
const stderr = pipe();
|
||||
stderr.close();
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream,
|
||||
kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 22 as any; });
|
||||
try {
|
||||
const result = importCookies('chromium', ['fixture.test']);
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
expect(await result).toMatchObject({ count: 0, failed: 1 });
|
||||
expect(killed).toBe(1);
|
||||
expect(stdout.cancelled()).toBe(true);
|
||||
} finally { timer.mockRestore(); }
|
||||
});
|
||||
|
||||
test('bounds DPAPI stdout and preserves successful extraction without exposing input', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||
const key = Buffer.alloc(32, 0x42);
|
||||
const nonce = Buffer.alloc(12, 0x22);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
||||
const encrypted = Buffer.concat([Buffer.from('v10'), nonce, cipher.update('fixture-value'), cipher.final(), cipher.getAuthTag()]);
|
||||
fixture(encrypted, 'win32');
|
||||
fs.writeFileSync(path.join(home, 'AppData/Local/Chromium/User Data/Local State'), JSON.stringify({ os_crypt: { encrypted_key: Buffer.from('DPAPIsynthetic').toString('base64') } }));
|
||||
const stdout = pipe(key.toString('base64'));
|
||||
const stderr = pipe();
|
||||
let sent = '';
|
||||
let killed = 0;
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: stdout.stream, stderr: stderr.stream,
|
||||
stdin: { write(value: string) { sent = value; }, end() {} }, kill() { killed++; } })) as typeof Bun.spawn;
|
||||
let expire: (() => void) | undefined;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockImplementation((callback: any) => { expire = callback; return 21 as any; });
|
||||
try {
|
||||
const result = importCookies('chromium', ['fixture.test']);
|
||||
expect(sent).toBe(Buffer.from('synthetic').toString('base64'));
|
||||
expect(expire).toBeFunction();
|
||||
expire!();
|
||||
await expect(result).rejects.toMatchObject({ code: 'keychain_timeout' });
|
||||
expect(killed).toBe(1);
|
||||
} finally { timer.mockRestore(); }
|
||||
Bun.spawn = (() => ({ exited: Promise.resolve(0), stdout: new Blob([key.toString('base64')]).stream(),
|
||||
stderr: new Blob([]).stream(), stdin: { write(value: string) { sent = value; }, end() {} }, kill() { throw new Error('Unexpected kill'); } })) as typeof Bun.spawn;
|
||||
expect((await importCookies('chromium', ['fixture.test'])).cookies[0].value).toBe('fixture-value');
|
||||
});
|
||||
|
||||
test('Node polyfill replay streams work with the bundled importer and a synthetic credential child', () => {
|
||||
const bundle = path.join(home, 'importer.mjs');
|
||||
const build = spawnSync(process.execPath, ['build', path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'), '--target=node', '--outfile', bundle], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(build.status).toBe(0);
|
||||
const node = Bun.which('node')!;
|
||||
const polyfill = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
|
||||
const nodeFixture = path.join(home, 'node-fixture');
|
||||
fs.mkdirSync(nodeFixture);
|
||||
const dir = path.join(nodeFixture, 'Library/Application Support/Dia/User Data/Default');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(nodeFixture) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run("INSERT INTO cookies VALUES ('.fixture.test', 'session', '', ?, '/', 0, 1, 1, 0, 1)", [cbcCookie('fixture-node-password')]);
|
||||
db.close();
|
||||
const result = spawnSync(node, ['--input-type=module', '-e', `
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
require(process.argv[1]);
|
||||
const original = Bun.spawn;
|
||||
Bun.spawn = (_command, options) => original([process.execPath, '-e', 'console.log("fixture-node-password")'], options);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
const { importCookies } = await import(process.argv[2]);
|
||||
const result = await importCookies('dia', ['fixture.test']);
|
||||
console.log(JSON.stringify({ count: result.count, value: result.cookies[0]?.value }));
|
||||
`, polyfill, pathToFileURL(bundle).href], { encoding: 'utf8', timeout: 15_000,
|
||||
env: { ...process.env, HOME: nodeFixture, USERPROFILE: nodeFixture, NODE_NO_WARNINGS: '1' } });
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.stderr).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ count: 1, value: 'fixture-value' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { openCookieDatabase } from '../src/cookie-database';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'cookie-db-')));
|
||||
const dbPath = path.join(root, 'Cookies');
|
||||
const adapterPath = path.join(root, 'cookie-database.mjs');
|
||||
const corruptPath = path.join(root, 'private-corrupt-fixture');
|
||||
const missingPath = path.join(root, 'private-missing-fixture');
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node.js is required for cookie database adapter tests');
|
||||
|
||||
beforeAll(() => {
|
||||
const database = new Database(dbPath);
|
||||
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, encrypted_value BLOB, expires_utc INTEGER, has_expires INTEGER)');
|
||||
const insert = database.query('INSERT INTO cookies VALUES (?, ?, ?, ?, ?)');
|
||||
insert.run('.fixture.test', 'large-expiry', new Uint8Array([0, 127, 255]), 13300000000000001n, 1);
|
||||
insert.run('.fixture.test', 'session', new Uint8Array([]), 0, 0);
|
||||
insert.run('.fixture.test', 'expired', new Uint8Array([]), 500, 1);
|
||||
insert.run('.other.test', 'other-session', new Uint8Array([]), 0, 0);
|
||||
database.close();
|
||||
chmodSync(dbPath, 0o600);
|
||||
writeFileSync(corruptPath, 'private-fixture-not-a-database', { mode: 0o600 });
|
||||
const source = readFileSync(path.resolve(import.meta.dir, '../src/cookie-database.ts'), 'utf8');
|
||||
writeFileSync(adapterPath, new Bun.Transpiler({ loader: 'ts', target: 'node' }).transformSync(source), { mode: 0o600 });
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function runNode(source: string, flags: string[] = []) {
|
||||
const result = spawnSync(node!, ['--input-type=module', ...flags, '-e', source, pathToFileURL(adapterPath).href, dbPath, corruptPath, missingPath], {
|
||||
encoding: 'utf8', timeout: 10_000, env: {
|
||||
PATH: path.dirname(node!), HOME: root, USERPROFILE: root, TEMP: root, TMP: root,
|
||||
NODE_NO_WARNINGS: '1', ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
||||
},
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
describe('cookie database runtime adapter', () => {
|
||||
test('reproduces the old Node null stub before cookie queries can run', () => {
|
||||
const result = runNode(`
|
||||
import { existsSync } from 'node:fs';
|
||||
const Database = null;
|
||||
let attemptedQuery = false;
|
||||
try {
|
||||
const database = new Database(process.argv[2], { readonly: true });
|
||||
attemptedQuery = true;
|
||||
database.query('SELECT host_key FROM cookies').all();
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify({ fixtureExists: existsSync(process.argv[2]), error: error.name, attemptedQuery }));
|
||||
}
|
||||
`);
|
||||
expect(result).toEqual({ fixtureExists: true, error: 'TypeError', attemptedQuery: false });
|
||||
});
|
||||
|
||||
test('Bun returns JSON-safe domain counts and preserves precise expiry integers and blobs', () => {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
const domains = database.query('SELECT host_key AS domain, COUNT(*) AS count FROM cookies WHERE has_expires = 0 OR expires_utc > ? GROUP BY host_key ORDER BY count DESC').all(1000);
|
||||
expect(domains).toEqual([{ domain: '.fixture.test', count: 2 }, { domain: '.other.test', count: 1 }]);
|
||||
expect(JSON.parse(JSON.stringify(domains))).toEqual(domains);
|
||||
const rows = database.query('SELECT name, encrypted_value, expires_utc, has_expires FROM cookies WHERE host_key IN (?, ?) AND expires_utc = ?').all('fixture.test', '.fixture.test', 13300000000000001n) as any[];
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].expires_utc).toBe(13300000000000001n);
|
||||
expect(rows[0].has_expires).toBe(1);
|
||||
expect(Array.from(rows[0].encrypted_value)).toEqual([0, 127, 255]);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Node uses built-in SQLite even with a Bun server polyfill and preserves the same values', () => {
|
||||
const result = runNode(`
|
||||
globalThis.Bun = { fixturePolyfill: true };
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const domains = database.query('SELECT host_key AS domain, COUNT(*) AS count FROM cookies WHERE has_expires = 0 OR expires_utc > ? GROUP BY host_key ORDER BY count DESC').all(1000);
|
||||
const rows = database.query('SELECT name, encrypted_value, expires_utc, has_expires FROM cookies WHERE host_key IN (?, ?) AND expires_utc = ?').all('fixture.test', '.fixture.test', 13300000000000001n);
|
||||
console.log(JSON.stringify({ domains, rowCount: rows.length, expiry: String(rows[0].expires_utc), expiryType: typeof rows[0].expires_utc,
|
||||
flag: rows[0].has_expires, blob: Array.from(rows[0].encrypted_value) }));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
expect(result).toEqual({ domains: [{ domain: '.fixture.test', count: 2 }, { domain: '.other.test', count: 1 }],
|
||||
rowCount: 1, expiry: '13300000000000001', expiryType: 'bigint', flag: 1, blob: [0, 127, 255] });
|
||||
});
|
||||
|
||||
for (const runtime of ['Bun', 'Node']) {
|
||||
test(`${runtime} converts only integers inside the safe Number range`, () => {
|
||||
const sql = 'SELECT 9007199254740991 AS safe_positive, -9007199254740991 AS safe_negative, 9007199254740992 AS unsafe_positive, -9007199254740992 AS unsafe_negative, 9223372036854775807 AS maximum, 1.5 AS fractional, NULL AS empty';
|
||||
let row: any;
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
row = database.query(sql).all()[0];
|
||||
row = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, { type: typeof value, value: String(value) }]));
|
||||
} finally { database.close(); }
|
||||
} else {
|
||||
row = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const row = database.query(${JSON.stringify(sql)}).all()[0];
|
||||
console.log(JSON.stringify(Object.fromEntries(Object.entries(row).map(([key, value]) => [key, { type: typeof value, value: String(value) }]))));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
}
|
||||
expect(row).toEqual({ safe_positive: { type: 'number', value: '9007199254740991' }, safe_negative: { type: 'number', value: '-9007199254740991' },
|
||||
unsafe_positive: { type: 'bigint', value: '9007199254740992' }, unsafe_negative: { type: 'bigint', value: '-9007199254740992' },
|
||||
maximum: { type: 'bigint', value: '9223372036854775807' }, fractional: { type: 'number', value: '1.5' }, empty: { type: 'object', value: 'null' } });
|
||||
});
|
||||
|
||||
test(`${runtime} refuses database writes and leaves the source bytes unchanged`, () => {
|
||||
const before = readFileSync(dbPath);
|
||||
const writes = ["INSERT INTO cookies VALUES ('private-insert', 'attempt', NULL, 0, 0)",
|
||||
"UPDATE cookies SET name = 'private-update'", 'DELETE FROM cookies', 'CREATE TABLE private_created_table (value TEXT)'];
|
||||
let results: any[];
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
results = writes.map(sql => {
|
||||
try { database.query(sql).all(); return { wrote: true }; }
|
||||
catch (error: any) { return { code: error.code, message: error.message }; }
|
||||
});
|
||||
} finally { database.close(); }
|
||||
} else {
|
||||
results = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const results = ${JSON.stringify(writes)}.map(sql => {
|
||||
try { database.query(sql).all(); return { wrote: true }; }
|
||||
catch (error) { return { code: error.code, message: error.message }; }
|
||||
});
|
||||
console.log(JSON.stringify(results));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
}
|
||||
expect(results).toHaveLength(4);
|
||||
for (const result of results) {
|
||||
expect(result.code).toBe('SQLITE_READONLY');
|
||||
expect(result.message).not.toContain('private-');
|
||||
expect(result.message).not.toContain('private_created_table');
|
||||
}
|
||||
expect(readFileSync(dbPath)).toEqual(before);
|
||||
});
|
||||
|
||||
test(`${runtime} keeps parameters bound instead of interpreting SQL-looking values`, () => {
|
||||
const value = ".fixture.test'; DROP TABLE cookies; --";
|
||||
let rows: unknown[];
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
try {
|
||||
rows = database.query('SELECT name FROM cookies WHERE host_key = ?').all(value);
|
||||
expect(database.query('SELECT COUNT(*) AS count FROM cookies').all()).toEqual([{ count: 4 }]);
|
||||
} finally { database.close(); }
|
||||
} else {
|
||||
const result = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
try {
|
||||
const rows = database.query('SELECT name FROM cookies WHERE host_key = ?').all(${JSON.stringify(value)});
|
||||
console.log(JSON.stringify({ rows, counts: database.query('SELECT COUNT(*) AS count FROM cookies').all() }));
|
||||
} finally { database.close(); }
|
||||
`);
|
||||
rows = result.rows;
|
||||
expect(result.counts).toEqual([{ count: 4 }]);
|
||||
}
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
test(`${runtime} supports a mutable close method for copied-profile cleanup`, () => {
|
||||
if (runtime === 'Bun') {
|
||||
const database = openCookieDatabase(dbPath);
|
||||
const close = database.close.bind(database);
|
||||
let cleanup = false;
|
||||
database.close = () => { close(); cleanup = true; };
|
||||
database.close();
|
||||
expect(cleanup).toBe(true);
|
||||
expect(() => database.query('SELECT 1').all()).toThrow();
|
||||
} else {
|
||||
expect(runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const database = openCookieDatabase(process.argv[2]);
|
||||
const close = database.close.bind(database);
|
||||
let cleanup = false;
|
||||
database.close = () => { close(); cleanup = true; };
|
||||
database.close();
|
||||
let closed = false;
|
||||
try { database.query('SELECT 1').all(); } catch { closed = true; }
|
||||
console.log(JSON.stringify({ cleanup, closed }));
|
||||
`)).toEqual({ cleanup: true, closed: true });
|
||||
}
|
||||
});
|
||||
|
||||
test(`${runtime} sanitizes open, corrupt-database, query, and binding failures`, () => {
|
||||
const exercise = (open: typeof openCookieDatabase, missing: string, corrupt: string, valid: string) => {
|
||||
const results: { code: string; message: string }[] = [];
|
||||
for (const file of [missing, corrupt]) {
|
||||
let database: ReturnType<typeof openCookieDatabase> | undefined;
|
||||
try { database = open(file); database.query('SELECT * FROM cookies').all(); }
|
||||
catch (error: any) { results.push({ code: error.code, message: error.message }); }
|
||||
finally { database?.close(); }
|
||||
}
|
||||
const database = open(valid);
|
||||
try {
|
||||
try { database.query('SELECT private_query_sentinel FROM cookies').all(); }
|
||||
catch (error: any) { results.push({ code: error.code, message: error.message }); }
|
||||
try { database.query('SELECT ? AS value').all(Symbol('private-binding-sentinel')); }
|
||||
catch (error: any) { results.push({ code: error.code, message: error.message }); }
|
||||
} finally { database.close(); }
|
||||
return results;
|
||||
};
|
||||
const results = runtime === 'Bun' ? exercise(openCookieDatabase, missingPath, corruptPath, dbPath) : runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const exercise = ${exercise.toString()};
|
||||
console.log(JSON.stringify(exercise(openCookieDatabase, process.argv[4], process.argv[3], process.argv[2])));
|
||||
`);
|
||||
expect(results.map((result: any) => result.code)).toEqual(['SQLITE_CANTOPEN', 'SQLITE_CORRUPT', 'SQLITE_ERROR', 'SQLITE_ERROR']);
|
||||
expect(JSON.stringify(results)).not.toMatch(/private[-_]|Cookies|cookie-db-/);
|
||||
expect(existsSync(missingPath)).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ['missing module', 'missing export']) {
|
||||
test(`Node import remains available without SQLite and cookie use fails safely (${failure})`, () => {
|
||||
const result = runNode(`
|
||||
import Module from 'node:module';
|
||||
const original = Module._load;
|
||||
let loads = 0;
|
||||
Module._load = function(name, ...args) {
|
||||
if (name === 'node:sqlite') {
|
||||
loads++;
|
||||
if (${JSON.stringify(failure)} === 'missing export') return {};
|
||||
throw new Error('private-module-error');
|
||||
}
|
||||
return original.call(this, name, ...args);
|
||||
};
|
||||
try {
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
const loadsAtImport = loads;
|
||||
try { openCookieDatabase(process.argv[2]); }
|
||||
catch (error) { console.log(JSON.stringify({ loadsAtImport, loads, code: error.code, message: error.message })); }
|
||||
} finally { Module._load = original; }
|
||||
`);
|
||||
expect(result.loadsAtImport).toBe(0);
|
||||
expect(result.loads).toBe(1);
|
||||
expect(result.code).toBe('sqlite_unavailable');
|
||||
expect(result.message).toContain('Node.js 22.13 or newer');
|
||||
expect(result.message).toContain('Upgrade Node.js or enable SQLite');
|
||||
expect(result.message).not.toContain('private-module-error');
|
||||
expect(result.message).not.toContain(dbPath);
|
||||
});
|
||||
}
|
||||
|
||||
test('actual Node with SQLite disabled can import the module and gets version guidance only on cookie use', () => {
|
||||
const result = runNode(`
|
||||
const { openCookieDatabase } = await import(process.argv[1]);
|
||||
try { openCookieDatabase(process.argv[2]); }
|
||||
catch (error) { console.log(JSON.stringify({ imported: true, code: error.code, message: error.message })); }
|
||||
`, ['--no-experimental-sqlite']);
|
||||
expect(result.imported).toBe(true);
|
||||
expect(result.code).toBe('sqlite_unavailable');
|
||||
expect(result.message).toContain('Node.js 22.13 or newer');
|
||||
expect(result.message).toContain('Upgrade Node.js or enable SQLite');
|
||||
expect(result.message).not.toContain(dbPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterAll, expect, test } from 'bun:test';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, rmdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
import { createFixtureDeleteLease, deleteWithFixtureLease, FixtureDeleteError, type FixtureDeleteBackend } from './fixtures/native-cookie-delete-lease';
|
||||
import { nativeCookieEnvironment } from '../src/cookie-import-native-worker';
|
||||
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'delete-lease-'));
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
const identity = { dev: 7n, ino: 9007199254740993n, mode: 0o100666n };
|
||||
|
||||
function model() {
|
||||
let time = 0;
|
||||
const calls: string[] = [];
|
||||
const backend: FixtureDeleteBackend = {
|
||||
open: () => { calls.push('open'); return 42; },
|
||||
identity: () => { calls.push('identity'); return { ...identity, attributes: 0x80, filesystem: 'NTFS' }; },
|
||||
dispose: () => { calls.push('dispose'); },
|
||||
close: () => { calls.push('close'); },
|
||||
absent: () => { calls.push('absent'); return true; },
|
||||
};
|
||||
const verify = () => { calls.push('verify'); };
|
||||
const clock = { now: () => time, wait: (ms: number) => { calls.push(`wait:${ms}`); time += ms; } };
|
||||
return { backend, calls, verify, clock, advance: (ms: number) => { time += ms; } };
|
||||
}
|
||||
|
||||
test('an admitted identity is deleted once, closed, and checked for absence in that order', () => {
|
||||
const f = model();
|
||||
expect(deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toEqual({ admissionProbes: 1, waitedMs: 0 });
|
||||
expect(f.calls).toEqual(['verify', 'open', 'identity', 'verify', 'dispose', 'close', 'absent']);
|
||||
});
|
||||
|
||||
test('sharing admission can settle inside the same deadline without retrying deletion', () => {
|
||||
const f = model(); let attempts = 0;
|
||||
f.backend.open = () => { f.calls.push('open'); if (++attempts < 3) throw new FixtureDeleteError('admission', 'owned', 32); return 42; };
|
||||
expect(deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toEqual({ admissionProbes: 3, waitedMs: 40 });
|
||||
expect(f.calls.filter(call => call === 'dispose')).toHaveLength(1);
|
||||
expect(f.calls.filter(call => call === 'close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('persistent sharing keeps its first error and performs zero deletes', () => {
|
||||
const f = model(); const first = new FixtureDeleteError('admission', 'owned', 32); let attempts = 0;
|
||||
f.backend.open = () => { attempts++; throw attempts === 1 ? first : new FixtureDeleteError('admission', 'owned', 32); };
|
||||
let failure: unknown;
|
||||
try { deleteWithFixtureLease('owned', identity, 40, f.verify, f.backend, f.clock); } catch (error) { failure = error; }
|
||||
expect(failure).toBe(first);
|
||||
expect(attempts).toBe(2);
|
||||
expect(f.calls).not.toContain('dispose');
|
||||
expect(f.calls).not.toContain('close');
|
||||
});
|
||||
|
||||
test.each([2, 3, 5, 33, 50, 87])('admission error %s is never polled or deleted', code => {
|
||||
const f = model(); const original = new FixtureDeleteError('admission', 'owned', code);
|
||||
f.backend.open = () => { f.calls.push('open'); throw original; };
|
||||
let failure: unknown;
|
||||
try { deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock); } catch (error) { failure = error; }
|
||||
expect(failure).toBe(original);
|
||||
expect(f.calls).toEqual(['verify', 'open']);
|
||||
});
|
||||
|
||||
test.each(['volume', 'inode', 'ReFS', 'readonly', 'directory', 'reparse', 'ancestor'])(
|
||||
'a rejected %s identity closes its handle without deleting', defect => {
|
||||
const f = model();
|
||||
f.backend.identity = () => ({ ...identity, dev: defect === 'volume' ? 8n : identity.dev,
|
||||
ino: defect === 'inode' ? 9007199254740992n : identity.ino, filesystem: defect === 'ReFS' ? 'ReFS' : 'NTFS',
|
||||
attributes: { readonly: 1, directory: 16, reparse: 1024 }[defect] ?? 0x80 });
|
||||
let checks = 0;
|
||||
const verify = () => { if (++checks === 2 && defect === 'ancestor') throw new Error('Owned ancestor changed'); };
|
||||
expect(() => deleteWithFixtureLease('owned', identity, 100, verify, f.backend, f.clock)).toThrow();
|
||||
expect(f.calls.filter(call => call === 'close')).toHaveLength(1);
|
||||
expect(f.calls).not.toContain('dispose');
|
||||
});
|
||||
|
||||
test.each(['before_open', 'after_verify', 'after_identity', 'after_revalidation'])(
|
||||
'expiration %s never grants late deletion', stage => {
|
||||
const f = model(); let checks = 0;
|
||||
if (stage === 'before_open') f.advance(100);
|
||||
const verify = () => { checks++; if (stage === 'after_verify' && checks === 1 || stage === 'after_revalidation' && checks === 2) f.advance(100); };
|
||||
const identify = f.backend.identity;
|
||||
f.backend.identity = (handle, file) => { const result = identify(handle, file); if (stage === 'after_identity') f.advance(100); return result; };
|
||||
expect(() => deleteWithFixtureLease('owned', identity, 100, verify, f.backend, f.clock)).toThrow('deadline');
|
||||
expect(f.calls).not.toContain('dispose');
|
||||
expect(f.calls.filter(call => call === 'close')).toHaveLength(stage.startsWith('before') || stage === 'after_verify' ? 0 : 1);
|
||||
});
|
||||
|
||||
test('a disposition failure is not retried and survives close', () => {
|
||||
const f = model(); const original = new FixtureDeleteError('disposition', 'owned', 32);
|
||||
f.backend.dispose = () => { f.calls.push('dispose'); throw original; };
|
||||
let failure: unknown;
|
||||
try { deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock); } catch (error) { failure = error; }
|
||||
expect(failure).toBe(original);
|
||||
expect(f.calls.filter(call => call === 'dispose')).toHaveLength(1);
|
||||
expect(f.calls.at(-1)).toBe('close');
|
||||
expect(f.calls.some(call => call.startsWith('wait'))).toBe(false);
|
||||
});
|
||||
|
||||
test.each(['close', 'absence'])('%s failure never reports completed removal', stage => {
|
||||
const f = model();
|
||||
if (stage === 'close') f.backend.close = () => { throw new FixtureDeleteError('close', 'owned', 6); };
|
||||
else f.backend.absent = () => false;
|
||||
expect(() => deleteWithFixtureLease('owned', identity, 100, f.verify, f.backend, f.clock)).toThrow(stage);
|
||||
});
|
||||
|
||||
test('the actual Windows binding uses the checked handle, NTFS identity, and Ex flags without a fallback', () => {
|
||||
const source = readFileSync(path.join(import.meta.dir, 'fixtures/native-cookie-delete-lease.ts'), 'utf8');
|
||||
const start = source.indexOf('export function createFixtureDeleteLease(');
|
||||
expect(start).toBeGreaterThan(0);
|
||||
const body = new Bun.Transpiler({ loader: 'ts' }).transformSync(source.slice(start).replace('export function', 'function'));
|
||||
const calls: string[] = [];
|
||||
const api = {
|
||||
CreateFileW(name: Buffer, access: number, share: number, security: unknown, creation: number, flags: number, template: number) {
|
||||
calls.push('open');
|
||||
expect(name.toString('utf16le')).toBe('namespaced-owned\0');
|
||||
expect([access, share, security, creation, flags, template]).toEqual([0x10080, 3, null, 3, 0x00200000, 0]);
|
||||
return 42;
|
||||
},
|
||||
GetLastError() { calls.push('last-error'); return 0; },
|
||||
GetFileInformationByHandle(handle: number, info: Buffer) {
|
||||
calls.push('identity'); expect(handle).toBe(42); expect(info.length).toBe(52);
|
||||
info.writeUInt32LE(0x80, 0); info.writeUInt32LE(Number(identity.dev), 28);
|
||||
info.writeUInt32LE(Number(identity.ino >> 32n), 44); info.writeUInt32LE(Number(identity.ino & 0xffffffffn), 48);
|
||||
return 1;
|
||||
},
|
||||
GetVolumeInformationByHandleW(handle: number, name: unknown, length: number, serial: unknown, component: unknown, flags: unknown, filesystem: Buffer, size: number) {
|
||||
calls.push('filesystem'); expect([handle, name, length, serial, component, flags, size]).toEqual([42, null, 0, null, null, null, 64]);
|
||||
expect(filesystem.length).toBe(128); filesystem.write('NTFS\0', 'utf16le'); return 1;
|
||||
},
|
||||
SetFileInformationByHandle(handle: number, kind: number, flags: Buffer, size: number) {
|
||||
calls.push('dispose'); expect([handle, kind, flags.length, flags.readUInt32LE(0), size]).toEqual([42, 21, 4, 3, 4]); return 1;
|
||||
},
|
||||
CloseHandle(handle: number) { calls.push('close'); expect(handle).toBe(42); return 1; },
|
||||
};
|
||||
const factory = new Function('process', 'dlopen', 'FFIType', 'ptr', 'unlinkSync', 'lstatSync', 'toNamespacedPath',
|
||||
'FixtureDeleteError', 'deleteWithFixtureLease', 'leaseClock', `${body}\nreturn createFixtureDeleteLease;`)(
|
||||
{ platform: 'win32' }, (name: string) => { expect(name).toBe('kernel32.dll'); return { symbols: api, close() { calls.push('unload'); } }; },
|
||||
{ ptr: 'ptr', u32: 'u32', u64: 'u64', i32: 'i32' }, (value: Buffer) => value,
|
||||
() => { throw new Error('Regular files must not use a path-based fallback'); },
|
||||
() => { calls.push('absence'); throw Object.assign(new Error('Absent'), { code: 'ENOENT' }); },
|
||||
(file: string) => { expect(file).toBe('owned'); return 'namespaced-owned'; },
|
||||
FixtureDeleteError, deleteWithFixtureLease, { now: () => 0, wait: () => { throw new Error('Unexpected wait'); } });
|
||||
const lease = factory(100);
|
||||
lease.unlink('owned', identity, () => { calls.push('verify'); });
|
||||
lease.close();
|
||||
expect(calls).toEqual(['verify', 'open', 'last-error', 'identity', 'filesystem', 'verify', 'dispose', 'close', 'absence', 'unload']);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('the native lease refuses a persistent no-delete-sharing holder', () => {
|
||||
const file = path.join(root, 'persistent'); writeFileSync(file, 'fixture-only');
|
||||
const expected = lstatSync(file, { bigint: true });
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
const name = Buffer.from(file + '\0', 'utf16le');
|
||||
const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 3, null, 3, 0x80, 0);
|
||||
const lease = createFixtureDeleteLease(performance.now() + 1_000);
|
||||
try {
|
||||
expect(BigInt(handle)).not.toBe(0xffffffffffffffffn);
|
||||
expect(BigInt(handle)).not.toBe(0n);
|
||||
let failure: unknown;
|
||||
try { lease.unlink(file, expected, () => {}); } catch (error) { failure = error; }
|
||||
expect(failure).toBeInstanceOf(FixtureDeleteError);
|
||||
expect(failure).toMatchObject({ stage: 'admission', win32Error: 32, code: 'EBUSY' });
|
||||
expect(readFileSync(file, 'utf8')).toBe('fixture-only');
|
||||
expect(lstatSync(file, { bigint: true }).ino).toBe(expected.ino);
|
||||
} finally { lease.close(); if (BigInt(handle) !== 0xffffffffffffffffn && BigInt(handle) !== 0n) kernel.symbols.CloseHandle(handle); kernel.close(); }
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('a compatible reader retains its data while the admitted delete removes the namespace', () => {
|
||||
const directory = mkdtempSync(path.join(root, 'reader-'));
|
||||
const file = path.join(directory, 'data'); writeFileSync(file, 'fixture-only');
|
||||
const expected = lstatSync(file, { bigint: true });
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
ReadFile: { args: [FFIType.u64, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
const name = Buffer.from(file + '\0', 'utf16le');
|
||||
const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 7, null, 3, 0x80, 0);
|
||||
const lease = createFixtureDeleteLease(performance.now() + 2_000);
|
||||
try {
|
||||
expect(BigInt(handle)).not.toBe(0xffffffffffffffffn);
|
||||
expect(BigInt(handle)).not.toBe(0n);
|
||||
let checks = 0;
|
||||
lease.unlink(file, expected, () => { if (++checks === 2) expect(() => renameSync(file, path.join(directory, 'moved'))).toThrow(); });
|
||||
expect(checks).toBe(2);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
rmdirSync(directory);
|
||||
const buffer = Buffer.alloc(32), bytes = Buffer.alloc(4);
|
||||
expect(kernel.symbols.ReadFile(handle, ptr(buffer), buffer.length, ptr(bytes), null)).toBe(1);
|
||||
expect(buffer.subarray(0, bytes.readUInt32LE()).toString()).toBe('fixture-only');
|
||||
} finally { lease.close(); if (BigInt(handle) !== 0xffffffffffffffffn && BigInt(handle) !== 0n) kernel.symbols.CloseHandle(handle); kernel.close(); }
|
||||
});
|
||||
|
||||
test.skipIf(process.platform !== 'win32')('an independently released holder admits one identity-bound deletion', async () => {
|
||||
const directory = mkdtempSync(path.join(root, 'released-'));
|
||||
const file = path.join(directory, 'held'), ready = path.join(directory, 'ready'), release = path.join(directory, 'release');
|
||||
writeFileSync(file, 'fixture-only'); const expected = lstatSync(file, { bigint: true });
|
||||
const script = `
|
||||
const { dlopen, FFIType, ptr } = await import('bun:ffi');
|
||||
const { existsSync, writeFileSync } = await import('node:fs');
|
||||
const api = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
const name = Buffer.from(${JSON.stringify(file)} + '\\0', 'utf16le');
|
||||
const handle = api.symbols.CreateFileW(ptr(name), 0x80000000, 3, null, 3, 0x80, 0);
|
||||
if (BigInt(handle) === 0xffffffffffffffffn || BigInt(handle) === 0n) throw new Error('Holder open failed');
|
||||
try {
|
||||
writeFileSync(${JSON.stringify(ready)}, 'ready');
|
||||
while (!existsSync(${JSON.stringify(release)})) await Bun.sleep(10);
|
||||
} finally { if (!api.symbols.CloseHandle(handle)) throw new Error('Holder close failed'); api.close(); }
|
||||
`;
|
||||
const child: ChildProcess = spawn(process.execPath, ['--no-env-file', '--no-install', '--config=NUL', '-e', script],
|
||||
{ env: nativeCookieEnvironment(process.env), stdio: 'ignore', windowsHide: true });
|
||||
let spawnError: Error | undefined;
|
||||
child.once('error', error => { spawnError = error; });
|
||||
const closed = new Promise<void>(resolve => child.once('close', () => resolve()));
|
||||
const timer = setTimeout(() => child.kill(), 5_000);
|
||||
let lease: ReturnType<typeof createFixtureDeleteLease> | undefined;
|
||||
try {
|
||||
const deadline = performance.now() + 3_000;
|
||||
while (!existsSync(ready) && child.exitCode === null && !spawnError && performance.now() < deadline) await Bun.sleep(10);
|
||||
expect(spawnError).toBeUndefined();
|
||||
expect(existsSync(ready)).toBe(true);
|
||||
let blocked = 0;
|
||||
lease = createFixtureDeleteLease(performance.now() + 2_000, () => { blocked++; writeFileSync(release, 'release'); });
|
||||
lease.unlink(file, expected, () => {});
|
||||
expect(blocked).toBe(1);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
await closed;
|
||||
expect(child.exitCode).toBe(0);
|
||||
} finally { clearTimeout(timer); lease?.close(); child.kill(); await closed; }
|
||||
}, 10_000);
|
||||
|
||||
test.skipIf(process.platform !== 'win32').each(['identity', 'readonly', 'reparse'])(
|
||||
'the native lease refuses %s without removing the file or target', defect => {
|
||||
const directory = mkdtempSync(path.join(root, 'refused-'));
|
||||
const file = path.join(directory, 'data'); writeFileSync(file, 'fixture-only');
|
||||
const expected = lstatSync(file, { bigint: true });
|
||||
const destination = path.join(directory, 'destination');
|
||||
if (defect === 'readonly') chmodSync(file, 0o444);
|
||||
if (defect === 'reparse') {
|
||||
unlinkSync(file); mkdirSync(destination); writeFileSync(path.join(destination, 'preserved'), 'fixture-only');
|
||||
symlinkSync(destination, file, 'junction');
|
||||
}
|
||||
const lease = createFixtureDeleteLease(performance.now() + 2_000);
|
||||
try {
|
||||
expect(() => lease.unlink(file, defect === 'identity' ? { ...expected, ino: expected.ino + 1n } : expected, () => {})).toThrow();
|
||||
expect(existsSync(file)).toBe(true);
|
||||
if (defect === 'reparse') expect(readFileSync(path.join(destination, 'preserved'), 'utf8')).toBe('fixture-only');
|
||||
else expect(readFileSync(file, 'utf8')).toBe('fixture-only');
|
||||
} finally { lease.close(); if (defect === 'readonly') chmodSync(file, 0o666); }
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { release, tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { nativeBrowserPaths } from '../src/cookie-import-native';
|
||||
import { nativeCookieEnvironment, probeNativeCookieMember } from '../src/cookie-import-native-worker';
|
||||
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, NATIVE_BROWSER_VERSION_COMMAND, NATIVE_QUALIFICATION_DATA } from '../src/cookie-import-native-integrity';
|
||||
import { createNativeCookieJob, NativeCookieJobError, nativeCookieDiagnostic, type NativeCookieDiagnostic, type NativeCookieJob } from '../src/cookie-import-native-job';
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
console.error('Native cookie qualification requires Windows; no cases ran and no qualification was issued.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.env.GITHUB_ACTIONS !== 'true' || process.env.CI !== 'true') {
|
||||
console.error('Native cookie qualification requires a disposable GitHub Actions runner and never uses an existing browser profile.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const output = mkdtempSync(path.join(process.argv[2] || tmpdir(), 'cookie-native-qualification-'));
|
||||
function incomplete(reason: string, diagnostic?: NativeCookieDiagnostic): never {
|
||||
const receipt = { status: 'incomplete', reason, ...(diagnostic ? { diagnostic } : {}), counts: { pass: 0, fail: 0, skip: 0 }, activation: 'No build was qualified; production extraction remains disabled.' };
|
||||
writeFileSync(path.join(output, 'qualification.json'), JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ ...receipt, artifactDirectory: output }));
|
||||
process.exit(2);
|
||||
}
|
||||
if (Bun.version !== '1.4.0') incomplete('bun_1_4_0_required');
|
||||
let emptyJob: NativeCookieJob | undefined;
|
||||
try {
|
||||
emptyJob = await createNativeCookieJob();
|
||||
if (emptyJob.activeProcesses() !== 0) throw new NativeCookieJobError('job_query');
|
||||
emptyJob.terminate();
|
||||
if (emptyJob.activeProcesses() !== 0) throw new NativeCookieJobError('job_query');
|
||||
emptyJob.close();
|
||||
const preflight = { status: 'passed', activeProcesses: 0 };
|
||||
writeFileSync(path.join(output, 'job-preflight.json'), JSON.stringify(preflight, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ nativeJobPreflight: preflight }));
|
||||
} catch (error) {
|
||||
const diagnostic = nativeCookieDiagnostic(error, 'job_create');
|
||||
try { emptyJob?.close(); } catch {}
|
||||
writeFileSync(path.join(output, 'job-preflight.json'), JSON.stringify({ status: 'failed', diagnostic }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
incomplete('native_job_preflight_failed', diagnostic);
|
||||
}
|
||||
const memberProbe = await probeNativeCookieMember();
|
||||
const memberPreflight = 'cookies' in memberProbe ? { status: 'passed', activeProcesses: 0 } : { status: 'failed', error: memberProbe.error, diagnostic: memberProbe.diagnostic };
|
||||
writeFileSync(path.join(output, 'member-preflight.json'), JSON.stringify(memberPreflight, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ nativeMemberPreflight: memberPreflight }));
|
||||
if ('error' in memberProbe) incomplete('native_member_preflight_failed', memberProbe.diagnostic);
|
||||
const root = path.resolve(import.meta.dir, '../..');
|
||||
let sourceHashes: Record<string, string>;
|
||||
try {
|
||||
sourceHashes = await nativeCodeHashes(root, Date.now() + 25_000);
|
||||
} catch {
|
||||
incomplete('source_inputs_unavailable_or_timed_out');
|
||||
}
|
||||
const environment = nativeCookieEnvironment(process.env);
|
||||
const browser = nativeBrowserPaths('Edge', environment);
|
||||
const executable = browser.executables.find(existsSync);
|
||||
const node = Bun.which('node');
|
||||
if (!executable || !node) incomplete('node_or_edge_not_installed');
|
||||
if (existsSync(browser.userDataDir)) incomplete('existing_default_profile_refused');
|
||||
const executableSha256 = await hashNativeFile(executable, Date.now() + 25_000);
|
||||
const nodeProbe = spawnSync(node, ['-p', 'JSON.stringify({ version: process.version, architecture: process.arch })'], { env: environment, encoding: 'utf8', timeout: 10_000, windowsHide: true });
|
||||
if (nodeProbe.status !== 0) incomplete('node_runtime_preflight_failed');
|
||||
const nodeInfo = JSON.parse(nodeProbe.stdout);
|
||||
if (nodeInfo.architecture !== process.arch || !['x64', 'arm64'].includes(process.arch)) incomplete('runtime_architecture_mismatch');
|
||||
const require = createRequire(import.meta.url);
|
||||
const playwrightVersion = require('playwright/package.json').version;
|
||||
if (playwrightVersion !== '1.62.1') incomplete('playwright_1_62_1_required');
|
||||
const versionProbe = spawnSync(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [
|
||||
'-NoProfile', '-NonInteractive', '-Command', NATIVE_BROWSER_VERSION_COMMAND,
|
||||
], { env: { ...environment, GSTACK_QUALIFY_BROWSER_EXE: executable }, encoding: 'utf8', timeout: 10_000, windowsHide: true });
|
||||
const versionErrorCode = (versionProbe.error as NodeJS.ErrnoException | undefined)?.code;
|
||||
const versionMetadata = {
|
||||
exitCode: versionProbe.status,
|
||||
signal: versionProbe.signal,
|
||||
spawnError: versionProbe.error ? (['ENOENT', 'EACCES', 'EPERM', 'ETIMEDOUT'].includes(versionErrorCode || '') ? versionErrorCode : 'spawn_failed') : undefined,
|
||||
stdoutBytes: Buffer.byteLength(versionProbe.stdout || ''),
|
||||
stderrBytes: Buffer.byteLength(versionProbe.stderr || ''),
|
||||
versionValid: /^\d+(?:\.\d+){2,3}$/.test((versionProbe.stdout || '').trim()),
|
||||
};
|
||||
writeFileSync(path.join(output, 'browser-version-preflight.json'), JSON.stringify(versionMetadata, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ nativeBrowserVersionPreflight: versionMetadata }));
|
||||
if (versionProbe.status !== 0 || !versionMetadata.versionValid) incomplete('browser_version_preflight_failed');
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', 'test', 'browse/test/cookie-import-native-job.test.ts', '--test-name-pattern', '^native Windows process qualification'], {
|
||||
cwd: root,
|
||||
env: { ...environment, CI: 'true', GITHUB_ACTIONS: 'true', GSTACK_COOKIE_NATIVE_DEFAULT_FIXTURE: '1', NO_COLOR: '1' },
|
||||
encoding: 'utf8',
|
||||
timeout: 300_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
const log = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
writeFileSync(path.join(output, 'qualification.log'), log, { mode: 0o600, flag: 'wx' });
|
||||
const count = (kind: string) => Number([...log.matchAll(new RegExp(`(?:^|\\n)\\s*(\\d+) ${kind}\\b`, 'g'))].at(-1)?.[1] ?? 0);
|
||||
const counts = { pass: count('pass'), fail: count('fail'), skip: count('skip') };
|
||||
let inputsUnchanged = false;
|
||||
try {
|
||||
const deadline = Date.now() + 25_000;
|
||||
inputsUnchanged = await hashNativeFile(executable, deadline) === executableSha256 && nativeCodeMatches(sourceHashes, await nativeCodeHashes(root, deadline));
|
||||
} catch {}
|
||||
const passed = result.status === 0 && !result.error && counts.pass === 7 && counts.fail === 0 && counts.skip === 0 && inputsUnchanged && !existsSync(browser.userDataDir);
|
||||
const receipt = {
|
||||
status: passed ? 'passed' : 'failed',
|
||||
scope: 'Edge default-profile v20 extraction, pipe transport, profile identity, and owned-process cleanup',
|
||||
qualifiedBuild: {
|
||||
browserName: 'Edge',
|
||||
architecture: nodeInfo.architecture,
|
||||
windowsRelease: release(),
|
||||
executableSha256,
|
||||
nodeVersion: nodeInfo.version,
|
||||
bunVersion: Bun.version,
|
||||
playwrightVersion,
|
||||
sourceHashes,
|
||||
},
|
||||
browserVersion: versionProbe.stdout.trim(),
|
||||
supervisorArchitecture: process.arch,
|
||||
jobPreflight: { status: 'passed', activeProcesses: 0 },
|
||||
memberPreflight,
|
||||
counts,
|
||||
inputsUnchanged,
|
||||
sourceHashes,
|
||||
activationData: NATIVE_QUALIFICATION_DATA,
|
||||
activation: 'Review this receipt and add only this exact qualifiedBuild to the separate activation data; the runner never enables extraction and activation does not modify the recorded code hashes.',
|
||||
};
|
||||
writeFileSync(path.join(output, 'qualification.json'), JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ status: receipt.status, counts, artifactDirectory: output }));
|
||||
process.exitCode = passed ? 0 : 1;
|
||||
@@ -0,0 +1,436 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { release, tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { nativeBrowserPaths, importNativeCookies } from '../src/cookie-import-native';
|
||||
import { nativeCookieEnvironment, NATIVE_COOKIE_NODE_SCRIPT, NATIVE_PROGRESS_PREFIX } from '../src/cookie-import-native-worker';
|
||||
import { parseNativeCookieDiagnostic } from '../src/cookie-import-native-job';
|
||||
import { hashNativeFile, nativeCodeHashes, nativeCodeMatches, NATIVE_CODE_INPUTS, NATIVE_QUALIFICATION_DATA, readNativeQualifications } from '../src/cookie-import-native-integrity';
|
||||
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'native-cookies-'));
|
||||
const env = { LOCALAPPDATA: 'C:\\fixture\\Local', PROGRAMFILES: 'C:\\Apps', 'PROGRAMFILES(X86)': 'C:\\Apps32' };
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node is required for native-cookie transport tests');
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function isolatedEnv(): NodeJS.ProcessEnv {
|
||||
const local = path.join(root, 'AppData', 'Local');
|
||||
const roaming = path.join(root, 'AppData', 'Roaming');
|
||||
const temporary = path.join(local, 'Temp');
|
||||
for (const directory of [local, roaming, temporary]) mkdirSync(directory, { recursive: true });
|
||||
return {
|
||||
PATH: path.dirname(node!),
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
LOCALAPPDATA: local,
|
||||
APPDATA: roaming,
|
||||
TEMP: temporary,
|
||||
TMP: temporary,
|
||||
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function expectNativeProgress(stderr: string) {
|
||||
const lines = stderr.trim().split(/\r?\n/).filter(Boolean);
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
return lines.map(line => {
|
||||
expect(line.startsWith(NATIVE_PROGRESS_PREFIX)).toBe(true);
|
||||
const record = JSON.parse(line.slice(NATIVE_PROGRESS_PREFIX.length));
|
||||
expect(parseNativeCookieDiagnostic(record)).toEqual(record);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
function adapter(options: object, extraEnv: object = {}) {
|
||||
const script = `
|
||||
const { importNativeCookies } = await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/cookie-import-native.ts'))});
|
||||
const { CookieImportError } = await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'))});
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
Object.defineProperty(process.versions, 'bun', { value: undefined });
|
||||
process.env.LOCALAPPDATA = ${JSON.stringify(env.LOCALAPPDATA)};
|
||||
Object.assign(process.env, ${JSON.stringify(extraEnv)});
|
||||
try { console.log(JSON.stringify({ cookies: await importNativeCookies(${JSON.stringify(options)}) })); }
|
||||
catch (error) { console.log(JSON.stringify({ code: error.code, message: error.message, typed: error instanceof CookieImportError })); }
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
const options = {
|
||||
browserName: 'Edge',
|
||||
userDataDir: 'C:\\fixture\\Local\\Microsoft\\Edge\\User Data',
|
||||
profile: 'Default',
|
||||
domains: ['example.test'],
|
||||
};
|
||||
|
||||
describe('native-cookie production admission', () => {
|
||||
test('the real member entry reports boot and decoded input before refusing an unowned job', () => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts'), '--member-smoke'], {
|
||||
env: isolatedEnv(), input: JSON.stringify({ jobName: 'Local\\gstack-cookie-00000000-0000-0000-0000-000000000000' }), encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'worker_boot', memberMode: true });
|
||||
expect(progress).toContainEqual({ stage: 'member_input' });
|
||||
expect(progress).toContainEqual({ stage: 'member_decoded' });
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ error: 'native_supervision_failed', diagnostic: { stage: 'job_open' } });
|
||||
});
|
||||
|
||||
test.each([['malformed', '{'], ['oversized', 'x'.repeat(1024 * 1024 + 1)]])('the real member rejects %s input before opening a job', (_name, input) => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts'), '--member-smoke'], {
|
||||
env: isolatedEnv(), input, encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'member_input' });
|
||||
expect(progress).not.toContainEqual({ stage: 'member_decoded' });
|
||||
expect(progress).not.toContainEqual({ stage: 'job_open' });
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ error: 'native_supervision_failed', diagnostic: { stage: 'member_input' } });
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32')('qualification refuses non-Windows without issuing a receipt', () => {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--config=/dev/null', path.resolve(import.meta.dir, 'cookie-import-native-qualification.ts'), root], {
|
||||
env: isolatedEnv(), encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toContain('no cases ran and no qualification was issued');
|
||||
});
|
||||
|
||||
test('activation data is separate from the receipt code inputs', () => {
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-import-native.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-database.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/src/cookie-import-native-worker.ts');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/scripts/build-node-server.sh');
|
||||
expect(NATIVE_CODE_INPUTS).toContain('browse/dist/server-node.mjs');
|
||||
expect(NATIVE_CODE_INPUTS).not.toContain(NATIVE_QUALIFICATION_DATA);
|
||||
});
|
||||
|
||||
test('empty selection never launches or widens to all cookies', async () => {
|
||||
expect(await importNativeCookies({ ...options, domains: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects unsupported production runtime', async () => {
|
||||
await expect(importNativeCookies(options)).rejects.toMatchObject({ code: 'not_supported' });
|
||||
});
|
||||
|
||||
test('rejects invalid domains before launching', async () => {
|
||||
for (const domain of ['https://example.test', 'example.test/path', '*', 'example.test\n--flag']) {
|
||||
await expect(importNativeCookies({ ...options, domains: [domain] })).rejects.toMatchObject({ code: 'invalid_domain' });
|
||||
}
|
||||
});
|
||||
|
||||
test('valid Chromium hostname forms reach runtime admission', async () => {
|
||||
for (const domain of ['service_name.example.test', '-service.example.test', '[::1]']) {
|
||||
await expect(importNativeCookies({ ...options, domains: [domain] })).rejects.toMatchObject({ code: 'not_supported' });
|
||||
}
|
||||
});
|
||||
|
||||
test('unqualified Windows extraction is closed with a typed safe error', () => {
|
||||
expect(adapter(options)).toMatchObject({ code: 'native_unqualified', typed: true });
|
||||
});
|
||||
|
||||
test('environment flags cannot activate unqualified extraction', () => {
|
||||
expect(adapter(options, { GSTACK_COOKIE_NATIVE_QUALIFY: '1', GSTACK_NATIVE_COOKIES: '1' })).toMatchObject({ code: 'native_unqualified', typed: true });
|
||||
});
|
||||
|
||||
test('Chrome default user-data policy also blocks numbered profiles', () => {
|
||||
const result = adapter({ ...options, browserName: 'Chrome', userDataDir: 'C:\\fixture\\Local\\Google\\Chrome\\User Data', profile: 'Profile 2' });
|
||||
expect(result.code).toBe('native_profile_unsupported');
|
||||
expect(result.message).toContain('Chrome 136');
|
||||
expect(result.message).toContain('both pipe and TCP');
|
||||
expect(result.message).toContain('sign in manually');
|
||||
});
|
||||
|
||||
test('does not substitute a different browser for an unsupported identity', () => {
|
||||
expect(adapter({ ...options, browserName: 'Dia' })).toMatchObject({ code: 'not_supported', typed: true });
|
||||
});
|
||||
|
||||
test('rejects mismatched and copied profile roots', () => {
|
||||
expect(adapter({ ...options, browserName: 'Brave' })).toMatchObject({ code: 'native_profile_unsupported' });
|
||||
expect(adapter({ ...options, userDataDir: 'C:\\copied-profile' })).toMatchObject({ code: 'native_profile_unsupported' });
|
||||
});
|
||||
|
||||
test('rejects profile traversal without echoing the supplied text', () => {
|
||||
const result = adapter({ ...options, profile: '../sensitive-sentinel' });
|
||||
expect(result.code).toBe('invalid_profile');
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test('maps every supported browser to only its own executable and data root', () => {
|
||||
const expected = [
|
||||
['Chrome', 'Google\\Chrome', 'chrome.exe'],
|
||||
['Chromium', 'Chromium', 'chrome.exe'],
|
||||
['Brave', 'BraveSoftware\\Brave-Browser', 'brave.exe'],
|
||||
['Edge', 'Microsoft\\Edge', 'msedge.exe'],
|
||||
];
|
||||
for (const [name, relative, exe] of expected) {
|
||||
const mapping = nativeBrowserPaths(name, env);
|
||||
expect(mapping.userDataDir).toBe(`${env.LOCALAPPDATA}\\${relative}\\User Data`);
|
||||
expect(mapping.executables.length).toBeGreaterThan(0);
|
||||
for (const candidate of mapping.executables) expect(candidate.endsWith(`\\${relative}\\Application\\${exe}`)).toBe(true);
|
||||
}
|
||||
expect(nativeBrowserPaths('Edge', env).executables[0]).toStartWith('C:\\Apps32');
|
||||
expect(nativeBrowserPaths('Brave', env).executables.join(' ')).not.toContain('chrome.exe');
|
||||
});
|
||||
|
||||
test('browser environment excludes inherited secrets and runtime injection', () => {
|
||||
expect(nativeCookieEnvironment({ ...env, SystemRoot: 'C:\\Windows', API_KEY: 'sensitive-sentinel', NODE_OPTIONS: '--require=unsafe', DEBUG: 'pw:*', PWDEBUG: '1' })).toEqual({ ...env, SystemRoot: 'C:\\Windows' });
|
||||
});
|
||||
});
|
||||
|
||||
async function qualifiedAdapterFixture() {
|
||||
const fixture = mkdtempSync(path.join(root, 'admission-'));
|
||||
for (const file of NATIVE_CODE_INPUTS) {
|
||||
const destination = path.join(fixture, file);
|
||||
mkdirSync(path.dirname(destination), { recursive: true });
|
||||
if (file === 'browse/dist/server-node.mjs') continue;
|
||||
copyFileSync(path.resolve(import.meta.dir, '../..', file === 'browse/dist/bun-polyfill.cjs' ? 'browse/src/bun-polyfill.cjs' : file), destination);
|
||||
}
|
||||
const activation = path.join(fixture, NATIVE_QUALIFICATION_DATA);
|
||||
writeFileSync(activation, '[]\n');
|
||||
const bundle = path.join(fixture, 'browse/dist/server-node.mjs');
|
||||
const build = async () => {
|
||||
const result = await Bun.build({
|
||||
entrypoints: [path.join(fixture, 'browse/src/cookie-import-native.ts')],
|
||||
target: 'node',
|
||||
define: { 'import.meta.dir': JSON.stringify(path.join(fixture, 'browse/src')) },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
writeFileSync(bundle, await result.outputs[0].text());
|
||||
};
|
||||
await build();
|
||||
const probe = spawnSync(node!, ['-p', 'JSON.stringify({ version: process.version, architecture: process.arch })'], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(probe.status).toBe(0);
|
||||
const runtime = JSON.parse(probe.stdout);
|
||||
const sourceHashes = await nativeCodeHashes(fixture, Date.now() + 25_000);
|
||||
const receipt = {
|
||||
browserName: 'Edge', architecture: runtime.architecture, windowsRelease: release(),
|
||||
executableSha256: '0'.repeat(64), nodeVersion: runtime.version, bunVersion: Bun.version,
|
||||
playwrightVersion: '1.62.1', sourceHashes,
|
||||
};
|
||||
const virtualRoot = `C:\\gstack-${path.basename(fixture)}`;
|
||||
const windowsEnv = { LOCALAPPDATA: `${virtualRoot}\\Local`, PROGRAMFILES: `${virtualRoot}\\Apps`, 'PROGRAMFILES(X86)': `${virtualRoot}\\Apps32` };
|
||||
const input = { ...options, userDataDir: nativeBrowserPaths('Edge', windowsEnv).userDataDir };
|
||||
const invoke = () => {
|
||||
const script = `
|
||||
import cp from 'node:child_process';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
let spawns = 0;
|
||||
cp.spawn = () => { spawns++; throw new Error('Unexpected browser launch before source admission'); };
|
||||
syncBuiltinESMExports();
|
||||
const { importNativeCookies } = await import(${JSON.stringify(pathToFileURL(bundle).href)});
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
Object.assign(process.env, ${JSON.stringify(windowsEnv)});
|
||||
try { await importNativeCookies(${JSON.stringify(input)}); console.log(JSON.stringify({ accepted: true, spawns })); }
|
||||
catch (error) { console.log(JSON.stringify({ code: error.code, message: error.message, spawns, typed: error.name === 'CookieImportError' })); }
|
||||
`;
|
||||
const result = spawnSync(node!, ['--input-type=module', '-e', script], { cwd: fixture, env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
return { fixture, activation, bundle, build, receipt, invoke };
|
||||
}
|
||||
|
||||
describe('production adapter source-bound qualification', () => {
|
||||
test('matching code passes admission, while later worker/core/database/bundle edits reject the old receipt', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0, typed: true });
|
||||
writeFileSync(fixture.activation, JSON.stringify([fixture.receipt]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0, typed: true });
|
||||
for (const file of ['browse/src/cookie-import-native-worker.ts', 'browse/src/cookie-import-native-job.ts', 'browse/src/cookie-import-native-integrity.ts', 'browse/src/cookie-import-browser.ts', 'browse/src/cookie-database.ts', 'browse/scripts/build-node-server.sh', 'browse/dist/server-node.mjs']) {
|
||||
const target = path.join(fixture.fixture, file);
|
||||
const original = readFileSync(target);
|
||||
writeFileSync(target, Buffer.concat([original, Buffer.from('\n')]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0, typed: true });
|
||||
writeFileSync(target, original);
|
||||
}
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0, typed: true });
|
||||
});
|
||||
|
||||
test('activation-only edits preserve all bound bytes, including a rebuilt Node bundle', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
const before = readFileSync(fixture.bundle);
|
||||
writeFileSync(fixture.activation, JSON.stringify([fixture.receipt], null, 2) + '\n');
|
||||
await fixture.build();
|
||||
expect(readFileSync(fixture.bundle)).toEqual(before);
|
||||
expect(await nativeCodeHashes(fixture.fixture, Date.now() + 25_000)).toEqual(fixture.receipt.sourceHashes);
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'not_installed', spawns: 0 });
|
||||
});
|
||||
|
||||
test('partial or legacy receipts cannot activate even when their browser/runtime tuple matches', async () => {
|
||||
const fixture = await qualifiedAdapterFixture();
|
||||
const sourceHashes = { ...fixture.receipt.sourceHashes };
|
||||
delete sourceHashes['browse/src/cookie-database.ts'];
|
||||
writeFileSync(fixture.activation, JSON.stringify([{ ...fixture.receipt, sourceHashes }]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0 });
|
||||
writeFileSync(fixture.activation, JSON.stringify([{ ...fixture.receipt, sourceHashes: undefined }]));
|
||||
expect(fixture.invoke()).toMatchObject({ code: 'native_unqualified', spawns: 0 });
|
||||
expect(nativeCodeMatches(sourceHashes, fixture.receipt.sourceHashes)).toBe(false);
|
||||
});
|
||||
|
||||
test('qualification reads have a size cap and share the operation deadline', async () => {
|
||||
const fixture = mkdtempSync(path.join(root, 'bounds-'));
|
||||
const activation = path.join(fixture, NATIVE_QUALIFICATION_DATA);
|
||||
mkdirSync(path.dirname(activation), { recursive: true });
|
||||
writeFileSync(activation, ' '.repeat(1024 * 1024 + 1));
|
||||
await expect(readNativeQualifications(fixture, Date.now() + 25_000)).rejects.toThrow('native_unqualified');
|
||||
await expect(hashNativeFile(activation, Date.now() - 1)).rejects.toThrow('native_timeout');
|
||||
});
|
||||
|
||||
test('a stalled source read aborts and destroys its stream at the deadline', async () => {
|
||||
const fixture = mkdtempSync(path.join(root, 'stalled-read-'));
|
||||
const entry = path.join(fixture, 'integrity.mjs');
|
||||
const built = await Bun.build({ entrypoints: [path.resolve(import.meta.dir, '../src/cookie-import-native-integrity.ts')], target: 'node' });
|
||||
expect(built.success).toBe(true);
|
||||
writeFileSync(entry, await built.outputs[0].text());
|
||||
const script = `
|
||||
import fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
let aborted = false;
|
||||
let closed = false;
|
||||
fs.createReadStream = (file, options) => {
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.once('close', () => { closed = true; });
|
||||
options.signal.addEventListener('abort', () => { aborted = true; stream.destroy(new Error('synthetic interruption')); }, { once: true });
|
||||
return stream;
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
const { hashNativeFile } = await import(${JSON.stringify(pathToFileURL(entry).href)});
|
||||
const started = Date.now();
|
||||
let code;
|
||||
try { await hashNativeFile('synthetic-source', started + 20); }
|
||||
catch (error) { code = error.message; }
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
console.log(JSON.stringify({ code, aborted, closed, elapsed: Date.now() - started }));
|
||||
`;
|
||||
const result = spawnSync(node!, ['--input-type=module', '-e', script], { env: isolatedEnv(), encoding: 'utf8', timeout: 10_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
const outcome = JSON.parse(result.stdout);
|
||||
expect(outcome).toMatchObject({ code: 'native_timeout', aborted: true, closed: true });
|
||||
expect(outcome.elapsed).toBeLessThan(1500);
|
||||
});
|
||||
});
|
||||
|
||||
function runNodeWorker(mode: string, cookies: object[] = [], exitCode = 21) {
|
||||
const fixture = mkdtempSync(path.join(root, 'worker-'));
|
||||
const observation = path.join(fixture, 'observed.json');
|
||||
const playwrightEntry = path.join(fixture, 'playwright.cjs');
|
||||
writeFileSync(playwrightEntry, `
|
||||
exports.chromium = {
|
||||
async launchPersistentContext(userDataDir, options) {
|
||||
require('node:fs').writeFileSync(${JSON.stringify(observation)}, JSON.stringify({ userDataDir, options, nodeVersion: process.version }));
|
||||
if (${JSON.stringify(mode)} === 'locked') throw new Error('ProcessSingleton sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'policy') throw new Error('Remote debugging requires a non-default data directory sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'failure') throw new Error('sensitive-sentinel');
|
||||
if (${JSON.stringify(mode)} === 'process-exit') throw new Error('<process did exit: exitCode=${exitCode}, signal=null> sensitive-sentinel');
|
||||
return { cookies: async () => ${JSON.stringify(cookies)}, close: async () => {} };
|
||||
},
|
||||
};
|
||||
`);
|
||||
const userDataDir = path.join(fixture, 'profile');
|
||||
mkdirSync(userDataDir);
|
||||
const result = spawnSync(node!, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
|
||||
env: isolatedEnv(),
|
||||
input: JSON.stringify({ playwrightEntry, userDataDir, executablePath: 'C:\\Apps\\Microsoft\\Edge\\Application\\msedge.exe', profile: 'Profile 2', domains: ['EXAMPLE.TEST.'], deadline: Date.now() + 25_000 }),
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'node_input' });
|
||||
expect(progress).toContainEqual({ stage: 'node_load' });
|
||||
return { result: JSON.parse(result.stdout), observation: JSON.parse(readFileSync(observation, 'utf8')), userDataDir };
|
||||
}
|
||||
|
||||
describe('production Node Playwright worker', () => {
|
||||
test('real synthetic Playwright pipe smoke uses no TCP and leaves no browser', () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require('playwright');
|
||||
const fixture = mkdtempSync(path.join(root, 'pipe-'));
|
||||
const observation = path.join(fixture, 'launch.json');
|
||||
const playwrightEntry = path.join(fixture, 'instrumented-playwright.cjs');
|
||||
writeFileSync(playwrightEntry, `
|
||||
const cp = require('node:child_process');
|
||||
const spawn = cp.spawn;
|
||||
cp.spawn = function(command, args, options) {
|
||||
const child = spawn.call(this, command, args, options);
|
||||
require('node:fs').writeFileSync(${JSON.stringify(observation)}, JSON.stringify({ args, pid: child.pid }));
|
||||
return child;
|
||||
};
|
||||
const { chromium } = require(${JSON.stringify(require.resolve('playwright'))});
|
||||
exports.chromium = { async launchPersistentContext(root, options) {
|
||||
const context = await chromium.launchPersistentContext(root, options);
|
||||
await context.addCookies([{ name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/' }]);
|
||||
return context;
|
||||
} };
|
||||
`);
|
||||
const result = spawnSync(node!, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
|
||||
env: isolatedEnv(),
|
||||
input: JSON.stringify({ playwrightEntry, userDataDir: path.join(fixture, 'User Data'), executablePath: chromium.executablePath(), profile: 'Default', domains: ['example.test'], deadline: Date.now() + 25_000 }),
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const progress = expectNativeProgress(result.stderr);
|
||||
expect(progress).toContainEqual({ stage: 'browser_launch' });
|
||||
expect(progress).toContainEqual({ stage: 'cookie_read' });
|
||||
expect(JSON.parse(result.stdout).cookies).toHaveLength(1);
|
||||
const launched = JSON.parse(readFileSync(observation, 'utf8'));
|
||||
expect(launched.args).toContain('--remote-debugging-pipe');
|
||||
expect(launched.args.some((arg: string) => arg.startsWith('--remote-debugging-port'))).toBe(false);
|
||||
expect(launched.args.some((arg: string) => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))).toBe(false);
|
||||
expect(launched.args).toContain(`--user-data-dir=${path.join(fixture, 'User Data')}`);
|
||||
expect(() => process.kill(launched.pid, 0)).toThrow();
|
||||
}, 35_000);
|
||||
|
||||
test('launches the requested profile once through Playwright with no TCP or bypass arguments', () => {
|
||||
const { result, observation, userDataDir } = runNodeWorker('success');
|
||||
expect(result).toEqual({ cookies: [] });
|
||||
expect(observation.userDataDir).toBe(userDataDir);
|
||||
expect(observation.options).toMatchObject({
|
||||
executablePath: 'C:\\Apps\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
args: ['--profile-directory=Profile 2'],
|
||||
handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false,
|
||||
headless: true,
|
||||
chromiumSandbox: true,
|
||||
});
|
||||
expect(observation.options.timeout).toBeGreaterThan(0);
|
||||
expect(observation.options.timeout).toBeLessThanOrEqual(25_000);
|
||||
expect(observation.nodeVersion).toStartWith('v');
|
||||
expect(Object.keys(observation.options.env)).not.toContain('NODE_OPTIONS');
|
||||
});
|
||||
|
||||
test('bare/dotted matching preserves scope and cookie attributes', () => {
|
||||
const cookie = { name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/', httpOnly: true, secure: true, sameSite: 'Lax', expires: -1 };
|
||||
const selected = [cookie, { ...cookie, domain: '.example.test' }];
|
||||
const { result } = runNodeWorker('success', [...selected, { ...cookie, domain: 'other.example.test' }, { ...cookie, domain: 'badexample.test' }]);
|
||||
expect(result.cookies).toEqual(selected);
|
||||
});
|
||||
|
||||
test.each([['locked', 'browser_running'], ['policy', 'native_profile_unsupported'], ['failure', 'native_failed']])('classifies %s without leaking browser stderr or retrying', (mode, error) => {
|
||||
const { result } = runNodeWorker(mode);
|
||||
expect(result).toEqual({ error, diagnostic: { stage: 'browser_launch' } });
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test('reports only the managed browser exit code, not raw launch errors', () => {
|
||||
const { result } = runNodeWorker('process-exit');
|
||||
expect(result).toEqual({ error: 'browser_running', diagnostic: { stage: 'browser_launch', exitCode: 21 } });
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive-sentinel');
|
||||
});
|
||||
|
||||
test.each([0, 1, 20, 22, 24, -1, -1073741819])('does not classify unrelated browser exit %d as a profile lock', exitCode => {
|
||||
const { result } = runNodeWorker('process-exit', [], exitCode);
|
||||
expect(result).toEqual({ error: 'native_failed', diagnostic: { stage: 'browser_launch', exitCode } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'cookie-node-')));
|
||||
const bundle = path.join(root, 'importer.mjs');
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node.js is required for importer runtime coverage');
|
||||
|
||||
beforeAll(() => {
|
||||
const build = spawnSync(process.execPath, ['build', path.resolve(import.meta.dir, '../src/cookie-import-browser.ts'), '--target=node', '--outfile', bundle], {
|
||||
encoding: 'utf8', timeout: 30_000,
|
||||
});
|
||||
expect(build.status).toBe(0);
|
||||
const profile = path.join(root, '.config/chromium/Default');
|
||||
mkdirSync(profile, { recursive: true });
|
||||
expect(realpathSync(profile).startsWith(root + path.sep)).toBe(true);
|
||||
const dbPath = path.join(profile, 'Cookies');
|
||||
const database = new Database(dbPath);
|
||||
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
database.run("INSERT INTO cookies VALUES ('.fixture.test', 'synthetic', 'fixture-value', x'', '/', 0, 0, 1, 0, 1)");
|
||||
database.close();
|
||||
const windows = path.join(root, 'AppData/Local/Chromium/User Data/Default');
|
||||
mkdirSync(windows, { recursive: true });
|
||||
expect(realpathSync(windows).startsWith(root + path.sep)).toBe(true);
|
||||
copyFileSync(dbPath, path.join(windows, 'Cookies'));
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('actual Node importer runtime', () => {
|
||||
test('lists and imports cookies through the bundled production module', () => {
|
||||
const child = spawnSync(node!, ['--input-type=module', '-e', `
|
||||
const { listDomains, importCookies } = await import(process.argv[1]);
|
||||
const domains = listDomains('chromium');
|
||||
const result = await importCookies('chromium', ['fixture.test']);
|
||||
console.log(JSON.stringify({ domains, count: result.count, failed: result.failed, scope: Object.keys(result.domainCounts), cookieName: result.cookies[0]?.name }));
|
||||
`, pathToFileURL(bundle).href], {
|
||||
encoding: 'utf8', timeout: 15_000,
|
||||
env: { HOME: root, USERPROFILE: root, LOCALAPPDATA: path.join(root, 'AppData/Local'), TEMP: root, TMP: root, NODE_NO_WARNINGS: '1', PATH: path.dirname(node!), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) },
|
||||
});
|
||||
expect(child.error).toBeUndefined();
|
||||
expect(child.status).toBe(0);
|
||||
expect(child.stderr).toBe('');
|
||||
expect(JSON.parse(child.stdout)).toEqual({ domains: { browser: 'Chromium', domains: [{ domain: '.fixture.test', count: 1 }] }, count: 1, failed: 0, scope: ['.fixture.test'], cookieName: 'synthetic' });
|
||||
});
|
||||
|
||||
test('Node server build does not stub away the database', () => {
|
||||
const script = readFileSync(path.resolve(import.meta.dir, '../scripts/build-node-server.sh'), 'utf8');
|
||||
expect(script).not.toContain('const Database = null');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
import { getCookieProfiles, parseCookieImportArgs, runCookieImport } from '../src/cookie-import-operation';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
import { handleReadCommand } from '../src/read-commands';
|
||||
import { handleWriteCommand } from '../src/write-commands';
|
||||
import * as importer from '../src/cookie-import-browser';
|
||||
|
||||
let home: string;
|
||||
let homeMock: ReturnType<typeof spyOn>;
|
||||
let currentUrl: string;
|
||||
let page: any;
|
||||
let context: any;
|
||||
let session: any;
|
||||
let bm: BrowserManager;
|
||||
let cdp: any;
|
||||
|
||||
function installProfile(name = 'Default', domain = '.example.test', badCookie = false) {
|
||||
const dir = path.join(home, '.config/chromium', name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(home) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [domain, 'session', 'synthetic-session', Buffer.alloc(0), '/']);
|
||||
if (badCookie) db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [domain, 'broken', '', Buffer.from('v20synthetic'), '/']);
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function route(method: string, pathname: string, body?: unknown, cookie?: string) {
|
||||
const url = new URL('http://127.0.0.1:9470/cookie-picker' + pathname);
|
||||
let pickerInstance = '';
|
||||
if (cookie) {
|
||||
const document = await handleCookiePickerRoute(new URL(url.origin + '/cookie-picker'), new Request(url.origin + '/cookie-picker', {
|
||||
headers: { Cookie: cookie },
|
||||
}), bm, 'fixture');
|
||||
const html = await document.text();
|
||||
pickerInstance = JSON.parse(html.match(/<script id="picker-config" type="application\/json">(.*?)<\/script>/s)![1]).pickerInstance;
|
||||
}
|
||||
return handleCookiePickerRoute(url, new Request(url, {
|
||||
method,
|
||||
headers: cookie ? { Cookie: cookie, Origin: url.origin, 'Content-Type': 'application/json', 'X-Gstack-Picker-Instance': pickerInstance } : { Authorization: 'Bearer fixture', 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}), bm, 'fixture');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-op-'));
|
||||
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
currentUrl = 'https://example.test/protected';
|
||||
const cdpEvents = new EventEmitter();
|
||||
const pageEvents = new EventEmitter();
|
||||
const frame = {};
|
||||
cdp = {
|
||||
on: cdpEvents.on.bind(cdpEvents), off: cdpEvents.off.bind(cdpEvents), detach: mock(async () => {}),
|
||||
send: mock(async (method: string, params: any) => {
|
||||
if (method === 'Page.getFrameTree') return { frameTree: { frame: { id: 'fixture-frame' } } };
|
||||
if (method === 'Runtime.enable') return {};
|
||||
if (method === 'Page.createIsolatedWorld') {
|
||||
cdpEvents.emit('Runtime.executionContextCreated', { context: { name: params.worldName, id: 7, uniqueId: 'fixture-world', auxData: { frameId: 'fixture-frame', isDefault: false } } });
|
||||
return { executionContextId: 7 };
|
||||
}
|
||||
if (method === 'Runtime.evaluate') return { result: { value: 100 } };
|
||||
if (method === 'Runtime.callFunctionOn') return { result: { value: 'cleared' } };
|
||||
throw new Error('Unexpected protocol method');
|
||||
}),
|
||||
};
|
||||
context = { addCookies: mock(async () => {}), clearCookies: mock(async () => {}),
|
||||
browser: () => ({ browserType: () => ({ name: () => 'chromium' }) }), newCDPSession: mock(async () => cdp) };
|
||||
page = {
|
||||
url: () => currentUrl,
|
||||
isClosed: () => false,
|
||||
context: () => context,
|
||||
evaluate: mock(async () => 'cleared'),
|
||||
reload: mock(async () => { throw new Error('Should not reload'); }),
|
||||
mainFrame: () => frame, on: pageEvents.on.bind(pageEvents), off: pageEvents.off.bind(pageEvents),
|
||||
};
|
||||
session = { getPage: () => page, getFrame: () => null, getActiveFrameOrPage: () => page };
|
||||
bm = new BrowserManager();
|
||||
spyOn(bm, 'getActiveSession').mockReturnValue(session);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
homeMock.mockRestore();
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 300_000 + 3_600_000 + 1;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('cookie picker mutation origin policy', () => {
|
||||
for (const action of ['import', 'remove']) {
|
||||
for (const origin of [undefined, 'null', 'http://127.0.0.1:9988', 'http://localhost:9470', 'https://attacker.test']) {
|
||||
test(`${action} rejects session mutation from ${origin ?? 'missing origin'} before touching the target`, async () => {
|
||||
installProfile();
|
||||
const code = generatePickerCode({ target: { page, url: currentUrl } });
|
||||
const exchanged = await route('GET', `?code=${code}`);
|
||||
const cookie = exchanged.headers.get('set-cookie')!;
|
||||
const url = new URL(`http://127.0.0.1:9470/cookie-picker/${action}`);
|
||||
const response = await handleCookiePickerRoute(url, new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie, 'Content-Type': 'text/plain', 'Sec-Fetch-Site': 'same-site', ...(origin === undefined ? {} : { Origin: origin }) },
|
||||
body: JSON.stringify({ browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true }),
|
||||
}), bm, 'fixture');
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.json()).code).toBe('invalid_origin');
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(context.clearCookies).not.toHaveBeenCalled();
|
||||
expect(cdp.send).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test(`${action} accepts the real picker origin and preserves bearer authorization without browser headers`, async () => {
|
||||
installProfile();
|
||||
const code = generatePickerCode({ target: { page, url: currentUrl } });
|
||||
const exchanged = await route('GET', `?code=${code}`);
|
||||
const cookie = exchanged.headers.get('set-cookie')!;
|
||||
const body = { browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true };
|
||||
expect((await route('POST', `/${action}`, body, cookie)).status).toBe(200);
|
||||
expect((await route('POST', `/${action}`, body)).status).toBe(200);
|
||||
if (action === 'import') {
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(2);
|
||||
expect(cdp.send.mock.calls.filter(([method]: [string]) => method === 'Runtime.callFunctionOn')).toHaveLength(2);
|
||||
} else {
|
||||
expect(context.clearCookies).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('cookie import argument and selection policy', () => {
|
||||
test('parses flag values independently from the browser position', () => {
|
||||
expect(parseCookieImportArgs(['--domain', '.EXAMPLE.TEST', 'chromium', '--profile', 'Profile 2'])).toEqual({ browser: 'chromium', domains: ['example.test'], profile: 'Profile 2' });
|
||||
expect(parseCookieImportArgs(['--domain', 'example.test'])).toEqual({ browser: 'comet', domains: ['example.test'] });
|
||||
});
|
||||
|
||||
test('rejects missing duplicate unknown and incompatible options', () => {
|
||||
for (const args of [['--domain'], ['--profile', '--all'], ['--unknown'], ['chrome', 'edge'], ['--all', '--domain', 'example.test'], ['--all', '--clear-storage'], ['--all', '--all']]) {
|
||||
expect(() => parseCookieImportArgs(args)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('recommends a unique domain match without overriding explicit profiles', async () => {
|
||||
installProfile();
|
||||
installProfile('Profile 2', '.other.test');
|
||||
expect((await getCookieProfiles('chromium', ['example.test'])).recommendedProfile).toBe('Default');
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Profile 2', domains: ['other.test'] }, { page, url: currentUrl }, () => {});
|
||||
expect(result.profile).toBe('Profile 2');
|
||||
});
|
||||
|
||||
test('does not guess among matching or unreadable profiles', async () => {
|
||||
installProfile();
|
||||
installProfile('Profile 2');
|
||||
expect((await getCookieProfiles('chromium', ['example.test'])).recommendedProfile).toBeUndefined();
|
||||
await expect(runCookieImport({ browser: 'chromium', domains: ['example.test'] }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'profile_required' });
|
||||
fs.writeFileSync(path.join(home, '.config/chromium/Profile 2/Cookies'), 'not a database');
|
||||
const profiles = await getCookieProfiles('chromium', ['example.test']);
|
||||
expect(profiles.recommendedProfile).toBeUndefined();
|
||||
expect(profiles.profiles.find(profile => profile.name === 'Profile 2')?.unavailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registered import callers', () => {
|
||||
test('picker applies cookies and activates the actual downstream JS-origin guard', async () => {
|
||||
installProfile();
|
||||
const response = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['example.test'] });
|
||||
expect(response.status).toBe(200);
|
||||
const receipt = await response.json();
|
||||
expect(receipt.imported).toBe(1);
|
||||
expect(receipt.verification.reason).toBe('not_requested');
|
||||
expect(bm.getCookieImportedDomains().has('.example.test')).toBe(true);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(page.reload).not.toHaveBeenCalled();
|
||||
currentUrl = 'https://unrelated.test/';
|
||||
await expect(handleReadCommand('js', ['document.cookie'], session, bm)).rejects.toThrow('JS execution blocked');
|
||||
});
|
||||
|
||||
test('direct command imports both domain spellings and preserves the domain guard', async () => {
|
||||
installProfile();
|
||||
const result = await handleWriteCommand('cookie-import-browser', ['--domain', 'example.test', 'chromium'], session, bm);
|
||||
expect(result).toContain('Imported 1 cookies');
|
||||
expect(result).toContain('Authentication: not_requested');
|
||||
expect(bm.hasCookieImports()).toBe(true);
|
||||
await expect(handleWriteCommand('cookie-import-browser', ['chromium', '--domain', 'other.test'], session, bm)).rejects.toMatchObject({ code: 'target_mismatch' });
|
||||
});
|
||||
|
||||
test('returns partial and zero receipts without exposing cookie values', async () => {
|
||||
installProfile('Default', '.example.test', true);
|
||||
const response = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['example.test'] });
|
||||
const text = await response.text();
|
||||
const receipt = JSON.parse(text);
|
||||
expect(receipt.outcome).toBe('partial');
|
||||
expect(receipt.imported).toBe(1);
|
||||
expect(receipt.failed).toBe(1);
|
||||
expect(text).not.toContain('synthetic-session');
|
||||
const empty = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['missing.test'] });
|
||||
expect(await empty.json()).toMatchObject({ imported: 0, outcome: 'empty' });
|
||||
});
|
||||
|
||||
test('native fallback cannot erase unresolved source-cookie failures', async () => {
|
||||
installProfile();
|
||||
const db = new Database(path.join(home, '.config/chromium/Default/Cookies'));
|
||||
db.run("UPDATE cookies SET value = '', encrypted_value = ?", [Buffer.from('v20synthetic')]);
|
||||
db.close();
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
const native = spyOn(importer, 'importCookiesViaCdp').mockResolvedValue({ cookies: [], count: 0, failed: 0, domainCounts: {} });
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
try {
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {});
|
||||
expect(native).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({ imported: 0, failed: 1, outcome: 'failed', failureReasons: { native_unrecovered: 1 } });
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
native.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('all-domain mode requires explicit consent and never resets storage', async () => {
|
||||
installProfile();
|
||||
const result = await handleWriteCommand('cookie-import-browser', ['chromium', '--profile', 'Default', '--all'], session, bm);
|
||||
expect(result).toContain('Used --all');
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
await expect(handleWriteCommand('cookie-import-browser', ['chromium', '--all', '--clear-storage'], session, bm)).rejects.toMatchObject({ code: 'bad_request' });
|
||||
});
|
||||
|
||||
test('preflights requested verification before importing or resetting', async () => {
|
||||
installProfile();
|
||||
await expect(runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'], verifyAuth: true, clearStorage: true }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'verification_not_configured' });
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects unsupported reset before reading profiles or applying cookies', async () => {
|
||||
context.browser = () => ({ browserType: () => ({ name: () => 'firefox' }) });
|
||||
await expect(runCookieImport({ browser: 'chromium', domains: ['example.test'], clearStorage: true }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'storage_reset_unsupported' });
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ordinary imports on other target engines remain available', async () => {
|
||||
installProfile();
|
||||
context.browser = () => ({ browserType: () => ({ name: () => 'webkit' }) });
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {});
|
||||
expect(result.imported).toBe(1);
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('zero import never resets or reports verified', async () => {
|
||||
installProfile();
|
||||
currentUrl = 'https://empty.example.test/';
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['empty.example.test'], verifyAuth: true }, { page, url: currentUrl }, () => {}, { identitySelector: '.identity', expectedIdentity: 'Synthetic' });
|
||||
expect(result.imported).toBe(0);
|
||||
expect(result.verification.verified).toBe(false);
|
||||
expect(page.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('explicit reset precedes application and reports later application failure', async () => {
|
||||
installProfile();
|
||||
context.addCookies.mockImplementation(async () => { throw new Error('synthetic-sensitive-error'); });
|
||||
const result = await runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'], clearStorage: true }, { page, url: currentUrl }, domains => bm.trackCookieImportDomains(domains));
|
||||
expect(cdp.send.mock.calls.filter(([method]: [string]) => method === 'Runtime.callFunctionOn')).toHaveLength(1);
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ reset: 'cleared', imported: 0, outcome: 'failed' });
|
||||
expect(JSON.stringify(result)).not.toContain('synthetic-sensitive-error');
|
||||
expect(bm.hasCookieImports()).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a target switch during the import before a reset', async () => {
|
||||
installProfile();
|
||||
const pending = runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'], clearStorage: true }, { page, url: currentUrl }, () => {});
|
||||
currentUrl = 'https://other.test/';
|
||||
await expect(pending).rejects.toMatchObject({ code: 'target_changed' });
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('serializes context mutations instead of replaying duplicate imports', async () => {
|
||||
installProfile();
|
||||
const blocked = Promise.withResolvers<void>();
|
||||
context.addCookies.mockImplementation(() => blocked.promise);
|
||||
const first = runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {});
|
||||
await expect(runCookieImport({ browser: 'chromium', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {})).rejects.toMatchObject({ code: 'import_busy' });
|
||||
blocked.resolve();
|
||||
expect((await first).imported).toBe(1);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('picker session retains its captured destination instead of the newly active page', async () => {
|
||||
installProfile();
|
||||
const code = generatePickerCode({ target: { page, url: currentUrl } });
|
||||
const exchange = await route('GET', '?code=' + code);
|
||||
const cookie = exchange.headers.get('set-cookie')!.split(';')[0];
|
||||
const otherAdd = mock(async () => {});
|
||||
spyOn(bm, 'getActiveSession').mockReturnValue({ getPage: () => ({ context: () => ({ addCookies: otherAdd }), url: () => 'https://other.test/' }) } as any);
|
||||
const response = await route('POST', '/import', { browser: 'chromium', profile: 'Default', domains: ['example.test'] }, cookie);
|
||||
expect((await response.json()).imported).toBe(1);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
expect(otherAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('one-time picker codes last five minutes and fail at the expiry boundary', async () => {
|
||||
const now = Date.now;
|
||||
const start = now();
|
||||
const code = generatePickerCode();
|
||||
Date.now = () => start + 299_999;
|
||||
try { expect((await route('GET', '?code=' + code)).status).toBe(302); } finally { Date.now = now; }
|
||||
const expired = generatePickerCode();
|
||||
Date.now = () => now() + 300_000;
|
||||
try { expect((await route('GET', '?code=' + expired)).status).toBe(403); } finally { Date.now = now; }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { findInstalledBrowsers, importCookies, listProfiles, cookieDomainMatches, CookieImportError, normalizeCookieDomain, withCookieReadRetry } from '../src/cookie-import-browser';
|
||||
|
||||
let home: string;
|
||||
let oldHome: string | undefined;
|
||||
let oldUserProfile: string | undefined;
|
||||
let spawn: typeof Bun.spawn;
|
||||
let homeMock: ReturnType<typeof spyOn>;
|
||||
|
||||
function profile(dir: string, name: string, cookieDomain = '.example.test') {
|
||||
const target = path.join(home, dir, name);
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
expect(fs.realpathSync(target).startsWith(fs.realpathSync(home) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(target, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [cookieDomain, 'fixture', 'synthetic-value', Buffer.alloc(0), '/']);
|
||||
db.close();
|
||||
return target;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-wave-'));
|
||||
oldHome = process.env.HOME;
|
||||
oldUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
spawn = Bun.spawn;
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
if (!['secret-tool', 'security'].includes(command[0])) throw new Error('Unexpected fixture subprocess');
|
||||
return { stdout: new Blob(['fixture-password']).stream(), stderr: new Blob([]).stream(), exited: Promise.resolve(0), kill() {} };
|
||||
}) as typeof Bun.spawn;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Bun.spawn = spawn;
|
||||
homeMock.mockRestore();
|
||||
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
|
||||
if (oldUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = oldUserProfile;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('cookie import reliability', () => {
|
||||
test('discovers Dia with only a numbered macOS profile', () => {
|
||||
profile('Library/Application Support/Dia/User Data', 'Profile 2');
|
||||
expect(findInstalledBrowsers().map(browser => browser.name)).toContain('Dia');
|
||||
expect(listProfiles('dia')[0].name).toBe('Profile 2');
|
||||
});
|
||||
|
||||
test('prefers current Local State names and sorts profile numbers naturally', () => {
|
||||
const root = '.config/chromium';
|
||||
for (const name of ['Profile 10', 'Profile 2', 'Default']) {
|
||||
const dir = profile(root, name);
|
||||
fs.writeFileSync(path.join(dir, 'Preferences'), JSON.stringify({ profile: { name: 'Old name' } }));
|
||||
}
|
||||
fs.writeFileSync(path.join(home, root, 'Local State'), JSON.stringify({ profile: { info_cache: { 'Profile 2': { name: 'Current name' } } } }));
|
||||
expect(listProfiles('chromium')).toEqual([
|
||||
{ name: 'Default', displayName: 'Old name' },
|
||||
{ name: 'Profile 2', displayName: 'Current name' },
|
||||
{ name: 'Profile 10', displayName: 'Old name' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('malformed metadata preserves the directory identity', () => {
|
||||
const dir = profile('.config/chromium', 'Default');
|
||||
fs.writeFileSync(path.join(home, '.config/chromium/Local State'), '{');
|
||||
fs.writeFileSync(path.join(dir, 'Preferences'), '{');
|
||||
expect(listProfiles('chromium')).toEqual([{ name: 'Default', displayName: 'Default' }]);
|
||||
});
|
||||
|
||||
test('bare and dotted domain selection import the same stored row without widening scope', async () => {
|
||||
const dir = profile('.config/chromium', 'Default');
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run("INSERT INTO cookies VALUES ('evil-example.test', 'other', 'synthetic-other', x'', '/', 0, 1, 1, 0, 1)");
|
||||
db.close();
|
||||
for (const domain of ['example.test', '.example.test', 'EXAMPLE.TEST.']) {
|
||||
const result = await importCookies('chromium', [domain]);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.cookies[0].domain).toBe('.example.test');
|
||||
}
|
||||
});
|
||||
|
||||
test('reports partial decrypt reasons without error or cookie values', async () => {
|
||||
const dir = profile('.config/chromium', 'Default');
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run("INSERT INTO cookies VALUES ('.example.test', 'broken', '', ?, '/', 0, 1, 1, 0, 1)", [Buffer.from('v20synthetic')]);
|
||||
db.close();
|
||||
const result = await importCookies('chromium', ['example.test']);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.failureReasons).toEqual({ unsupported_encryption: 1 });
|
||||
});
|
||||
|
||||
test('domain counts do not inherit object prototype properties', async () => {
|
||||
profile('.config/chromium', 'Default', 'constructor');
|
||||
const result = await importCookies('chromium', ['constructor']);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.domainCounts.constructor).toBe(1);
|
||||
expect(JSON.stringify(result.domainCounts)).toBe('{"constructor":1}');
|
||||
});
|
||||
|
||||
test('domain matching preserves host-only boundaries and rejects malformed input', () => {
|
||||
expect(cookieDomainMatches('app.example.test', '.example.test')).toBe(true);
|
||||
expect(cookieDomainMatches('app.example.test', 'example.test')).toBe(false);
|
||||
expect(cookieDomainMatches('evil-example.test', '.example.test')).toBe(false);
|
||||
expect(normalizeCookieDomain('.EXAMPLE.TEST.')).toBe('example.test');
|
||||
expect(normalizeCookieDomain('service_name.example.test')).toBe('service_name.example.test');
|
||||
expect(normalizeCookieDomain('-service.example.test')).toBe('-service.example.test');
|
||||
expect(normalizeCookieDomain('::1')).toBe('[::1]');
|
||||
expect(normalizeCookieDomain('[0:0:0:0:0:0:0:1]')).toBe('[::1]');
|
||||
expect(cookieDomainMatches('[::1]', '[::1]')).toBe(true);
|
||||
for (const domain of ['', 'https://example.test', 'example.test/path', 'user@example.test', '..example.test', 'example.test:80', '*.example.test']) {
|
||||
expect(() => normalizeCookieDomain(domain)).toThrow(CookieImportError);
|
||||
}
|
||||
});
|
||||
|
||||
for (const domain of ['service_name.example.test', '[::1]', '-service.example.test']) {
|
||||
test(`imports the Chromium-supported hostname ${domain}`, async () => {
|
||||
profile('.config/chromium', 'Default', domain);
|
||||
const result = await importCookies('chromium', [domain]);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.cookies[0].domain).toBe(domain);
|
||||
});
|
||||
}
|
||||
|
||||
test('does not automatically retry permission denial or corrupt databases', async () => {
|
||||
for (const code of ['keychain_denied', 'keychain_timeout', 'db_corrupt']) {
|
||||
let attempts = 0;
|
||||
await expect(withCookieReadRetry(() => {
|
||||
attempts++;
|
||||
throw new CookieImportError('Safe fixture error', code, 'retry');
|
||||
})).rejects.toThrow('Safe fixture error');
|
||||
expect(attempts).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes adapter failures without exposing database error text', async () => {
|
||||
for (const [source, expected] of [['SQLITE_CORRUPT', 'db_corrupt'], ['SQLITE_READONLY', 'db_permission'], ['SQLITE_ERROR', 'db_read_error']]) {
|
||||
let caught: any;
|
||||
try { await withCookieReadRetry(() => { throw Object.assign(new Error('synthetic-private-db-detail'), { code: source }); }); }
|
||||
catch (error) { caught = error; }
|
||||
expect(caught).toBeInstanceOf(CookieImportError);
|
||||
expect(caught.code).toBe(expected);
|
||||
expect(caught.message).not.toContain('synthetic-private-db-detail');
|
||||
}
|
||||
});
|
||||
|
||||
test('actual Keychain denial is sanitized, never repeated, and clears its deadline', async () => {
|
||||
const dir = profile('Library/Application Support/Dia/User Data', 'Default');
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run("UPDATE cookies SET value = '', encrypted_value = ?", [Buffer.from('v10synthetic-encrypted-row')]);
|
||||
db.close();
|
||||
let requests = 0;
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
expect(command).toEqual(['security', 'find-generic-password', '-s', 'Dia Safe Storage', '-w']);
|
||||
requests++;
|
||||
return { stdout: new Blob([]).stream(), stderr: new Blob(['user canceled synthetic-private-detail']).stream(), exited: Promise.resolve(1), kill() {} };
|
||||
}) as typeof Bun.spawn;
|
||||
const timer = spyOn(globalThis, 'setTimeout').mockReturnValue(123 as any);
|
||||
const clear = spyOn(globalThis, 'clearTimeout').mockImplementation(() => {});
|
||||
try {
|
||||
let error: any;
|
||||
try { await withCookieReadRetry(() => importCookies('dia', ['example.test'])); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('keychain_denied');
|
||||
expect(error.message).not.toContain('synthetic-private-detail');
|
||||
expect(requests).toBe(1);
|
||||
expect(clear).toHaveBeenCalledWith(123);
|
||||
} finally {
|
||||
timer.mockRestore();
|
||||
clear.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('bounds transient database retries to three attempts', async () => {
|
||||
let attempts = 0;
|
||||
await expect(withCookieReadRetry(() => {
|
||||
attempts++;
|
||||
throw new CookieImportError('Locked', 'db_locked', 'retry');
|
||||
})).rejects.toThrow('Locked');
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { sendCommand } from '../src/cli';
|
||||
|
||||
afterEach(() => mock.restore());
|
||||
|
||||
describe('cookie import CLI transport', () => {
|
||||
const state = { port: 9470, token: 'synthetic-fixture' } as any;
|
||||
|
||||
test('allows the bounded import stages to finish without changing other command budgets', async () => {
|
||||
const budgets: number[] = [];
|
||||
spyOn(AbortSignal, 'timeout').mockImplementation(ms => {
|
||||
budgets.push(ms);
|
||||
return new AbortController().signal;
|
||||
});
|
||||
spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('Synthetic receipt'));
|
||||
const output = spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await sendCommand(state, 'cookie-import-browser', ['chromium', '--domain', 'example.test']);
|
||||
await sendCommand(state, 'text', []);
|
||||
expect(budgets).toEqual([90_000, 30_000]);
|
||||
expect(output).toHaveBeenCalledWith('Synthetic receipt');
|
||||
});
|
||||
|
||||
for (const failure of ['ECONNRESET', 'ECONNREFUSED', 'AbortError', 'TimeoutError', 'fetch failed']) {
|
||||
test(`does not replay an import after ${failure}`, async () => {
|
||||
const request = spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
throw Object.assign(new Error(failure), { code: failure, name: failure });
|
||||
});
|
||||
await expect(sendCommand(state, 'cookie-import-browser', ['chromium', '--all'])).rejects.toThrow('was not replayed');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('does not automatically retry cookie import after an authorization change', async () => {
|
||||
const request = spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('Unauthorized', { status: 401 }));
|
||||
await expect(sendCommand(state, 'cookie-import-browser', ['chromium', '--all'])).rejects.toThrow('retry manually');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
|
||||
for (const sameOrigin of [false, true]) {
|
||||
test(`two real picker windows cannot reset the other ${sameOrigin ? 'same-origin tab' : 'same-host different-port origin'}`, async () => {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), 'picker-binding-'));
|
||||
const profile = path.join(home, '.config/chromium/Default');
|
||||
mkdirSync(profile, { recursive: true });
|
||||
expect(realpathSync(profile).startsWith(realpathSync(home) + path.sep)).toBe(true);
|
||||
const database = new Database(path.join(profile, 'Cookies'));
|
||||
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
database.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 0, 1, 0, 1)', ['127.0.0.1', 'fixture', 'synthetic', Buffer.alloc(0), '/']);
|
||||
database.close();
|
||||
const serveTarget = () => Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
|
||||
const authenticated = (request.headers.get('cookie') ?? '').split(';').some(value => value.trim() === 'fixture=synthetic');
|
||||
return new Response(authenticated ? '<div id="fixture-identity">Synthetic account</div>' : '<div>Not signed in</div>', {
|
||||
headers: { 'Content-Type': 'text/html' }, status: authenticated ? 200 : 401,
|
||||
});
|
||||
} });
|
||||
const firstServer = serveTarget();
|
||||
const secondServer = sameOrigin ? firstServer : serveTarget();
|
||||
let browser: Browser | undefined;
|
||||
let picker: ReturnType<typeof Bun.serve> | undefined;
|
||||
let homeMock: ReturnType<typeof spyOn> | undefined;
|
||||
const selector = process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
const identity = process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
process.env.GSTACK_COOKIE_AUTH_SELECTOR = '#fixture-identity';
|
||||
process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = 'Synthetic account';
|
||||
const destination = await browser.newContext();
|
||||
destination.setDefaultTimeout(5_000);
|
||||
const targetA = await destination.newPage();
|
||||
const targetB = await destination.newPage();
|
||||
await targetA.goto(`http://127.0.0.1:${firstServer.port}/a`);
|
||||
await targetB.goto(`http://127.0.0.1:${secondServer.port}/b`);
|
||||
for (const target of [targetA, targetB]) {
|
||||
await target.evaluate(() => { localStorage.setItem('keep', 'preserved'); sessionStorage.setItem('keep', 'preserved'); });
|
||||
}
|
||||
const bm = { getActiveSession: () => ({ getPage: () => targetA }), trackCookieImportDomains() {} } as any;
|
||||
picker = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: request => handleCookiePickerRoute(new URL(request.url), request, bm) });
|
||||
const pickerOrigin = `http://127.0.0.1:${picker.port}`;
|
||||
const client = await browser.newContext();
|
||||
client.setDefaultTimeout(5_000);
|
||||
const windowA = await client.newPage();
|
||||
const windowB = await client.newPage();
|
||||
const importButton = /^(?:Import|Reimport) 127\.0\.0\.1$/;
|
||||
const errors: string[] = [];
|
||||
for (const window of [windowA, windowB]) window.on('pageerror', error => errors.push(error.message));
|
||||
const open = async (window: typeof windowA, target: typeof targetA) => {
|
||||
const code = generatePickerCode({ browser: 'Chromium', target: { page: target, url: target.url() } });
|
||||
await window.goto(pickerOrigin + '/cookie-picker?code=' + code);
|
||||
await window.getByRole('button', { name: importButton }).waitFor();
|
||||
await window.locator('#clear-storage').check();
|
||||
await window.locator('#verify-auth').check();
|
||||
};
|
||||
await open(windowA, targetA);
|
||||
await open(windowB, targetB);
|
||||
await windowA.getByRole('button', { name: importButton }).click();
|
||||
await windowA.getByRole('status').filter({ hasText: 'Reopen the picker from the intended page before continuing.' }).waitFor();
|
||||
for (const target of [targetA, targetB]) {
|
||||
expect(await target.evaluate(() => [localStorage.getItem('keep'), sessionStorage.getItem('keep')])).toEqual(['preserved', 'preserved']);
|
||||
}
|
||||
expect(await destination.cookies()).toEqual([]);
|
||||
expect(await targetA.locator('#fixture-identity').count()).toBe(0);
|
||||
expect(await targetB.locator('#fixture-identity').count()).toBe(0);
|
||||
await windowB.getByRole('button', { name: importButton }).click();
|
||||
await windowB.getByRole('status').filter({ hasText: 'Authentication verified on the captured target.' }).waitFor();
|
||||
expect(await targetB.evaluate(() => [localStorage.getItem('keep'), sessionStorage.getItem('keep')])).toEqual([null, null]);
|
||||
expect(await targetA.evaluate(() => [localStorage.getItem('keep'), sessionStorage.getItem('keep')])).toEqual([sameOrigin ? null : 'preserved', 'preserved']);
|
||||
expect(await targetB.locator('#fixture-identity').innerText()).toBe('Synthetic account');
|
||||
expect(await targetA.locator('#fixture-identity').count()).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
homeMock?.mockRestore();
|
||||
if (selector === undefined) delete process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
else process.env.GSTACK_COOKIE_AUTH_SELECTOR = selector;
|
||||
if (identity === undefined) delete process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
else process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = identity;
|
||||
await browser?.close();
|
||||
picker?.stop(true);
|
||||
firstServer.stop(true);
|
||||
if (!sameOrigin) secondServer.stop(true);
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 3_900_001;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 40_000);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
import * as importer from '../src/cookie-import-browser';
|
||||
import * as operation from '../src/cookie-import-operation';
|
||||
import * as auth from '../src/cookie-auth-verification';
|
||||
|
||||
const origin = 'http://127.0.0.1:9470';
|
||||
let bm: any;
|
||||
let context: any;
|
||||
let reads: ReturnType<typeof spyOn>[];
|
||||
let reset: ReturnType<typeof spyOn>;
|
||||
let verify: ReturnType<typeof spyOn>;
|
||||
let previousSelector: string | undefined;
|
||||
let previousIdentity: string | undefined;
|
||||
|
||||
function page(url: string) {
|
||||
return { url: () => url, isClosed: () => false, context: () => context } as any;
|
||||
}
|
||||
|
||||
async function request(path: string, cookie?: string, instance?: string, body?: unknown, bearer?: string) {
|
||||
const url = new URL(origin + '/cookie-picker' + path);
|
||||
return handleCookiePickerRoute(url, new Request(url, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: { Origin: origin, 'Content-Type': 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
...(instance ? { 'X-Gstack-Picker-Instance': instance } : {}),
|
||||
...(bearer ? { Authorization: 'Bearer ' + bearer } : {}) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
}), bm, 'fixture-bearer');
|
||||
}
|
||||
|
||||
async function open(target: any) {
|
||||
const code = generatePickerCode({ target: { page: target, url: target.url() } });
|
||||
const exchanged = await request('?code=' + code);
|
||||
expect(exchanged.status).toBe(302);
|
||||
const cookie = exchanged.headers.get('set-cookie')!.split(';')[0];
|
||||
const response = await request('', cookie);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
const config = JSON.parse(html.match(/<script id="picker-config" type="application\/json">(.*?)<\/script>/s)![1]);
|
||||
return { cookie, config, html, code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
previousSelector = process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
previousIdentity = process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
process.env.GSTACK_COOKIE_AUTH_SELECTOR = '#fixture-identity';
|
||||
process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = 'Synthetic account';
|
||||
context = { addCookies: mock(async () => {}), clearCookies: mock(async () => {}),
|
||||
browser: () => ({ browserType: () => ({ name: () => 'chromium' }) }) };
|
||||
bm = { getActiveSession: () => ({ getPage: () => page('https://example.test/current') }), trackCookieImportDomains: mock(() => {}) };
|
||||
reads = [
|
||||
spyOn(importer, 'findInstalledBrowsers').mockReturnValue([{ name: 'Chromium', aliases: ['chromium'] }] as any),
|
||||
spyOn(operation, 'getCookieProfiles').mockResolvedValue({ profiles: [{ name: 'Default', displayName: 'Synthetic' }], recommendedProfile: 'Default' }),
|
||||
spyOn(importer, 'listDomains').mockReturnValue({ browser: 'Chromium', domains: [{ domain: '.example.test', count: 1 }] }),
|
||||
spyOn(importer, 'importCookies').mockResolvedValue({ cookies: [{ name: 'fixture', value: 'synthetic', domain: '.example.test', path: '/',
|
||||
expires: -1, secure: true, httpOnly: true, sameSite: 'Lax' }], count: 1, failed: 0, domainCounts: { '.example.test': 1 } }),
|
||||
];
|
||||
reset = spyOn(auth, 'clearCookieTargetStorage').mockResolvedValue(undefined);
|
||||
verify = spyOn(auth, 'verifyCookieAuthentication').mockResolvedValue({ verified: true, reason: 'verified' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
if (previousSelector === undefined) delete process.env.GSTACK_COOKIE_AUTH_SELECTOR;
|
||||
else process.env.GSTACK_COOKIE_AUTH_SELECTOR = previousSelector;
|
||||
if (previousIdentity === undefined) delete process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY;
|
||||
else process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY = previousIdentity;
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 3_900_001;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
});
|
||||
|
||||
describe('cookie picker document binding', () => {
|
||||
test('renders independent instance identifiers without exposing authentication credentials', async () => {
|
||||
const first = await open(page('https://example.test/a'));
|
||||
const second = await open(page('https://example.test/b'));
|
||||
for (const picker of [first, second]) {
|
||||
expect(picker.config.pickerInstance).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(picker.html).not.toContain(picker.cookie.slice('gstack_picker='.length));
|
||||
expect(picker.html).not.toContain(picker.code);
|
||||
expect(picker.html).not.toContain('fixture-bearer');
|
||||
}
|
||||
expect(first.config.pickerInstance).not.toBe(second.config.pickerInstance);
|
||||
});
|
||||
|
||||
test('missing and forged instance headers fail before discovery or import', async () => {
|
||||
const picker = await open(page('https://example.test/a'));
|
||||
for (const instance of [undefined, 'forged', picker.cookie.slice('gstack_picker='.length)]) {
|
||||
for (const [path, payload] of [['/browsers'], ['/import', { browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true, verifyAuth: true }]] as const) {
|
||||
const response = await request(path, picker.cookie, instance, payload);
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.json()).code).toBe('picker_changed');
|
||||
}
|
||||
}
|
||||
for (const read of reads) expect(read).not.toHaveBeenCalled();
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
expect(verify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a rendered instance identifier never authorizes an unauthenticated request', async () => {
|
||||
const picker = await open(page('https://example.test/a'));
|
||||
for (const cookie of [undefined, 'gstack_picker=' + picker.config.pickerInstance]) {
|
||||
const response = await request('/browsers', cookie, picker.config.pickerInstance);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ error: 'Unauthorized' });
|
||||
}
|
||||
for (const read of reads) expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('bearer authorization ignores an unrelated cookie and stale picker identifier', async () => {
|
||||
const first = await open(page('https://example.test/a'));
|
||||
const second = await open(page('https://example.test/b'));
|
||||
const current = page('https://example.test/current');
|
||||
bm.getActiveSession = () => ({ getPage: () => current });
|
||||
const response = await request('/import', second.cookie, first.config.pickerInstance,
|
||||
{ browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true, verifyAuth: true }, 'fixture-bearer');
|
||||
expect(response.status).toBe(200);
|
||||
expect(reset).toHaveBeenCalledWith(current, 'https://example.test');
|
||||
expect(verify).toHaveBeenCalledWith(current, { identitySelector: '#fixture-identity', expectedIdentity: 'Synthetic account' }, 'https://example.test');
|
||||
});
|
||||
|
||||
for (const [scenario, firstUrl, secondUrl] of [
|
||||
['same-host different-port origins', 'https://example.test:8443/a', 'https://example.test:9443/b'],
|
||||
['same-origin different tabs', 'https://example.test/a', 'https://example.test/b'],
|
||||
]) {
|
||||
test(`rejects an old window before reads or mutations for ${scenario}`, async () => {
|
||||
const first = await open(page(firstUrl));
|
||||
const target = page(secondUrl);
|
||||
const second = await open(target);
|
||||
const body = { browser: 'Chromium', profile: 'Default', domains: ['example.test'], clearStorage: true, verifyAuth: true };
|
||||
const calls: Array<[string, unknown?]> = [['/browsers'], ['/profiles?browser=Chromium'], ['/domains?browser=Chromium&profile=Default'],
|
||||
['/imported'], ['/import', body], ['/remove', { domains: ['example.test'] }]];
|
||||
for (const [path, payload] of calls) {
|
||||
const response = await request(path, second.cookie, first.config.pickerInstance, payload);
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toMatchObject({ code: 'picker_changed', error: expect.stringContaining('Reopen') });
|
||||
}
|
||||
for (const read of reads) expect(read).not.toHaveBeenCalled();
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
expect(verify).not.toHaveBeenCalled();
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
expect(context.clearCookies).not.toHaveBeenCalled();
|
||||
expect(first.config.targetOrigin).toBe(new URL(firstUrl).origin);
|
||||
for (const [path, payload] of calls) {
|
||||
expect((await request(path, second.cookie, second.config.pickerInstance, payload)).status).toBe(200);
|
||||
}
|
||||
for (const read of reads) expect(read).toHaveBeenCalledTimes(1);
|
||||
expect(reset).toHaveBeenCalledWith(target, new URL(secondUrl).origin);
|
||||
expect(verify).toHaveBeenCalledWith(target, { identitySelector: '#fixture-identity', expectedIdentity: 'Synthetic account' }, new URL(secondUrl).origin);
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
expect(context.clearCookies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { generatePickerCode, handleCookiePickerRoute, hasActivePicker } from '../src/cookie-picker-routes';
|
||||
|
||||
test('a same-site cross-port browser request carries the picker cookie but cannot mutate the session', async () => {
|
||||
let removed = 0;
|
||||
const observed: Array<{ origin: string | null; cookiePresent: boolean; status: number; contentType: string | null }> = [];
|
||||
const bm = { getActiveSession: () => ({ getPage: () => ({ context: () => ({ clearCookies: async () => { removed++; } }) }) }) } as any;
|
||||
const picker = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(request) {
|
||||
const response = await handleCookiePickerRoute(new URL(request.url), request, bm);
|
||||
if (request.method === 'POST') observed.push({ origin: request.headers.get('origin'), cookiePresent: /(?:^|;\s*)gstack_picker=/.test(request.headers.get('cookie') ?? ''), status: response.status, contentType: request.headers.get('content-type') });
|
||||
if (request.method === 'GET' && response.status === 200) {
|
||||
const config = (await response.text()).match(/<script id="picker-config" type="application\/json">.*?<\/script>/s)![0];
|
||||
return new Response('<title>Synthetic authorized picker</title>' + config, { headers: { 'Content-Type': 'text/html' } });
|
||||
}
|
||||
return response;
|
||||
} });
|
||||
const attacker = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('<title>Synthetic cross-port source</title>', { headers: { 'Content-Type': 'text/html' } }) });
|
||||
let browser: Browser | undefined;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
const pickerOrigin = `http://127.0.0.1:${picker.port}`;
|
||||
const attackerOrigin = `http://127.0.0.1:${attacker.port}`;
|
||||
await page.goto(`${pickerOrigin}/cookie-picker?code=${generatePickerCode()}`);
|
||||
const sameOriginStatus = await page.evaluate(async () => (await fetch('/cookie-picker/remove', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json',
|
||||
'X-Gstack-Picker-Instance': JSON.parse(document.getElementById('picker-config')!.textContent!).pickerInstance }, body: JSON.stringify({ domains: ['synthetic.test'] }),
|
||||
})).status);
|
||||
expect(sameOriginStatus).toBe(200);
|
||||
expect(removed).toBe(1);
|
||||
observed.length = 0;
|
||||
await page.goto(attackerOrigin);
|
||||
for (const action of ['import', 'remove']) {
|
||||
await page.evaluate(async ({ target, action }) => {
|
||||
await fetch(`${target}/cookie-picker/${action}`, { method: 'POST', mode: 'no-cors', credentials: 'include',
|
||||
headers: { 'Content-Type': 'text/plain' }, body: JSON.stringify({ browser: 'Chromium', domains: ['synthetic.test'], clearStorage: true }) });
|
||||
}, { target: pickerOrigin, action });
|
||||
}
|
||||
expect(observed).toEqual([1, 2].map(() => ({ origin: attackerOrigin, cookiePresent: true, status: 403, contentType: 'text/plain' })));
|
||||
expect(removed).toBe(1);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
picker.stop(true);
|
||||
attacker.stop(true);
|
||||
const now = Date.now;
|
||||
Date.now = () => now() + 3_600_001;
|
||||
try { hasActivePicker(); } finally { Date.now = now; }
|
||||
}
|
||||
}, 40_000);
|
||||
@@ -378,16 +378,20 @@ describe('cookie-picker-routes', () => {
|
||||
|
||||
expect(html).not.toContain(authToken);
|
||||
expect(html).not.toContain('AUTH_TOKEN');
|
||||
expect(html).not.toContain(session);
|
||||
});
|
||||
|
||||
test('data routes accept session cookie', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const session = await getSessionCookie(bm, 'test-token');
|
||||
const document = await handleCookiePickerRoute(makeUrl('/cookie-picker'), makeReq('GET', undefined, { Cookie: `gstack_picker=${session}` }), bm, 'test-token');
|
||||
const html = await document.text();
|
||||
const { pickerInstance } = JSON.parse(html.match(/<script id="picker-config" type="application\/json">(.*?)<\/script>/s)![1]);
|
||||
|
||||
const url = makeUrl('/cookie-picker/browsers');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Cookie': `gstack_picker=${session}` },
|
||||
headers: { 'Cookie': `gstack_picker=${session}`, 'X-Gstack-Picker-Instance': pickerInstance },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { getCookiePickerHTML } from '../src/cookie-picker-ui';
|
||||
|
||||
type PickerOptions = NonNullable<Parameters<typeof getCookiePickerHTML>[1]>;
|
||||
type ApiRequest = { path: string; method: string; browser: string | null; profile: string | null; pickerInstance: string | null; body?: any };
|
||||
|
||||
describe('rendered cookie picker', () => {
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let origin: string;
|
||||
let options: PickerOptions;
|
||||
let requests: ApiRequest[];
|
||||
let handler: (request: ApiRequest) => Response | Promise<Response> | undefined;
|
||||
let profiles: Record<string, { profiles: { name: string; displayName: string; unavailable?: boolean }[]; recommendedProfile?: string }>;
|
||||
let browsers: { name: string; aliases?: string[] }[];
|
||||
let imported: { domain: string; count: number }[];
|
||||
let errors: string[];
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/cookie-picker') return new Response(getCookiePickerHTML(server.port!, options), { headers: { 'Content-Type': 'text/html' } });
|
||||
if (!url.pathname.startsWith('/cookie-picker/')) return new Response(null, { status: 204 });
|
||||
const record = { path: url.pathname.slice('/cookie-picker'.length), method: request.method,
|
||||
browser: url.searchParams.get('browser'), profile: url.searchParams.get('profile'),
|
||||
pickerInstance: request.headers.get('X-Gstack-Picker-Instance'),
|
||||
...(request.method === 'POST' ? { body: await request.json() } : {}) };
|
||||
requests.push(record);
|
||||
const response = handler(record);
|
||||
if (response) return response;
|
||||
if (record.path === '/browsers') return Response.json({ browsers });
|
||||
if (record.path === '/imported') return Response.json({ domains: imported });
|
||||
if (record.path === '/profiles') return Response.json(profiles[record.browser!] || { profiles: [] });
|
||||
if (record.path === '/domains') return Response.json({ domains: [{ domain: '.' + record.browser!.toLowerCase() + '-' + record.profile!.toLowerCase().replaceAll(' ', '-') + '.test', count: 4 }] });
|
||||
if (record.path === '/import') return Response.json({ browser: record.body.browser, profile: record.body.profile,
|
||||
imported: 2, failed: 0, domainCounts: Object.fromEntries(record.body.domains.map((domain: string) => [domain, 2])),
|
||||
failureReasons: {}, outcome: 'imported', message: 'Cookies copied.', reset: 'not_requested',
|
||||
verification: { verified: false, reason: 'not_requested' } });
|
||||
if (record.path === '/remove') return Response.json({ removed: record.body.domains.length });
|
||||
return Response.json({ error: 'Fixture route unavailable' }, { status: 404 });
|
||||
} });
|
||||
origin = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
options = {};
|
||||
requests = [];
|
||||
imported = [];
|
||||
handler = () => undefined;
|
||||
browsers = [{ name: 'Chrome', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'] }, { name: 'Dia', aliases: ['dia'] }];
|
||||
profiles = {
|
||||
Chrome: { profiles: [{ name: 'Default', displayName: 'Personal' }, { name: 'Profile 1', displayName: 'Personal' }], recommendedProfile: 'Default' },
|
||||
Dia: { profiles: [{ name: 'Profile 1', displayName: 'Work' }, { name: 'Profile 2', displayName: 'Work' }], recommendedProfile: 'Profile 2' },
|
||||
};
|
||||
errors = [];
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.close();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
server?.stop(true);
|
||||
});
|
||||
|
||||
async function openPicker() {
|
||||
await page.goto(`${origin}/cookie-picker`);
|
||||
await page.locator('.pill').first().waitFor();
|
||||
}
|
||||
|
||||
test('sends the rendered instance on every discovery and mutation request', async () => {
|
||||
options = { pickerInstance: 'synthetic-picker-instance' };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Remove .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Removed imported cookies' }).waitFor();
|
||||
expect([...new Set(requests.map(request => request.path))].sort()).toEqual(['/browsers', '/domains', '/import', '/imported', '/profiles', '/remove']);
|
||||
expect(requests.every(request => request.pickerInstance === options.pickerInstance)).toBe(true);
|
||||
});
|
||||
|
||||
test('a stale picker fails with actionable reopen guidance instead of a success receipt', async () => {
|
||||
options = { pickerInstance: 'stale-picker-instance' };
|
||||
handler = request => request.path === '/import'
|
||||
? Response.json({ code: 'picker_changed', error: 'Reopen the picker.' }, { status: 403 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Reopen the picker from the intended page before continuing.' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Authentication was not verified.');
|
||||
expect(await page.locator('#imported-domains').textContent()).toContain('No cookies imported yet');
|
||||
});
|
||||
|
||||
test('preserves default storage and leaves verification disabled without a bound target', async () => {
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).waitFor();
|
||||
for (const selector of ['#clear-storage', '#verify-auth']) {
|
||||
expect(await page.locator(selector).isChecked()).toBe(false);
|
||||
expect(await page.locator(selector).isDisabled()).toBe(true);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.find(request => request.path === '/import')?.body).toEqual({ browser: 'Chrome', profile: 'Default',
|
||||
domains: ['.chrome-default.test'], clearStorage: false, verifyAuth: false });
|
||||
expect(await page.getByRole('status').textContent()).toContain('Storage preserved. Authentication not checked.');
|
||||
expect(await page.getByRole('status').isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
test('does not enable verification or storage reset merely because a target and assertion exist', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
await openPicker();
|
||||
expect(await page.locator('#target-origin').textContent()).toBe('https://target.test');
|
||||
for (const selector of ['#clear-storage', '#verify-auth']) {
|
||||
expect(await page.locator(selector).isChecked()).toBe(false);
|
||||
expect(await page.locator(selector).isEnabled()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('prechecks only explicitly requested options and supports keyboard changes', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true, clearStorage: true, verifyAuth: true };
|
||||
await openPicker();
|
||||
for (const selector of ['#clear-storage', '#verify-auth']) {
|
||||
expect(await page.locator(selector).isChecked()).toBe(true);
|
||||
await page.locator(selector).focus();
|
||||
await page.keyboard.press('Space');
|
||||
expect(await page.locator(selector).isChecked()).toBe(false);
|
||||
}
|
||||
const add = page.getByRole('button', { name: 'Import .chrome-default.test', exact: true });
|
||||
await add.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.find(request => request.path === '/import')?.body.verifyAuth).toBe(false);
|
||||
expect(requests.find(request => request.path === '/import')?.body.clearStorage).toBe(false);
|
||||
expect(await page.getByRole('button', { name: 'Reimport .chrome-default.test', exact: true }).evaluate(element => element === document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
for (const targetOrigin of [undefined, 'about:blank', 'javascript:alert(1)']) {
|
||||
test(`disables explicit mutation options for an unavailable target ${String(targetOrigin).split(':')[0]}`, async () => {
|
||||
options = { targetOrigin, clearStorage: true, verifyAuth: true, verificationAvailable: true };
|
||||
await openPicker();
|
||||
expect(await page.locator('#clear-storage').isChecked()).toBe(false);
|
||||
expect(await page.locator('#verify-auth').isChecked()).toBe(false);
|
||||
expect(await page.locator('#clear-storage').isDisabled()).toBe(true);
|
||||
expect(await page.locator('#verify-auth').isDisabled()).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('disables verification without configuration even when requested', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verifyAuth: true };
|
||||
await openPicker();
|
||||
expect(await page.locator('#verify-auth').isDisabled()).toBe(true);
|
||||
expect(await page.locator('#verify-auth').isChecked()).toBe(false);
|
||||
expect(await page.locator('#clear-storage').isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps reset disabled on unsupported targets without disabling import', async () => {
|
||||
options = { targetOrigin: 'https://target.test', clearStorage: true, storageResetAvailable: false };
|
||||
await openPicker();
|
||||
expect(await page.locator('#clear-storage').isDisabled()).toBe(true);
|
||||
expect(await page.locator('#clear-storage').isChecked()).toBe(false);
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.find(request => request.path === '/import')?.body.clearStorage).toBe(false);
|
||||
expect(await page.locator('#clear-storage').isDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test('requires explicit selection among ambiguous profiles and shows directory discriminators', async () => {
|
||||
delete profiles.Chrome.recommendedProfile;
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'No unambiguous profile' }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').count()).toBe(0);
|
||||
expect(await page.locator('.profile-pill').allTextContents()).toEqual(['Personal (Default)', 'Personal (Profile 1)']);
|
||||
expect(requests.filter(request => request.path === '/domains')).toHaveLength(0);
|
||||
expect(await page.locator('#btn-import-all').isVisible()).toBe(false);
|
||||
await page.getByRole('button', { name: 'Personal (Profile 1)', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
test('does not guess a sole profile when the server cannot recommend it', async () => {
|
||||
profiles.Chrome = { profiles: [{ name: 'Default', displayName: 'Personal', unavailable: true }] };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'No unambiguous profile' }).waitFor();
|
||||
expect(await page.locator('.profile-pill').textContent()).toContain('could not inspect');
|
||||
expect(requests.filter(request => request.path === '/domains')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('uses the explicit profile only for its matching browser before painting the active pill', async () => {
|
||||
options = { browser: 'chrome', profile: 'Profile 1' };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
});
|
||||
|
||||
test('recognizes supported browser aliases and binds the explicit profile to the canonical browser', async () => {
|
||||
options = { browser: 'GOOGLE-CHROME', profile: 'Profile 1' };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Chrome');
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
await page.getByRole('button', { name: 'Chrome', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Import .chrome-profile-1.test', exact: true }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/profiles').map(request => request.browser)).toEqual(['Chrome', 'Dia', 'Chrome']);
|
||||
});
|
||||
|
||||
test('does not interpret a browser alias substring as an installed browser', async () => {
|
||||
options = { browser: 'google' };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'requested browser is unavailable' }).waitFor();
|
||||
expect(await page.locator('.pill.active').count()).toBe(0);
|
||||
expect(requests.filter(request => request.path === '/profiles')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not silently replace a missing explicit profile or browser', async () => {
|
||||
options = { browser: 'Chrome', profile: 'Missing' };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'requested profile is unavailable' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/domains')).toHaveLength(0);
|
||||
options = { browser: 'Missing browser', profile: 'Default' };
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'requested browser is unavailable' }).waitFor();
|
||||
expect(await page.locator('.pill.active').count()).toBe(0);
|
||||
});
|
||||
|
||||
for (const earlierFirst of [true, false]) {
|
||||
test(`ignores stale profile responses when the earlier request returns ${earlierFirst ? 'first' : 'last'}`, async () => {
|
||||
const chrome = Promise.withResolvers<Response>();
|
||||
const dia = Promise.withResolvers<Response>();
|
||||
const chromeSeen = Promise.withResolvers<void>();
|
||||
const diaSeen = Promise.withResolvers<void>();
|
||||
handler = request => {
|
||||
if (request.path !== '/profiles') return;
|
||||
if (request.browser === 'Chrome') { chromeSeen.resolve(); return chrome.promise; }
|
||||
diaSeen.resolve(); return dia.promise;
|
||||
};
|
||||
await openPicker();
|
||||
await chromeSeen.promise;
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).click();
|
||||
await diaSeen.promise;
|
||||
if (earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('/profiles?browser=Chrome'));
|
||||
chrome.resolve(Response.json(profiles.Chrome));
|
||||
await response;
|
||||
expect(await page.locator('.profile-pill.active').count()).toBe(0);
|
||||
}
|
||||
dia.resolve(Response.json(profiles.Dia));
|
||||
await page.getByRole('button', { name: 'Import .dia-profile-2.test', exact: true }).waitFor();
|
||||
if (!earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('/profiles?browser=Chrome'));
|
||||
chrome.resolve(Response.json(profiles.Chrome));
|
||||
await response;
|
||||
}
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Dia');
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 2');
|
||||
expect(requests.filter(request => request.path === '/domains').map(request => request.browser)).toEqual(['Dia']);
|
||||
});
|
||||
}
|
||||
|
||||
for (const earlierFirst of [true, false]) {
|
||||
test(`ignores stale domain responses when the earlier request returns ${earlierFirst ? 'first' : 'last'}`, async () => {
|
||||
const first = Promise.withResolvers<Response>();
|
||||
const second = Promise.withResolvers<Response>();
|
||||
const firstSeen = Promise.withResolvers<void>();
|
||||
const secondSeen = Promise.withResolvers<void>();
|
||||
handler = request => {
|
||||
if (request.path !== '/domains') return;
|
||||
if (request.profile === 'Default') { firstSeen.resolve(); return first.promise; }
|
||||
secondSeen.resolve(); return second.promise;
|
||||
};
|
||||
await openPicker();
|
||||
await firstSeen.promise;
|
||||
await page.getByRole('button', { name: 'Personal (Profile 1)', exact: true }).click();
|
||||
await secondSeen.promise;
|
||||
if (earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('&profile=Default'));
|
||||
first.resolve(Response.json({ domains: [{ domain: '.stale.test', count: 4 }] }));
|
||||
await response;
|
||||
expect(await page.locator('.btn-add').count()).toBe(0);
|
||||
}
|
||||
second.resolve(Response.json({ domains: [{ domain: '.current.test', count: 2 }] }));
|
||||
await page.getByRole('button', { name: 'Import .current.test', exact: true }).waitFor();
|
||||
if (!earlierFirst) {
|
||||
const response = page.waitForResponse(response => response.url().includes('&profile=Default'));
|
||||
first.resolve(Response.json({ domains: [{ domain: '.stale.test', count: 4 }] }));
|
||||
await response;
|
||||
}
|
||||
expect(await page.locator('#source-domains .domain-name').allTextContents()).toEqual(['.current.test']);
|
||||
expect(await page.locator('.profile-pill.active').getAttribute('data-profile')).toBe('Profile 1');
|
||||
});
|
||||
}
|
||||
|
||||
for (const outcome of ['partial', 'empty', 'failed']) {
|
||||
test(`reports an HTTP-200 ${outcome} receipt persistently rather than as silent success`, async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ imported: outcome === 'partial' ? 2 : 0, failed: 3,
|
||||
domainCounts: outcome === 'partial' ? { '.chrome-default.test': 2 } : {}, failureReasons: { unsupported_encryption: 3 },
|
||||
outcome, message: 'Synthetic import receipt.', reset: outcome === 'failed' ? 'failed' : 'not_requested', verification: { verified: false, reason: 'not_requested' } }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Synthetic import receipt.' }).waitFor();
|
||||
const status = await page.getByRole('status').textContent();
|
||||
expect(status).toContain(outcome === 'partial' ? 'Partial import.' : outcome === 'empty' ? 'No cookies imported.' : 'Import failed.');
|
||||
expect(status).toContain('3 failed.');
|
||||
expect(status).toContain('unsupported encryption: 3.');
|
||||
expect(status).toContain('Authentication not checked.');
|
||||
expect(await page.getByRole('status').getAttribute('class')).toContain(outcome === 'failed' ? 'error' : 'warning');
|
||||
expect(await page.locator('.btn-add.imported').count()).toBe(0);
|
||||
expect(await page.locator('#imported-domains .domain-name').count()).toBe(outcome === 'partial' ? 1 : 0);
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('sets imported counts on explicit reimport rather than adding them again', async () => {
|
||||
imported = [{ domain: '.chrome-default.test', count: 6 }];
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Reimport .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('#imported-domains .domain-count').textContent()).toBe('2');
|
||||
await page.getByRole('button', { name: 'Reimport .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('#imported-domains .domain-count').textContent()).toBe('2');
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('serializes imports, disables source switches, and does not repeat POSTs on double submit', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
imported = [{ domain: '.already.test', count: 1 }];
|
||||
const result = Promise.withResolvers<Response>();
|
||||
const seen = Promise.withResolvers<void>();
|
||||
handler = request => { if (request.path === '/import') { seen.resolve(); return result.promise; } };
|
||||
await openPicker();
|
||||
const add = page.getByRole('button', { name: 'Import .chrome-default.test', exact: true });
|
||||
await add.evaluate((element: HTMLButtonElement) => { element.click(); element.click(); });
|
||||
await seen.promise;
|
||||
expect(await page.getByRole('button', { name: 'Dia', exact: true }).isDisabled()).toBe(true);
|
||||
expect(await page.getByRole('button', { name: 'Personal (Profile 1)', exact: true }).isDisabled()).toBe(true);
|
||||
for (const selector of ['#btn-import-all', '#clear-storage', '#verify-auth', '#search', '.btn-trash']) expect(await page.locator(selector).isDisabled()).toBe(true);
|
||||
await page.locator('#btn-import-all').evaluate((element: HTMLButtonElement) => element.click());
|
||||
await page.getByRole('button', { name: 'Dia', exact: true }).evaluate((element: HTMLButtonElement) => element.click());
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
result.resolve(Response.json({ imported: 1, failed: 0, domainCounts: { '.chrome-default.test': 1 }, outcome: 'imported', reset: 'not_requested', verification: { verified: false, reason: 'not_requested' } }));
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('.pill.active').textContent()).toBe('Chrome');
|
||||
expect(await page.getByRole('button', { name: 'Dia', exact: true }).isEnabled()).toBe(true);
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('serializes removal with imports and retains counts on a failed removal', async () => {
|
||||
imported = [{ domain: '.already.test', count: 1 }];
|
||||
const result = Promise.withResolvers<Response>();
|
||||
const seen = Promise.withResolvers<void>();
|
||||
handler = request => { if (request.path === '/remove') { seen.resolve(); return result.promise; } };
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Remove .already.test', exact: true }).click();
|
||||
await seen.promise;
|
||||
expect(await page.locator('.btn-add').isDisabled()).toBe(true);
|
||||
await page.locator('.btn-add').evaluate((element: HTMLButtonElement) => element.click());
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(0);
|
||||
result.resolve(Response.json({ error: 'synthetic-private-error' }, { status: 500 }));
|
||||
await page.getByRole('status').filter({ hasText: 'Cookie removal did not complete.' }).waitFor();
|
||||
expect(await page.locator('#imported-domains .domain-count').textContent()).toBe('1');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(requests.filter(request => request.path === '/remove')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('reports mutation errors without replaying the POST or exposing raw errors', async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ error: 'synthetic-private-error', action: 'retry' }, { status: 503 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Import did not complete' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
expect(await page.locator('.btn-add').isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
for (const [code, guidance] of [
|
||||
['keychain_denied', 'Allow access in the OS permission prompt or settings'],
|
||||
['keychain_timeout', 'Check for a pending OS permission prompt'],
|
||||
['keychain_error', 'Check the OS credential store'],
|
||||
['db_locked', 'Close the source browser, then retry manually'],
|
||||
['db_corrupt', 'Choose another profile or sign in manually'],
|
||||
['db_permission', 'Check source-profile permissions'],
|
||||
['db_read_error', 'Cookie data could not be read from this profile'],
|
||||
['sqlite_unavailable', 'Node.js 22.13 or newer with built-in SQLite enabled'],
|
||||
['storage_reset_unsupported', 'Storage reset requires a Chromium target'],
|
||||
['profile_required', 'Choose a source profile explicitly'],
|
||||
['target_changed', 'Reopen the picker from the intended HTTP(S) page'],
|
||||
['target_closed', 'The captured target is closed'],
|
||||
['target_mismatch', 'Select its cookie domain or reopen the picker'],
|
||||
['not_supported', 'Native cookie import is unsupported'],
|
||||
['native_profile_unsupported', 'This browser profile does not support native cookie extraction'],
|
||||
['native_unqualified', 'process ownership and cleanup are not qualified'],
|
||||
['native_cleanup_failed', 'Inspect the source browser before any manual retry'],
|
||||
['native_supervision_failed', 'Native browser supervision could not start'],
|
||||
['native_timeout', 'Native cookie extraction timed out'],
|
||||
['browser_running', 'Close it yourself before retrying'],
|
||||
]) {
|
||||
test(`shows actionable allowlisted ${code} guidance without server prose or automatic replay`, async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ code, action: 'retry',
|
||||
error: '<img src=x onerror="window.errorXss=1"> synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Import did not complete' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain(guidance);
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).errorXss)).toBeUndefined();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
expect(await page.locator('.btn-add').isEnabled()).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const code of ['unknown_private_code', '__proto__', '<img src=x onerror="window.errorXss=1">']) {
|
||||
test(`uses safe generic guidance for an unallowlisted error code ${code.startsWith('<') ? 'markup' : code}`, async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ code, error: 'synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Import did not complete' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Inspect the source and destination before retrying');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain(code);
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const path of ['/profiles', '/domains']) {
|
||||
test(`preserves safe actionable guidance on ${path} read failures`, async () => {
|
||||
handler = request => request.path === path ? Response.json({ code: 'db_locked', error: 'synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('status').filter({ hasText: 'source cookie database is busy' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Close the source browser, then retry manually');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('synthetic-private-error');
|
||||
expect(requests.filter(request => request.path === path)).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('permits a deliberate manual retry after permission recovery without replaying automatically', async () => {
|
||||
handler = request => request.path === '/import' ? Response.json({ code: 'keychain_denied', action: 'retry', error: 'synthetic-private-error' }, { status: 400 }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Keychain access was denied' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
handler = () => undefined;
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('imports the visible filtered domains in one request with explicit target options', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
handler = request => request.path === '/domains' ? Response.json({ domains: [{ domain: '.one.test', count: 3 }, { domain: '.two.test', count: 2 }, { domain: '.other.invalid', count: 1 }] }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .one.test', exact: true }).waitFor();
|
||||
await page.getByRole('textbox', { name: 'Search cookie domains' }).fill('.test');
|
||||
await page.locator('#clear-storage').check();
|
||||
await page.locator('#verify-auth').check();
|
||||
await page.getByRole('button', { name: 'Import All (2)', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(requests.filter(request => request.path === '/import')).toHaveLength(1);
|
||||
expect(requests.find(request => request.path === '/import')?.body).toEqual({ browser: 'Chrome', profile: 'Default', domains: ['.one.test', '.two.test'], clearStorage: true, verifyAuth: true });
|
||||
expect(await page.getByRole('status').textContent()).toContain('Authentication not verified');
|
||||
});
|
||||
|
||||
for (const importedCount of [0, 2]) {
|
||||
test(`requires a requested positive check and nonzero import before verified (${importedCount} imported)`, async () => {
|
||||
options = { targetOrigin: 'https://target.test', verifyAuth: true, verificationAvailable: true, clearStorage: true };
|
||||
handler = request => request.path === '/import' ? Response.json({ imported: importedCount, failed: 0, domainCounts: {}, outcome: importedCount ? 'imported' : 'empty', reset: 'cleared', verification: { verified: true, reason: 'verified' } }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Storage cleared for' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain(importedCount ? 'Authentication verified on the captured target.' : 'Authentication not verified.');
|
||||
});
|
||||
}
|
||||
|
||||
test('does not claim verification for an unrequested check even if a response says verified', async () => {
|
||||
options = { targetOrigin: 'https://target.test', verificationAvailable: true };
|
||||
handler = request => request.path === '/import' ? Response.json({ imported: 2, failed: 0, domainCounts: {}, outcome: 'imported', verification: { verified: true } }) : undefined;
|
||||
await openPicker();
|
||||
await page.getByRole('button', { name: 'Import .chrome-default.test', exact: true }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.getByRole('status').textContent()).toContain('Authentication not checked.');
|
||||
expect(await page.getByRole('status').textContent()).not.toContain('Authentication verified');
|
||||
});
|
||||
|
||||
test('escapes script configuration, profile attributes, domain attributes, and status text', async () => {
|
||||
const payload = '\"\'><img src=x onerror="window.fixtureXss=1"></script><script>window.fixtureXss=1</script>';
|
||||
options = { browser: payload, profile: payload, targetOrigin: 'https://target.test' };
|
||||
browsers = [{ name: payload }];
|
||||
profiles = { [payload]: { profiles: [{ name: payload, displayName: payload }] } };
|
||||
handler = request => request.path === '/domains' ? Response.json({ domains: [{ domain: payload, count: 1 }] })
|
||||
: request.path === '/import' ? Response.json({ imported: 1, failed: 0, domainCounts: { [payload]: 1 }, outcome: 'imported', message: payload }) : undefined;
|
||||
await openPicker();
|
||||
await page.locator('.btn-add').waitFor();
|
||||
expect(await page.locator('.profile-pill').getAttribute('data-profile')).toBe(payload);
|
||||
expect(await page.locator('.btn-add').getAttribute('data-domain')).toBe(payload);
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).fixtureXss)).toBeUndefined();
|
||||
await page.locator('.btn-add').click();
|
||||
await page.getByRole('status').filter({ hasText: 'Cookies imported.' }).waitFor();
|
||||
expect(await page.locator('img').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).fixtureXss)).toBeUndefined();
|
||||
expect(requests.find(request => request.path === '/import')?.body.profile).toBe(payload);
|
||||
expect(await page.locator('.btn-trash').getAttribute('data-domain')).toBe(payload);
|
||||
});
|
||||
|
||||
test('serializes only a safe target origin, never credentials, path, or query data', async () => {
|
||||
const target = new URL('https://target.test/private-path?fixture-token=private-value');
|
||||
target.username = 'fixture-user';
|
||||
target.password = 'fixture';
|
||||
options = { targetOrigin: target.href };
|
||||
const html = getCookiePickerHTML(server.port!, options);
|
||||
expect(html).not.toContain('fixture-user');
|
||||
expect(html).not.toContain('private-path');
|
||||
expect(html).not.toContain('private-value');
|
||||
await openPicker();
|
||||
expect(await page.locator('#target-origin').textContent()).toBe('https://target.test');
|
||||
expect(await page.getByRole('status').getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseGuiReadiness, runGuiReadiness, validateGuiReadinessAuthority } from '../../.github/scripts/qualify-dia-macos';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'dia-gui-readiness-test-')));
|
||||
const source = path.resolve(import.meta.dir, '../../.github/scripts/dia-gui-readiness.c');
|
||||
const executable = path.join(root, 'metadata-probe');
|
||||
const names = ['chrome', 'chromium', 'arc', 'dia', 'comet', 'brave', 'edge', 'safari', 'cookies'];
|
||||
const observation = () => ({ protocol: 1, supported: true, identity: { effectiveUidMatches: true, homeMatchesRegistered: true },
|
||||
security: { status: 0, graphicAccess: true, rootSession: false, tty: false, remote: false },
|
||||
quartz: { present: true, sameUid: true, loginDone: true, onConsole: true }, browserRoots: null as any });
|
||||
|
||||
beforeAll(() => {
|
||||
const wrapper = path.join(root, 'metadata-probe.c');
|
||||
writeFileSync(wrapper, `#define main native_readiness_main\n#include ${JSON.stringify(source)}\n#undef main
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) return 2;
|
||||
int home = open(argv[1], O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK);
|
||||
if (home < 0) return 2;
|
||||
print_browser_roots(home, getuid()); close(home); printf("\\n"); return 0;
|
||||
}`);
|
||||
const compiler = Bun.which('clang');
|
||||
if (!compiler) throw new Error('clang is required for the POSIX metadata regression');
|
||||
const compiled = spawnSync(compiler, ['-std=c11', '-O2', '-Wall', '-Wextra', wrapper,
|
||||
...(process.platform === 'darwin' ? ['-framework', 'Security', '-framework', 'ApplicationServices'] : []), '-o', executable],
|
||||
{ encoding: 'utf8', timeout: 30_000 });
|
||||
if (compiled.status !== 0) throw new Error('metadata_fixture_compile_failed');
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const inspect = (home: string) => {
|
||||
const result = spawnSync(executable, [home], { encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
|
||||
describe('read-only GUI readiness', () => {
|
||||
test('metadata inspection reports missing fixed roots without creating state', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'empty-')));
|
||||
const before = readdirSync(home);
|
||||
const result = inspect(home);
|
||||
expect(Object.keys(result)).toEqual(names);
|
||||
for (const name of names) expect(result[name]).toEqual({ state: 'absent', kind: null, ownerMatches: null, ancestorBlocked: false });
|
||||
expect(readdirSync(home)).toEqual(before);
|
||||
expect(lstatSync(home).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
test('metadata inspection never follows ancestor or target symlinks', () => {
|
||||
const outside = realpathSync(mkdtempSync(path.join(root, 'other-')));
|
||||
writeFileSync(path.join(outside, 'private-sentinel'), 'unchanged fixture contents');
|
||||
const linked = realpathSync(mkdtempSync(path.join(root, 'linked-')));
|
||||
symlinkSync(outside, path.join(linked, 'Library'), 'dir');
|
||||
const blocked = inspect(linked);
|
||||
for (const name of names) expect(blocked[name]).toEqual({ state: 'unavailable', kind: 'symlink', ownerMatches: true, ancestorBlocked: true });
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'target-')));
|
||||
mkdirSync(path.join(home, 'Library'));
|
||||
symlinkSync(outside, path.join(home, 'Library/Safari'), 'dir');
|
||||
expect(inspect(home).safari).toEqual({ state: 'present', kind: 'symlink', ownerMatches: true, ancestorBlocked: false });
|
||||
expect(readdirSync(outside)).toEqual(['private-sentinel']);
|
||||
expect(readFileSync(path.join(outside, 'private-sentinel'), 'utf8')).toBe('unchanged fixture contents');
|
||||
});
|
||||
|
||||
test('wrong ancestor types and access failures are unavailable, never absent', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'wrong-type-')));
|
||||
writeFileSync(path.join(home, 'Library'), 'not a directory');
|
||||
expect(inspect(home).dia).toEqual({ state: 'unavailable', kind: 'file', ownerMatches: true, ancestorBlocked: true });
|
||||
const denied = realpathSync(mkdtempSync(path.join(root, 'denied-')));
|
||||
const library = path.join(denied, 'Library');
|
||||
mkdirSync(library, { mode: 0o700 });
|
||||
chmodSync(library, 0);
|
||||
try {
|
||||
expect(inspect(denied).dia).toEqual({ state: 'unavailable', kind: 'directory', ownerMatches: true, ancestorBlocked: true });
|
||||
expect(lstatSync(library).mode & 0o777).toBe(0);
|
||||
} finally { chmodSync(library, 0o700); }
|
||||
});
|
||||
|
||||
test('present root metadata does not open contents or change them', () => {
|
||||
const home = realpathSync(mkdtempSync(path.join(root, 'present-')));
|
||||
const safari = path.join(home, 'Library/Safari');
|
||||
mkdirSync(safari, { recursive: true });
|
||||
const file = path.join(safari, 'private-sentinel');
|
||||
writeFileSync(file, 'private fixture content');
|
||||
chmodSync(file, 0);
|
||||
const before = lstatSync(file);
|
||||
const result = inspect(home);
|
||||
expect(result.safari).toEqual({ state: 'present', kind: 'directory', ownerMatches: true, ancestorBlocked: false });
|
||||
expect(lstatSync(file).mode).toBe(before.mode);
|
||||
expect(lstatSync(file).mtimeMs).toBe(before.mtimeMs);
|
||||
expect(JSON.stringify(result)).not.toContain('private');
|
||||
chmodSync(file, 0o600);
|
||||
expect(readFileSync(file, 'utf8')).toBe('private fixture content');
|
||||
});
|
||||
|
||||
test('GUI usability requires both the security capability and the caller-owned logged-in Quartz session', () => {
|
||||
const original = observation();
|
||||
const before = JSON.stringify(original);
|
||||
Object.freeze(original.identity); Object.freeze(original.security); Object.freeze(original.quartz); Object.freeze(original);
|
||||
expect(parseGuiReadiness(original, false).usableGui).toBe(true);
|
||||
expect(JSON.stringify(original)).toBe(before);
|
||||
for (const changed of [
|
||||
{ ...observation(), security: { ...observation().security, graphicAccess: false } },
|
||||
{ ...observation(), quartz: { present: false, sameUid: null, loginDone: null, onConsole: null } },
|
||||
{ ...observation(), quartz: { present: true, sameUid: false, loginDone: null, onConsole: null } },
|
||||
{ ...observation(), quartz: { ...observation().quartz, loginDone: false } },
|
||||
{ ...observation(), identity: { effectiveUidMatches: true, homeMatchesRegistered: false } },
|
||||
{ ...observation(), security: { status: -60500, graphicAccess: null, rootSession: null, tty: null, remote: null } },
|
||||
]) expect(parseGuiReadiness(changed, false).usableGui).toBe(false);
|
||||
});
|
||||
|
||||
test('schema rejects extra identity text, cross-UID session details and false absence claims', () => {
|
||||
for (const changed of [
|
||||
{ ...observation(), username: 'private-name' },
|
||||
{ ...observation(), quartz: { ...observation().quartz, uid: 501 } },
|
||||
{ ...observation(), quartz: { present: true, sameUid: false, loginDone: true, onConsole: true } },
|
||||
{ ...observation(), security: { ...observation().security, status: -1 } },
|
||||
{ ...observation(), security: { ...observation().security, graphicAccess: 1 } },
|
||||
]) expect(() => parseGuiReadiness(changed, false)).toThrow('invalid_gui_readiness_receipt');
|
||||
const roots = Object.fromEntries(names.map(name => [name, { state: 'absent', kind: null, ownerMatches: null, ancestorBlocked: false }]));
|
||||
expect(parseGuiReadiness({ ...observation(), browserRoots: roots }, true).browserRoots).toEqual(roots);
|
||||
expect(() => parseGuiReadiness({ ...observation(), browserRoots: roots }, false)).toThrow('invalid_gui_readiness_receipt');
|
||||
expect(() => parseGuiReadiness({ ...observation(), browserRoots: { ...roots, dia: { state: 'absent', kind: 'symlink', ownerMatches: true, ancestorBlocked: true } } }, true))
|
||||
.toThrow('invalid_gui_readiness_receipt');
|
||||
});
|
||||
|
||||
test('GUI authority cannot carry browser/comparison authority or placeholder destination hashes', () => {
|
||||
const account: any = { work: '/private/tmp/dn-fixture', guiReadiness: { mode: 'gui-readiness-only', executable: '/private/tmp/dn-fixture/bin/gui-readiness',
|
||||
executableSha256: 'a'.repeat(64), sourceSha256: 'b'.repeat(64) } };
|
||||
expect(() => validateGuiReadinessAuthority(account, 'coordinator')).not.toThrow();
|
||||
for (const changed of [{ ...account, launchComparison: {} }, { ...account, destinationExecutable: '' }, { ...account, destinationSha256: 'a'.repeat(64) },
|
||||
{ ...account, guiReadiness: null }, { ...account, guiReadiness: { ...account.guiReadiness, executableSha256: ['a'.repeat(64)] } },
|
||||
{ ...account, guiReadiness: { ...account.guiReadiness, executable: '/unowned/probe' } }]) {
|
||||
expect(() => validateGuiReadinessAuthority(changed, 'coordinator')).toThrow('gui_readiness_authority_invalid');
|
||||
}
|
||||
expect(() => validateGuiReadinessAuthority(account, 'comparison-driver')).toThrow('gui_readiness_authority_invalid');
|
||||
});
|
||||
|
||||
test('the registered probe validates hashes, bounds execution, and discards unrecognized output', async () => {
|
||||
const hash = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
const exeHash = hash(executable);
|
||||
const sourceHash = hash(source);
|
||||
const input = observation();
|
||||
const execute = ((command: string, args: string[], options: any) => {
|
||||
expect(command).toBe(executable); expect(args).toEqual([]);
|
||||
expect(options.timeout).toBeGreaterThan(0); expect(options.timeout).toBeLessThanOrEqual(5000);
|
||||
expect(options.killSignal).toBe('SIGKILL'); expect(options.maxBuffer).toBe(16 * 1024);
|
||||
return { status: 0, stdout: JSON.stringify(input), stderr: 'private diagnostic text' };
|
||||
}) as typeof spawnSync;
|
||||
const result = await runGuiReadiness(executable, exeHash, source, sourceHash, false, { HOME: root }, 5000, execute);
|
||||
expect(result.available).toBe(true);
|
||||
expect(JSON.stringify(result)).not.toContain('private diagnostic');
|
||||
await expect(runGuiReadiness(executable, 'c'.repeat(64), source, sourceHash, false, {}, 5000, execute)).rejects.toThrow('gui_readiness_inputs_changed');
|
||||
const malformed = (() => ({ status: 0, stdout: JSON.stringify({ ...input, username: 'private-name' }), stderr: '' })) as typeof spawnSync;
|
||||
expect((await runGuiReadiness(executable, exeHash, source, sourceHash, false, {}, 5000, malformed)).available).toBe(false);
|
||||
for (const timeout of [0, NaN, Infinity]) await expect(runGuiReadiness(executable, exeHash, source, sourceHash, false, {}, timeout, execute)).rejects.toThrow('gui_readiness_budget_exhausted');
|
||||
});
|
||||
|
||||
test('the readiness launcher loads without node_modules or Playwright and rejects conflicting CLI modes', () => {
|
||||
const directory = path.join(root, 'no-dependencies/.github/scripts');
|
||||
mkdirSync(directory, { recursive: true });
|
||||
for (const file of ['run-dia-native-qualification.ts', 'qualify-dia-macos.ts']) copyFileSync(path.resolve(import.meta.dir, '../../.github/scripts', file), path.join(directory, file));
|
||||
for (const args of [['--gui-readiness-only'], ['--gui-readiness-only', '--launch-comparison', 'node']]) {
|
||||
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=/dev/null', path.join(directory, 'run-dia-native-qualification.ts'), ...args],
|
||||
{ cwd: root, env: { HOME: root, PATH: '/usr/bin:/bin' }, encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(2); expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout).reason).toBe('fresh_account_launcher_preflight_failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { compareDiaLaunchReceipts, normalizedLaunchHashes } from '../../.github/scripts/dia-launch-driver.mjs';
|
||||
import { runDiaLaunchComparison, safeDiaComparisonResponse } from '../../.github/scripts/qualify-dia-macos';
|
||||
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'dc-')));
|
||||
const driverFile = path.resolve(import.meta.dir, '../../.github/scripts/dia-launch-driver.mjs');
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('diagnostic-only Dia runtime comparison', () => {
|
||||
test('normalization replaces only declared owned path boundaries', () => {
|
||||
const first = normalizedLaunchHashes(['--user-data-dir=/owned/a/home/profile', '--headless'], { HOME: '/owned/a/home', PATH: '/usr/bin:/bin' }, { home: '/owned/a/home' });
|
||||
const second = normalizedLaunchHashes(['--user-data-dir=/owned/b/home/profile', '--headless'], { HOME: '/owned/b/home', PATH: '/usr/bin:/bin' }, { home: '/owned/b/home' });
|
||||
expect(first).toEqual(second);
|
||||
expect(normalizedLaunchHashes(['--user-data-dir=/owned/a/home-other'], {}, { home: '/owned/a/home' }).argvSha256)
|
||||
.not.toBe(normalizedLaunchHashes(['--user-data-dir=/owned/b/home-other'], {}, { home: '/owned/b/home' }).argvSha256);
|
||||
expect(normalizedLaunchHashes(['--label=https://private.invalid/owned/a/home'], {}, { home: '/owned/a/home' }).argvSha256)
|
||||
.not.toBe(normalizedLaunchHashes(['--label=https://private.invalid/owned/b/home'], {}, { home: '/owned/b/home' }).argvSha256);
|
||||
expect(JSON.stringify(first)).not.toContain('/owned');
|
||||
});
|
||||
|
||||
test('IPC rejects raw payloads at any nesting depth', () => {
|
||||
for (const value of [{ args: ['private-argv'] }, { cleanup: { raw: 'private-value' } }, { error: 'private-error-value' },
|
||||
{ startupPages: { categories: ['https://private.invalid'] } }, { driver: { release: '/private/path' } }]) {
|
||||
expect(safeDiaComparisonResponse(value)).toBe(false);
|
||||
}
|
||||
expect(safeDiaComparisonResponse({ protocol: 1, purpose: 'source', error: 'operation_timeout', cleanup: { confirmed: false } })).toBe(true);
|
||||
});
|
||||
|
||||
test('the actual driver entry refuses an unapproved host without exposing its request', () => {
|
||||
for (const executable of [process.execPath, Bun.which('node')!]) {
|
||||
const result = spawnSync(executable, [driverFile, '/private-untrusted/account.json'], {
|
||||
env: { PATH: '/usr/bin:/bin', HOME: root }, input: '{"private":"request-value"}', encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toEqual({ protocol: 1, ready: false, error: 'driver_admission_failed', cleanup: { confirmed: false } });
|
||||
}
|
||||
});
|
||||
|
||||
test('the real bounded stdin callback works under both runtimes and refuses raw request payloads', () => {
|
||||
const entry = path.join(root, 'stdin-probe.mjs');
|
||||
writeFileSync(entry, `import { readComparisonRequest } from ${JSON.stringify(pathToFileURL(driverFile).href)};
|
||||
try { const request = readComparisonRequest(); process.stdout.write(JSON.stringify({purpose:request.purpose})); }
|
||||
catch { process.stdout.write(JSON.stringify({rejected:true})); }`);
|
||||
for (const executable of [process.execPath, Bun.which('node')!]) {
|
||||
for (const input of ['{"purpose":"control"}', '{"purpose":"control","env":{"PRIVATE":"value"}}', 'x'.repeat(16 * 1024 + 1)]) {
|
||||
const result = spawnSync(executable, [entry], { env: { HOME: root, PATH: '/usr/bin:/bin' }, input, encoding: 'utf8', timeout: 5000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(JSON.parse(result.stdout)).toEqual(input === '{"purpose":"control"}' ? { purpose: 'control' } : { rejected: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
test('both pinned runtimes execute the same protected worker with matched normalized inputs', async () => {
|
||||
const node = Bun.which('node');
|
||||
if (!node) throw new Error('Node 24.18 is required for the comparison regression');
|
||||
const entry = path.join(root, 'probe.mjs');
|
||||
const executablePath = realpathSync((await import('playwright')).chromium.executablePath());
|
||||
writeFileSync(entry, `import { runProtectedLaunch } from ${JSON.stringify(pathToFileURL(driverFile).href)};
|
||||
const home = process.env.HOME;
|
||||
const result = await runProtectedLaunch(${JSON.stringify(executablePath)}, home + '/profile',
|
||||
{ HOME: home, TMPDIR: home + '/tmp', PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'en_US.UTF-8' }, 'control', { home });
|
||||
process.stdout.write(JSON.stringify(result) + '\\n');`);
|
||||
const results: any[] = [];
|
||||
for (const [name, executable] of [['bun', process.execPath], ['node', node]]) {
|
||||
const home = path.join(root, name);
|
||||
mkdirSync(home);
|
||||
mkdirSync(path.join(home, 'tmp'));
|
||||
const result = spawnSync(executable, [entry], { env: { HOME: home, PATH: '/usr/bin:/bin:/usr/sbin:/sbin' },
|
||||
encoding: 'utf8', timeout: 40_000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
const observed = JSON.parse(result.stdout);
|
||||
expect(observed.ready).toBe(true);
|
||||
expect(observed.protocolResponded).toBe(true);
|
||||
expect(observed.cleanup.confirmed).toBe(true);
|
||||
expect(observed.cleanup.childClosed).toBe(true);
|
||||
expect(observed.cleanup.groupAbsent).toBe(true);
|
||||
expect(observed.cleanup.launchSettled).toBe(true);
|
||||
expect(observed.rootCount).toBe(1);
|
||||
expect(observed.launchAttempts[0].pipeFlag).toBe(true);
|
||||
expect(observed.launchAttempts[0].sandboxDisablingFlag).toBe(false);
|
||||
expect(observed.samplingEnabled).toBe(false);
|
||||
expect(safeDiaComparisonResponse(observed)).toBe(true);
|
||||
expect(JSON.stringify(observed)).not.toContain(home);
|
||||
results.push(observed);
|
||||
}
|
||||
expect(results[0].argvSha256).toBe(results[1].argvSha256);
|
||||
expect(results[0].environmentSha256).toBe(results[1].environmentSha256);
|
||||
}, 90_000);
|
||||
|
||||
const arm = (runtime: 'bun' | 'node', ready: boolean) => {
|
||||
const sha = 'a'.repeat(64);
|
||||
const config = { mode: 'launch-only', runtime, qualificationCredit: false, executableSha256: runtime === 'bun' ? sha : 'b'.repeat(64), driverSha256: sha, helpersSha256: sha };
|
||||
const driver = { runtime, version: runtime === 'bun' ? '1.4.0' : '24.18.0', architecture: 'arm64', os: 'darwin', release: '24.6.0', playwright: '1.62.1',
|
||||
executableSha256: config.executableSha256, driverSha256: sha, helpersSha256: sha };
|
||||
const result = (purpose: string, success: boolean) => ({ protocol: 1, purpose, ready: success, launchReturned: success, protocolResponded: success,
|
||||
samplingEnabled: false, rootCount: 1, supervisor: { closed: true, exitCode: 0 }, cleanup: { confirmed: true, childClosed: true, groupAbsent: true, launchSettled: true,
|
||||
groups: [{ absenceConfirmed: true, childCloseObserved: true }] }, startupPages: { allowed: true }, postProbePages: { allowed: true },
|
||||
argvSha256: sha, environmentSha256: sha, driver, launchAttempts: [{ sandboxRequired: true, sandboxDisablingFlag: false, pipeFlag: true,
|
||||
tcpDebuggingFlag: false, mockKeychainFlag: false, passwordStoreFlag: false, firstRunSuppressed: false, headlessFlag: true,
|
||||
expectedProfile: true, detached: true, shellDisabled: true, stdioCount: 5, extraPipeDescriptors: true, profileArgumentCount: 1 }] });
|
||||
return { launchComparison: config, counts: { pass: 0, fail: 0, skip: 0 }, launcher: { accountGuid: runtime + '-separate-account', sourceRevision: 'c'.repeat(40), archiveSha256: sha, destinationSha256: sha },
|
||||
launcherCleanup: { serviceStopped: true, userDomainStopped: true, userProcessesStopped: true, accountRemoved: true, groupRemoved: true, stagingRemoved: true },
|
||||
backgroundPreflight: { status: 'passed', comparisonControl: result('control', true) },
|
||||
qualification: { launchComparison: { qualificationCredit: false, source: result('source', ready) }, counts: { pass: 0, fail: 0, skip: 0 },
|
||||
keychainStage: 'completed', isolation: { registeredIdentity: true, sharedRegisteredHome: true },
|
||||
cleanup: { ownedBrowsersStopped: true, sourceProfileRemoved: true, keychainRestored: true, mountDetached: true, fixtureRemoved: true },
|
||||
platform: { os: 'darwin', architecture: 'arm64', bun: '1.4.0', playwright: '1.62.1', release: '24.6.0' },
|
||||
artifact: { signatureVerified: true, gatekeeperNotarized: true, macosCompatibility: { compatible: true, hostVersion: '15.7.9' },
|
||||
architectures: ['arm64'], sha256: sha, executableSha256: sha, version: '1.49.1', bundleId: 'company.thebrowser.dia', team: 'S6N382Y83G' } } };
|
||||
};
|
||||
|
||||
test('paired receipts distinguish runtime readiness without awarding qualification credit', () => {
|
||||
expect(compareDiaLaunchReceipts(arm('bun', false), arm('node', true))).toEqual({ comparable: true, qualificationCredit: false, outcome: 'node_only_ready' });
|
||||
expect(compareDiaLaunchReceipts(arm('bun', false), arm('node', false)).outcome).toBe('neither_ready');
|
||||
expect(compareDiaLaunchReceipts(arm('bun', true), arm('node', true)).outcome).toBe('both_ready');
|
||||
});
|
||||
|
||||
test('incomplete or incompatible arms cannot support a runtime conclusion', () => {
|
||||
const left = arm('bun', false);
|
||||
const mutations = [
|
||||
(right: any) => { right.backgroundPreflight.comparisonControl.ready = false; },
|
||||
(right: any) => { right.launcherCleanup.userDomainStopped = false; },
|
||||
(right: any) => { right.qualification.cleanup.sourceProfileRemoved = false; },
|
||||
(right: any) => { right.qualification.artifact.executableSha256 = 'b'.repeat(64); },
|
||||
(right: any) => { right.qualification.launchComparison.source.argvSha256 = 'b'.repeat(64); },
|
||||
(right: any) => { right.qualification.launchComparison.source.launchAttempts[0].sandboxDisablingFlag = true; },
|
||||
(right: any) => { right.qualification.launchComparison.source.driver.version = '24.19.0'; },
|
||||
(right: any) => { right.qualification.launchComparison.source.cleanup.launchSettled = false; },
|
||||
(right: any) => { right.counts.pass = 1; },
|
||||
(right: any) => { right.launcher.accountGuid = left.launcher.accountGuid; },
|
||||
(right: any) => { delete right.qualification.platform; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const right = arm('node', true);
|
||||
mutate(right);
|
||||
expect(compareDiaLaunchReceipts(left, right).comparable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('comparison supervisor binds code hashes and rejects extra private response fields', async () => {
|
||||
const work = path.join(root, 'ipc');
|
||||
mkdirSync(path.join(work, 'bin'), { recursive: true });
|
||||
mkdirSync(path.join(work, 'repo/.github/scripts'), { recursive: true });
|
||||
const executable = path.join(work, 'bin/node');
|
||||
const driver = path.join(work, 'repo/.github/scripts/dia-launch-driver.mjs');
|
||||
const helpers = path.join(work, 'repo/.github/scripts/qualify-dia-macos.ts');
|
||||
for (const file of [executable, driver, helpers]) writeFileSync(file, 'synthetic code');
|
||||
const hash = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
const account: any = { work, snapshot: path.join(work, 'repo'), configFile: path.join(work, 'account.json'), environment: { HOME: work },
|
||||
launchComparison: { mode: 'launch-only', runtime: 'node', executable, executableSha256: hash(executable), driverSha256: hash(driver), helpersSha256: hash(helpers) } };
|
||||
const response: any = { protocol: 1, purpose: 'control', ready: false, launchReturned: false, samplingEnabled: false, cleanup: { confirmed: false },
|
||||
driver: { runtime: 'node', version: '24.18.0', architecture: 'arm64', os: 'darwin', playwright: '1.62.1',
|
||||
executableSha256: hash(executable), driverSha256: hash(driver), helpersSha256: hash(helpers) } };
|
||||
let calls = 0;
|
||||
const execute = ((command: string, args: string[], options: any) => {
|
||||
calls++;
|
||||
expect(command).toBe(executable);
|
||||
expect(args).toEqual([driver, account.configFile]);
|
||||
expect(JSON.parse(options.input)).toEqual({ purpose: 'control' });
|
||||
expect(options.timeout).toBeLessThanOrEqual(40_000);
|
||||
expect(options.maxBuffer).toBe(64 * 1024);
|
||||
return { status: 0, stdout: JSON.stringify(response), stderr: '' };
|
||||
}) as typeof spawnSync;
|
||||
expect((await runDiaLaunchComparison(account, 'control', undefined, execute)).supervisor.exitCode).toBe(0);
|
||||
response.error = 'private-error-value';
|
||||
expect((await runDiaLaunchComparison(account, 'control', undefined, execute)).error).toBe('driver_exchange_failed');
|
||||
writeFileSync(executable, 'changed executable');
|
||||
await expect(runDiaLaunchComparison(account, 'control', undefined, execute)).rejects.toThrow('comparison_driver_inputs_changed');
|
||||
expect(calls).toBe(2);
|
||||
await expect(runDiaLaunchComparison({ ...account, launchComparison: undefined }, 'control', undefined, execute)).rejects.toThrow('comparison_driver_authority_missing');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+125
@@ -0,0 +1,125 @@
|
||||
import { lstatSync, unlinkSync } from 'node:fs';
|
||||
import { toNamespacedPath } from 'node:path';
|
||||
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
|
||||
type Identity = { dev: bigint; ino: bigint; mode: bigint };
|
||||
type Handle = number | bigint;
|
||||
type Stage = 'admission' | 'identity' | 'disposition' | 'close' | 'absence';
|
||||
|
||||
export class FixtureDeleteError extends Error {
|
||||
readonly code: string;
|
||||
readonly syscall: string;
|
||||
constructor(public stage: Stage, public path: string, public win32Error?: number, public deadlineExceeded = false) {
|
||||
super(`Owned fixture deletion failed at ${stage}${deadlineExceeded ? ' (deadline)' : win32Error === undefined ? '' : ` (Windows ${win32Error})`}`);
|
||||
this.name = 'FixtureDeleteError';
|
||||
this.code = deadlineExceeded ? 'ETIMEDOUT' : win32Error === 32 ? 'EBUSY' : win32Error === 2 || win32Error === 3 ? 'ENOENT' : win32Error === 5 ? 'EACCES' : 'EIO';
|
||||
this.syscall = { admission: 'open', identity: 'fstat', disposition: 'unlink', close: 'close', absence: 'lstat' }[stage];
|
||||
}
|
||||
}
|
||||
|
||||
export interface FixtureDeleteBackend {
|
||||
open(file: string): Handle;
|
||||
identity(handle: Handle, file: string): { dev: bigint; ino: bigint; attributes: number; filesystem: string };
|
||||
dispose(handle: Handle, file: string): void;
|
||||
close(handle: Handle, file: string): void;
|
||||
absent(file: string): boolean;
|
||||
}
|
||||
|
||||
type LeaseClock = { now(): number; wait(ms: number): void; onSharing?(): void };
|
||||
const leaseClock: LeaseClock = { now: () => performance.now(), wait: ms => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } };
|
||||
|
||||
export function deleteWithFixtureLease(file: string, expected: Identity, deadline: number, verify: () => void,
|
||||
backend: FixtureDeleteBackend, clock: LeaseClock = leaseClock): { admissionProbes: number; waitedMs: number } {
|
||||
if (!Number.isFinite(deadline)) throw new Error('Invalid fixture deletion deadline');
|
||||
let handle: Handle | undefined;
|
||||
let firstSharing: FixtureDeleteError | undefined;
|
||||
let admissionProbes = 0;
|
||||
let waitedMs = 0;
|
||||
while (handle === undefined) {
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
verify();
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
try { admissionProbes++; handle = backend.open(file); }
|
||||
catch (error) {
|
||||
if (!(error instanceof FixtureDeleteError) || error.stage !== 'admission' || error.win32Error !== 32) throw error;
|
||||
if (!firstSharing) { firstSharing = error; clock.onSharing?.(); }
|
||||
const remaining = deadline - clock.now();
|
||||
if (remaining <= 0) throw firstSharing;
|
||||
const before = clock.now();
|
||||
clock.wait(Math.min(20, remaining));
|
||||
waitedMs += Math.max(0, clock.now() - before);
|
||||
}
|
||||
}
|
||||
let failed = false;
|
||||
let failure: unknown;
|
||||
try {
|
||||
const current = backend.identity(handle, file);
|
||||
if (current.filesystem !== 'NTFS' || current.dev !== expected.dev || current.ino !== expected.ino
|
||||
|| (current.attributes & (0x1 | 0x10 | 0x400)) !== 0) throw new FixtureDeleteError('identity', file);
|
||||
verify();
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
backend.dispose(handle, file);
|
||||
} catch (error) { failed = true; failure = error; }
|
||||
try { backend.close(handle, file); }
|
||||
catch (error) {
|
||||
if (failed) console.error(JSON.stringify({ nativeFixtureDeleteCloseFailure: {
|
||||
stage: error instanceof FixtureDeleteError ? error.stage : 'close',
|
||||
win32Error: error instanceof FixtureDeleteError ? error.win32Error : undefined,
|
||||
} }));
|
||||
else { failed = true; failure = error; }
|
||||
}
|
||||
if (failed) throw failure;
|
||||
if (!backend.absent(file)) throw new FixtureDeleteError('absence', file);
|
||||
return { admissionProbes, waitedMs };
|
||||
}
|
||||
|
||||
export function createFixtureDeleteLease(deadline: number, onSharing?: () => void) {
|
||||
if (process.platform !== 'win32') return { unlink: (file: string, _identity: Identity, _verify: () => void) => unlinkSync(file), close: () => {} };
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
GetFileInformationByHandle: { args: [FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
|
||||
GetVolumeInformationByHandleW: { args: [FFIType.u64, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
||||
SetFileInformationByHandle: { args: [FFIType.u64, FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
GetLastError: { args: [], returns: FFIType.u32 },
|
||||
});
|
||||
const backend: FixtureDeleteBackend = {
|
||||
open(file) {
|
||||
const name = Buffer.from(toNamespacedPath(file) + '\0', 'utf16le');
|
||||
const handle = kernel.symbols.CreateFileW(ptr(name), 0x10080, 3, null, 3, 0x00200000, 0);
|
||||
const error = kernel.symbols.GetLastError();
|
||||
if (BigInt(handle) === 0xffffffffffffffffn || BigInt(handle) === 0n) throw new FixtureDeleteError('admission', file, error);
|
||||
return handle;
|
||||
},
|
||||
identity(handle, file) {
|
||||
const info = Buffer.alloc(52);
|
||||
if (!kernel.symbols.GetFileInformationByHandle(handle, ptr(info))) throw new FixtureDeleteError('identity', file, kernel.symbols.GetLastError());
|
||||
const filesystem = Buffer.alloc(128);
|
||||
if (!kernel.symbols.GetVolumeInformationByHandleW(handle, null, 0, null, null, null, ptr(filesystem), 64)) throw new FixtureDeleteError('identity', file, kernel.symbols.GetLastError());
|
||||
return { dev: BigInt(info.readUInt32LE(28)), ino: (BigInt(info.readUInt32LE(44)) << 32n) | BigInt(info.readUInt32LE(48)),
|
||||
attributes: info.readUInt32LE(0), filesystem: filesystem.toString('utf16le').split('\0')[0] };
|
||||
},
|
||||
dispose(handle, file) {
|
||||
const flags = Buffer.alloc(4);
|
||||
flags.writeUInt32LE(3);
|
||||
if (!kernel.symbols.SetFileInformationByHandle(handle, 21, ptr(flags), 4)) throw new FixtureDeleteError('disposition', file, kernel.symbols.GetLastError());
|
||||
},
|
||||
close(handle, file) {
|
||||
if (!kernel.symbols.CloseHandle(handle)) throw new FixtureDeleteError('close', file, kernel.symbols.GetLastError());
|
||||
},
|
||||
absent(file) {
|
||||
try { lstatSync(file); return false; }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true; throw error; }
|
||||
},
|
||||
};
|
||||
return {
|
||||
unlink(file: string, identity: Identity, verify: () => void) {
|
||||
if ((identity.mode & 0o170000n) !== 0o100000n) { verify(); unlinkSync(file); return; }
|
||||
const receipt = deleteWithFixtureLease(file, identity, deadline, verify, backend, { ...leaseClock, onSharing });
|
||||
if (receipt.admissionProbes > 1) console.log(JSON.stringify({ nativeFixtureSharingAdmission: {
|
||||
objectDev: identity.dev.toString(), objectIno: identity.ino.toString(), ...receipt, dispositionCalls: 1,
|
||||
} }));
|
||||
},
|
||||
close() { kernel.close(); },
|
||||
};
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { lstatSync, realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
|
||||
let stage = 'platform';
|
||||
|
||||
class FileOwnerProbeError extends Error {
|
||||
errorCode?: string;
|
||||
constructor(public stage: string, error: unknown) {
|
||||
super('owner_query_failed');
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (['EPERM', 'EACCES', 'EBUSY', 'ENOENT', 'EINVAL', 'ENOTDIR', 'ENAMETOOLONG', 'ETIMEDOUT'].includes(code || '')) this.errorCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
function inspect() {
|
||||
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) return { available: false, reason: 'not_windows' };
|
||||
stage = 'input_decode';
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
if (typeof input.root !== 'string' || typeof input.file !== 'string' || !Number.isSafeInteger(input.testPid)) return { available: false, reason: 'invalid_input' };
|
||||
stage = 'resolve_root';
|
||||
const root = realpathSync(input.root);
|
||||
stage = 'resolve_file';
|
||||
const file = realpathSync(input.file);
|
||||
const relative = path.relative(root, file);
|
||||
stage = 'file_stat';
|
||||
const initial = lstatSync(input.file, { bigint: true });
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative) || !initial.isFile() || initial.isSymbolicLink()
|
||||
|| relative !== path.relative(root, path.resolve(input.file))) return { available: false, reason: 'outside_owned_fixture' };
|
||||
if (input.expectedIdentity !== undefined && (input.expectedIdentity?.dev !== initial.dev.toString()
|
||||
|| input.expectedIdentity?.ino !== initial.ino.toString())) return { available: false, reason: 'failed_object_identity_changed' };
|
||||
const unchanged = () => {
|
||||
stage = 'file_recheck';
|
||||
const current = lstatSync(input.file, { bigint: true });
|
||||
return current.isFile() && !current.isSymbolicLink() && current.dev === initial.dev && current.ino === initial.ino
|
||||
&& realpathSync(input.file) === file;
|
||||
};
|
||||
stage = 'load_restart_manager';
|
||||
const restart = dlopen(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'rstrtmgr.dll'), {
|
||||
RmStartSession: { args: [FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmRegisterResources: { args: [FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmGetList: { args: [FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmEndSession: { args: [FFIType.u32], returns: FFIType.u32 },
|
||||
});
|
||||
stage = 'load_kernel';
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
|
||||
GetProcessTimes: { args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
QueryFullProcessImageNameW: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
let session: number | undefined;
|
||||
try {
|
||||
const sessionBuffer = Buffer.alloc(4);
|
||||
const key = Buffer.alloc(66);
|
||||
stage = 'session_start';
|
||||
let status = restart.symbols.RmStartSession(ptr(sessionBuffer), 0, ptr(key));
|
||||
if (status !== 0) return { available: false, reason: 'session_start', status };
|
||||
session = sessionBuffer.readUInt32LE(0);
|
||||
const wideFile = Buffer.from(file + '\0', 'utf16le');
|
||||
const names = Buffer.alloc(8);
|
||||
names.writeBigUInt64LE(BigInt(ptr(wideFile)));
|
||||
stage = 'register_file';
|
||||
status = restart.symbols.RmRegisterResources(session, 1, ptr(names), 0, null, 0, null);
|
||||
if (status !== 0) return { available: false, reason: 'register_file', status };
|
||||
const needed = Buffer.alloc(4);
|
||||
const count = Buffer.alloc(4);
|
||||
const rebootReasons = Buffer.alloc(4);
|
||||
stage = 'owner_count';
|
||||
status = restart.symbols.RmGetList(session, ptr(needed), ptr(count), null, ptr(rebootReasons));
|
||||
if (status === 0 && needed.readUInt32LE(0) === 0) return unchanged()
|
||||
? { available: true, owners: [], rebootReasons: rebootReasons.readUInt32LE(0) }
|
||||
: { available: false, reason: 'failed_object_identity_changed' };
|
||||
const entries = needed.readUInt32LE(0);
|
||||
if (status !== 234 || entries < 1 || entries > 64) return { available: false, reason: 'owner_count', status, entries };
|
||||
const information = Buffer.alloc(entries * 668);
|
||||
count.writeUInt32LE(entries);
|
||||
stage = 'owner_list';
|
||||
status = restart.symbols.RmGetList(session, ptr(needed), ptr(count), ptr(information), ptr(rebootReasons));
|
||||
const returned = count.readUInt32LE(0);
|
||||
if (status !== 0 || returned > entries) return { available: false, reason: 'owner_list', status };
|
||||
const owners = [];
|
||||
stage = 'owner_identity';
|
||||
for (let index = 0; index < returned; index++) {
|
||||
const offset = index * 668;
|
||||
const pid = information.readUInt32LE(offset);
|
||||
const recordedStart = information.readBigUInt64LE(offset + 4);
|
||||
let image = 'unavailable';
|
||||
let creationMatched = false;
|
||||
const handle = kernel.symbols.OpenProcess(0x1000, 0, pid);
|
||||
if (handle) {
|
||||
try {
|
||||
const times = Buffer.alloc(32);
|
||||
const timeAddress = ptr(times);
|
||||
if (kernel.symbols.GetProcessTimes(handle, timeAddress, timeAddress + 8, timeAddress + 16, timeAddress + 24)) {
|
||||
creationMatched = times.readBigUInt64LE(0) === recordedStart;
|
||||
}
|
||||
if (creationMatched) {
|
||||
const imageBuffer = Buffer.alloc(65536);
|
||||
const imageLength = Buffer.alloc(4);
|
||||
imageLength.writeUInt32LE(32768);
|
||||
if (kernel.symbols.QueryFullProcessImageNameW(handle, 0, ptr(imageBuffer), ptr(imageLength))) {
|
||||
const chars = imageLength.readUInt32LE(0);
|
||||
const name = chars <= 32768 ? path.basename(imageBuffer.subarray(0, chars * 2).toString('utf16le')).toLowerCase() : '';
|
||||
image = ['bun.exe', 'node.exe', 'msedge.exe', 'msmpeng.exe', 'mssense.exe', 'dllhost.exe', 'explorer.exe', 'powershell.exe', 'pwsh.exe', 'svchost.exe', 'conhost.exe'].includes(name) ? name : 'other';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
kernel.symbols.CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
owners.push({ pid, image, creationMatched, isTestHost: creationMatched && pid === input.testPid, applicationType: information.readUInt32LE(offset + 652) });
|
||||
}
|
||||
return unchanged() ? { available: true, owners, rebootReasons: rebootReasons.readUInt32LE(0) }
|
||||
: { available: false, reason: 'failed_object_identity_changed' };
|
||||
} catch (error) {
|
||||
throw new FileOwnerProbeError(stage, error);
|
||||
} finally {
|
||||
stage = 'session_end';
|
||||
if (session !== undefined) restart.symbols.RmEndSession(session);
|
||||
stage = 'library_close';
|
||||
kernel.close(); restart.close();
|
||||
}
|
||||
}
|
||||
|
||||
let result: object;
|
||||
try { result = inspect(); }
|
||||
catch (error) {
|
||||
const failure = error instanceof FileOwnerProbeError ? error : new FileOwnerProbeError(stage, error);
|
||||
result = { available: false, reason: 'owner_query_failed', stage: failure.stage, errorCode: failure.errorCode };
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(0));
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
const fs = require('node:fs');
|
||||
const cp = require('node:child_process');
|
||||
const { createHash } = require('node:crypto');
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = ({ observation, playwrightEntry, mode = 'normal-close', inspectCommandLine = false, observerExecutable, seedCookie = { name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/' } }) => {
|
||||
if (inspectCommandLine && process.platform === 'win32' && typeof observerExecutable !== 'string') throw new Error('Native observer executable is required');
|
||||
const originalSpawn = cp.spawn;
|
||||
let inspected = Promise.resolve();
|
||||
let folderEvidence;
|
||||
const directoryState = (env, root) => ({
|
||||
requestedProfile: fs.existsSync(root),
|
||||
localEnvironment: fs.existsSync(env.LOCALAPPDATA || ''),
|
||||
roamingEnvironment: fs.existsSync(env.APPDATA || ''),
|
||||
localUnderProfile: fs.existsSync(path.join(env.USERPROFILE || '', 'AppData', 'Local')),
|
||||
roamingUnderProfile: fs.existsSync(path.join(env.USERPROFILE || '', 'AppData', 'Roaming')),
|
||||
});
|
||||
const safeFolders = value => Object.fromEntries(['local', 'roaming'].map(name => [name,
|
||||
Object.fromEntries(['verified', 'dontVerify'].map(kind => {
|
||||
const item = value?.[name]?.[kind];
|
||||
return [kind, {
|
||||
hresult: Number.isInteger(item?.hresult) ? item.hresult : null,
|
||||
pathHash: /^[a-f0-9]{64}$/.test(item?.pathHash) ? item.pathHash : null,
|
||||
exists: typeof item?.exists === 'boolean' ? item.exists : null,
|
||||
matchesEnvironment: typeof item?.matchesEnvironment === 'boolean' ? item.matchesEnvironment : null,
|
||||
underUserProfile: typeof item?.underUserProfile === 'boolean' ? item.underUserProfile : null,
|
||||
}];
|
||||
})),
|
||||
]));
|
||||
const runProbe = (input, env) => new Promise(resolve => {
|
||||
const probe = originalSpawn(observerExecutable, [
|
||||
'--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.join(__dirname, 'native-cookie-process-observer.ts'),
|
||||
Buffer.from(JSON.stringify(input)).toString('base64'),
|
||||
], { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
||||
let output = '';
|
||||
let stderrBytes = 0;
|
||||
let spawnFailed = false;
|
||||
const timer = setTimeout(() => probe.kill(), 5_000);
|
||||
probe.stdout.on('data', chunk => { output += chunk.toString('utf8'); if (output.length > 16384) probe.kill(); });
|
||||
probe.stderr.on('data', chunk => { stderrBytes += chunk.length; });
|
||||
probe.once('error', () => { spawnFailed = true; });
|
||||
probe.once('close', code => {
|
||||
clearTimeout(timer);
|
||||
try { resolve({ measured: JSON.parse(output), code, stderrBytes }); }
|
||||
catch { resolve({ measured: { available: false, reason: spawnFailed ? 'probe_spawn_failed' : 'probe_no_receipt' }, code, stderrBytes }); }
|
||||
});
|
||||
});
|
||||
cp.spawn = function(command, args, options) {
|
||||
if (args.some(arg => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))) throw new Error('Native fixture refuses a sandbox-disabled browser');
|
||||
const child = originalSpawn.call(this, command, args, options);
|
||||
const evidence = {
|
||||
command, args, pid: child.pid,
|
||||
argsHash: createHash('sha256').update(JSON.stringify(args)).digest('hex'),
|
||||
envHash: createHash('sha256').update(JSON.stringify(Object.entries(options.env || {}).sort(([a], [b]) => a.localeCompare(b)))).digest('hex'),
|
||||
stderrBytes: 0,
|
||||
reasons: [],
|
||||
runtime: { node: process.version, bun: process.versions.bun || null, architecture: process.arch },
|
||||
folderEvidence,
|
||||
};
|
||||
const publish = () => fs.writeFileSync(observation, JSON.stringify(evidence));
|
||||
publish();
|
||||
let tail = '';
|
||||
child.stderr?.on('data', chunk => {
|
||||
evidence.stderrBytes += chunk.length;
|
||||
if (evidence.stderrBytes > 65536) return;
|
||||
const text = tail + chunk.toString('utf8');
|
||||
const patterns = {
|
||||
job_assignment_failed: /AssignProcessToJobObject|failed to (?:assign|create).*job object/i,
|
||||
sandbox_failed: /sandbox.*(?:failed|error)|SBOX_FATAL/i,
|
||||
profile_locked: /ProcessSingleton|profile.*in use/i,
|
||||
default_profile_policy: /remote debugging requires a non-default data directory/i,
|
||||
permission_denied: /access is denied|ERROR_ACCESS_DENIED|permission denied/i,
|
||||
crashpad_failed: /crashpad.*(?:failed|error)/i,
|
||||
missing_dependency: /specified module could not be found|0xc0000135/i,
|
||||
};
|
||||
for (const [reason, pattern] of Object.entries(patterns)) {
|
||||
if (pattern.test(text) && !evidence.reasons.includes(reason)) evidence.reasons.push(reason);
|
||||
}
|
||||
tail = text.slice(-512);
|
||||
publish();
|
||||
});
|
||||
child.once('exit', (code, signal) => { evidence.exitCode = code; evidence.signal = signal; publish(); });
|
||||
child.once('error', error => {
|
||||
evidence.spawnError = ['ENOENT', 'EACCES', 'EPERM', 'EINVAL'].includes(error.code) ? error.code : 'spawn_failed';
|
||||
publish();
|
||||
});
|
||||
if (inspectCommandLine && process.platform === 'win32' && Number.isInteger(child.pid)) {
|
||||
inspected = runProbe({ pid: child.pid, owner: process.pid, image: command }, options.env).then(({ measured, code, stderrBytes }) => {
|
||||
const expectedHashes = args.map(arg => createHash('sha256').update(arg).digest('hex'));
|
||||
const dataDir = args.find(arg => arg.startsWith('--user-data-dir='))?.slice(16);
|
||||
evidence.observedCommandLine = {
|
||||
available: measured.available === true,
|
||||
parentMatched: measured.parentMatched === true,
|
||||
imageMatched: measured.imageMatched === true,
|
||||
reason: ['not_windows', 'invalid_input', 'process_open', 'process_identity', 'owned_process_unavailable', 'command_line', 'command_line_length', 'command_line_bounds', 'argument_parse', 'argument_bounds', 'job_query', 'observer_initialize', 'probe_spawn_failed', 'probe_no_receipt'].includes(measured.reason) ? measured.reason : undefined,
|
||||
win32Error: Number.isInteger(measured.win32Error) ? measured.win32Error : undefined,
|
||||
ntStatus: Number.isInteger(measured.ntStatus) ? measured.ntStatus : undefined,
|
||||
commandLineHash: /^[a-f0-9]{64}$/.test(measured.commandLineHash) ? measured.commandLineHash : undefined,
|
||||
argumentsMatchRequested: measured.available === true && JSON.stringify(measured.argumentHashes) === JSON.stringify(expectedHashes),
|
||||
userDataDirCount: Number.isInteger(measured.userDataDirCount) && measured.userDataDirCount >= 0 && measured.userDataDirCount <= 128 ? measured.userDataDirCount : undefined,
|
||||
userDataDirMatchesRequested: typeof dataDir === 'string' && measured.userDataDirHash === createHash('sha256').update(dataDir).digest('hex'),
|
||||
pipePresent: measured.pipePresent === true,
|
||||
browserInJob: typeof measured.browserInJob === 'boolean' ? measured.browserInJob : undefined,
|
||||
observerJobLimitFlags: Number.isInteger(measured.observerJobLimitFlags) ? measured.observerJobLimitFlags : undefined,
|
||||
observerJobQueryError: Number.isInteger(measured.observerJobQueryError) ? measured.observerJobQueryError : undefined,
|
||||
exitCode: code, stderrBytes,
|
||||
};
|
||||
evidence.folderEvidence.afterLaunch = safeFolders(measured.knownFolders);
|
||||
evidence.folderEvidence.directoriesAfterLaunch = directoryState(options.env, args.find(arg => arg.startsWith('--user-data-dir='))?.slice(16) || '');
|
||||
publish();
|
||||
});
|
||||
}
|
||||
return child;
|
||||
};
|
||||
const { chromium } = require(playwrightEntry);
|
||||
return { chromium: { async launchPersistentContext(root, options) {
|
||||
if (options.chromiumSandbox !== true) throw new Error('Native fixture requires the browser sandbox');
|
||||
const started = Date.now();
|
||||
if (inspectCommandLine && process.platform === 'win32') {
|
||||
const directoriesBefore = directoryState(options.env, root);
|
||||
const before = await runProbe({ mode: 'known-folders' }, options.env);
|
||||
folderEvidence = { beforeLaunch: safeFolders(before.measured.knownFolders), directoriesBefore, directoriesAfterProbe: directoryState(options.env, root) };
|
||||
}
|
||||
const context = await chromium.launchPersistentContext(root, inspectCommandLine && process.platform === 'win32'
|
||||
? { ...options, timeout: Math.max(1, options.timeout - (Date.now() - started)) } : options);
|
||||
await inspected;
|
||||
await context.addCookies([seedCookie]);
|
||||
if (mode === 'stalled-close') context.close = () => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); };
|
||||
return context;
|
||||
} } };
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { dlopen, FFIType, ptr, toArrayBuffer } from 'bun:ffi';
|
||||
|
||||
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
|
||||
|
||||
export function decodeNativeCommandLine(buffer: Buffer, address: number | bigint): string | null {
|
||||
if (buffer.length < 16) return null;
|
||||
const length = buffer.readUInt16LE(0);
|
||||
const offset = Number(buffer.readBigUInt64LE(8) - BigInt(address));
|
||||
if (!Number.isSafeInteger(offset) || offset < 16 || offset + length > buffer.length || length % 2 !== 0) return null;
|
||||
return buffer.subarray(offset, offset + length).toString('utf16le');
|
||||
}
|
||||
|
||||
function knownFolders() {
|
||||
const shell = dlopen('shell32.dll', {
|
||||
SHGetFolderPathW: { args: [FFIType.u64, FFIType.i32, FFIType.u64, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
});
|
||||
const normalized = (value: string) => path.win32.normalize(value).toLowerCase();
|
||||
try {
|
||||
return Object.fromEntries([['local', 0x1c, 'LOCALAPPDATA'], ['roaming', 0x1a, 'APPDATA']].map(([name, id, env]) => {
|
||||
const calls = Object.fromEntries([['verified', 0], ['dontVerify', 0x4000]].map(([kind, flag]) => {
|
||||
const output = Buffer.alloc(520);
|
||||
const status = shell.symbols.SHGetFolderPathW(0, Number(id) | Number(flag), 0, 0, ptr(output));
|
||||
let end = 0;
|
||||
while (end + 2 <= output.length && output.readUInt16LE(end) !== 0) end += 2;
|
||||
const folder = status >= 0 && end > 0 && end + 2 <= output.length ? output.subarray(0, end).toString('utf16le') : null;
|
||||
return [kind, {
|
||||
hresult: status,
|
||||
pathHash: folder ? hash(normalized(folder)) : null,
|
||||
exists: folder ? existsSync(folder) : null,
|
||||
matchesEnvironment: folder ? normalized(folder) === normalized(process.env[String(env)] || '') : null,
|
||||
underUserProfile: folder ? normalized(folder).startsWith(normalized(process.env.USERPROFILE || '') + '\\') : null,
|
||||
}];
|
||||
}));
|
||||
return [name, calls];
|
||||
}));
|
||||
} finally {
|
||||
shell.close();
|
||||
}
|
||||
}
|
||||
|
||||
function observe() {
|
||||
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) return { available: false, reason: 'not_windows' };
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
if (input.mode === 'known-folders') return { available: true, knownFolders: knownFolders() };
|
||||
if (!Number.isSafeInteger(input.pid) || input.pid <= 0 || input.pid > 0xffffffff || !Number.isSafeInteger(input.owner) || input.owner <= 0 || input.owner > 0xffffffff || typeof input.image !== 'string' || input.image.length > 32768) {
|
||||
return { available: false, reason: 'invalid_input' };
|
||||
}
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
QueryFullProcessImageNameW: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
QueryInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
IsProcessInJob: { args: [FFIType.u64, FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
|
||||
LocalFree: { args: [FFIType.ptr], returns: FFIType.ptr },
|
||||
LocalSize: { args: [FFIType.ptr], returns: FFIType.u64 },
|
||||
GetLastError: { args: [], returns: FFIType.u32 },
|
||||
});
|
||||
const nt = dlopen('ntdll.dll', {
|
||||
NtQueryInformationProcess: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
});
|
||||
const shell = dlopen('shell32.dll', {
|
||||
CommandLineToArgvW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.ptr },
|
||||
});
|
||||
let processHandle: number | bigint = 0;
|
||||
let argumentMemory: ReturnType<typeof shell.symbols.CommandLineToArgvW> = null;
|
||||
let stage = 'process_open';
|
||||
try {
|
||||
processHandle = kernel.symbols.OpenProcess(0x1000, 0, input.pid);
|
||||
if (!processHandle) return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
|
||||
stage = 'process_identity';
|
||||
const basic = Buffer.alloc(48);
|
||||
const returned = Buffer.alloc(4);
|
||||
let status = nt.symbols.NtQueryInformationProcess(processHandle, 0, ptr(basic), basic.length, ptr(returned));
|
||||
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
|
||||
const parentMatched = basic.readBigUInt64LE(40) === BigInt(input.owner);
|
||||
const pidMatched = basic.readBigUInt64LE(32) === BigInt(input.pid);
|
||||
const imageBuffer = Buffer.alloc(65536);
|
||||
const imageLength = Buffer.alloc(4);
|
||||
imageLength.writeUInt32LE(32768);
|
||||
if (!kernel.symbols.QueryFullProcessImageNameW(processHandle, 0, ptr(imageBuffer), ptr(imageLength))) {
|
||||
return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
|
||||
}
|
||||
const imageChars = imageLength.readUInt32LE(0);
|
||||
const imageMatched = imageChars <= 32768 && imageBuffer.subarray(0, imageChars * 2).toString('utf16le').toLowerCase() === input.image.toLowerCase();
|
||||
if (!parentMatched || !pidMatched || !imageMatched) return { available: false, reason: 'owned_process_unavailable', parentMatched, imageMatched };
|
||||
stage = 'command_line';
|
||||
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, null, 0, ptr(returned));
|
||||
const length = returned.readUInt32LE(0);
|
||||
if (length < 16 || length > 131072) return { available: false, reason: 'command_line_length', ntStatus: status };
|
||||
const commandBuffer = Buffer.alloc(length);
|
||||
const commandAddress = ptr(commandBuffer);
|
||||
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, commandAddress, length, ptr(returned));
|
||||
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
|
||||
const commandLine = decodeNativeCommandLine(commandBuffer, commandAddress);
|
||||
if (commandLine === null) return { available: false, reason: 'command_line_bounds' };
|
||||
stage = 'argument_parse';
|
||||
const wideCommand = Buffer.from(commandLine + '\0', 'utf16le');
|
||||
const count = Buffer.alloc(4);
|
||||
argumentMemory = shell.symbols.CommandLineToArgvW(ptr(wideCommand), ptr(count));
|
||||
const argumentCount = count.readInt32LE(0);
|
||||
if (!argumentMemory || argumentCount < 1 || argumentCount > 4096) return { available: false, reason: stage };
|
||||
const size = Number(kernel.symbols.LocalSize(argumentMemory));
|
||||
if (!Number.isSafeInteger(size) || size < argumentCount * 8 || size > 1048576) return { available: false, reason: 'argument_bounds' };
|
||||
const argumentsBuffer = Buffer.from(toArrayBuffer(argumentMemory, 0, size));
|
||||
const args: string[] = [];
|
||||
for (let index = 1; index < argumentCount; index++) {
|
||||
const start = Number(argumentsBuffer.readBigUInt64LE(index * 8) - BigInt(argumentMemory));
|
||||
if (!Number.isSafeInteger(start) || start < argumentCount * 8 || start % 2 !== 0 || start >= size) return { available: false, reason: 'argument_bounds' };
|
||||
let end = start;
|
||||
while (end + 2 <= size && argumentsBuffer.readUInt16LE(end) !== 0) end += 2;
|
||||
if (end + 2 > size) return { available: false, reason: 'argument_bounds' };
|
||||
args.push(argumentsBuffer.subarray(start, end).toString('utf16le'));
|
||||
}
|
||||
stage = 'job_query';
|
||||
const limits = Buffer.alloc(144);
|
||||
const jobKnown = kernel.symbols.QueryInformationJobObject(0, 9, ptr(limits), limits.length, ptr(returned));
|
||||
const jobError = jobKnown ? undefined : kernel.symbols.GetLastError();
|
||||
const inJob = Buffer.alloc(4);
|
||||
const membershipKnown = kernel.symbols.IsProcessInJob(processHandle, 0, ptr(inJob));
|
||||
const dataArgs = args.filter(arg => arg.startsWith('--user-data-dir='));
|
||||
return {
|
||||
available: true, parentMatched, imageMatched,
|
||||
commandLineHash: hash(commandLine), argumentHashes: args.map(hash),
|
||||
userDataDirCount: dataArgs.length,
|
||||
userDataDirHash: dataArgs.length === 1 ? hash(dataArgs[0].slice(16)) : null,
|
||||
pipePresent: args.includes('--remote-debugging-pipe'),
|
||||
browserInJob: membershipKnown ? inJob.readInt32LE(0) !== 0 : null,
|
||||
observerJobLimitFlags: jobKnown ? limits.readUInt32LE(16) : null,
|
||||
observerJobQueryError: jobError,
|
||||
knownFolders: knownFolders(),
|
||||
};
|
||||
} catch {
|
||||
return { available: false, reason: stage };
|
||||
} finally {
|
||||
if (argumentMemory) kernel.symbols.LocalFree(argumentMemory);
|
||||
if (processHandle) kernel.symbols.CloseHandle(processHandle);
|
||||
shell.close(); nt.close(); kernel.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
let result: object;
|
||||
let exitCode = 0;
|
||||
try { result = observe(); }
|
||||
catch { result = { available: false, reason: 'observer_initialize' }; exitCode = 1; }
|
||||
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(exitCode));
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
module.exports = ({ pidsFile, mode }) => ({
|
||||
chromium: {
|
||||
async launchPersistentContext() {
|
||||
const child = require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
require('node:fs').writeFileSync(pidsFile, JSON.stringify([process.pid, child.pid]));
|
||||
if (mode === 'worker-crash') setTimeout(() => process.exit(3), 100);
|
||||
await new Promise(() => {});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
let stage = 'input';
|
||||
let root;
|
||||
const samePath = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
try {
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
root = input.root;
|
||||
if (typeof root !== 'string' || typeof input.realpath !== 'string' || typeof input.dev !== 'string' || typeof input.ino !== 'string') throw new Error('invalid_input');
|
||||
stage = 'identity';
|
||||
const state = fs.lstatSync(root, { bigint: true });
|
||||
const identity = {
|
||||
directory: state.isDirectory() && !state.isSymbolicLink(),
|
||||
pathMatches: samePath(fs.realpathSync(root), input.realpath),
|
||||
deviceMatches: state.dev.toString() === input.dev,
|
||||
inodeMatches: state.ino.toString() === input.ino,
|
||||
};
|
||||
if (Object.values(identity).some(value => !value)) {
|
||||
console.log(JSON.stringify({ removed: false, reason: 'identity_mismatch', identity }));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
stage = 'remove';
|
||||
fs.rmSync(root, { recursive: true, force: true, maxRetries: 0 });
|
||||
console.log(JSON.stringify({ removed: !fs.existsSync(root), identity, retries: 0 }));
|
||||
}
|
||||
} catch (error) {
|
||||
const code = ['EPERM', 'EACCES', 'EBUSY', 'ENOENT', 'EINVAL', 'ENOTEMPTY', 'ENOTDIR'].includes(error.code) ? error.code : 'filesystem_error';
|
||||
let failingPath = null;
|
||||
let metadata = null;
|
||||
if (typeof root === 'string' && typeof error.path === 'string') {
|
||||
const relative = path.relative(root, error.path);
|
||||
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
||||
failingPath = relative || '.';
|
||||
try {
|
||||
const state = fs.lstatSync(error.path);
|
||||
metadata = { mode: state.mode, directory: state.isDirectory(), link: state.isSymbolicLink() };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ removed: false, stage, code, failingPath, metadata }));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* that could silently remove a fix without breaking compilation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -364,11 +364,38 @@ describe('cookie-import domain validation', () => {
|
||||
expect(block).toContain('does not match current page domain');
|
||||
});
|
||||
|
||||
it('cookie-import-browser handler validates --domain against page hostname', () => {
|
||||
const block = sliceBetween(WRITE_SRC, "case 'cookie-import-browser':", "case 'style':");
|
||||
expect(block).toContain('normalizedDomain');
|
||||
expect(block).toContain('pageHostname');
|
||||
expect(block).toContain('does not match current page domain');
|
||||
it('cookie-import-browser handler validates --domain against page hostname', async () => {
|
||||
const operation = await import('../src/cookie-import-operation');
|
||||
const { handleWriteCommand } = await import('../src/write-commands');
|
||||
const imported = spyOn(operation, 'runCookieImport').mockResolvedValue({
|
||||
browser: 'chromium', profile: 'Profile 2', imported: 2, failed: 0,
|
||||
domainCounts: { '.example.test': 2 }, failureReasons: {}, outcome: 'imported',
|
||||
reset: 'not_requested', verification: { verified: false, reason: 'not_requested' }, message: 'Cookie copy complete.',
|
||||
});
|
||||
let currentUrl = 'https://example.test';
|
||||
const page = { url: () => currentUrl, isClosed: () => false };
|
||||
const session = { getPage: () => page, getActiveFrameOrPage: () => page, getFrame: () => null } as any;
|
||||
const manager = { trackCookieImportDomains() {} } as any;
|
||||
try {
|
||||
for (const [target, domain] of [
|
||||
['https://example.test', 'unrelated.test'],
|
||||
['https://example.test.evil.invalid', 'example.test'],
|
||||
['https://badexample.test', 'example.test'],
|
||||
]) {
|
||||
currentUrl = target;
|
||||
await expect(handleWriteCommand('cookie-import-browser', ['chromium', '--domain', domain], session, manager))
|
||||
.rejects.toMatchObject({ code: 'target_mismatch' });
|
||||
}
|
||||
expect(imported).not.toHaveBeenCalled();
|
||||
currentUrl = 'https://sub.example.test/protected';
|
||||
const result = await handleWriteCommand('cookie-import-browser', ['chromium', '--domain', '.Example.Test.', '--profile', 'Profile 2'], session, manager);
|
||||
expect(imported).toHaveBeenCalledTimes(1);
|
||||
expect(imported.mock.calls[0][0]).toMatchObject({ browser: 'chromium', domains: ['example.test'], profile: 'Profile 2' });
|
||||
expect(imported.mock.calls[0][1]).toEqual({ page, url: currentUrl });
|
||||
expect(result).toContain('Imported 2 cookies from chromium (profile: Profile 2)');
|
||||
} finally {
|
||||
imported.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,10 +42,13 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => {
|
||||
// isProcessAlive no longer spawns anything (signal-0 on every platform,
|
||||
// #1952) — process-liveness-windows.test.ts pins that it stays
|
||||
// subprocess-free, which is stronger than hiding a window.
|
||||
// powershell DPAPI + tasklist in cookie import.
|
||||
const cookie = SRC('cookie-import-browser.ts');
|
||||
expectHideNearEvery(cookie, "'powershell'");
|
||||
expectHideNearEvery(cookie, "'tasklist'");
|
||||
expect(cookie).not.toContain("'tasklist'");
|
||||
expectHideNearEvery(SRC('cookie-import-native.ts'), 'spawn(bunExecutable');
|
||||
const worker = SRC('cookie-import-native-worker.ts');
|
||||
expectHideNearEvery(worker, 'spawn(process.execPath');
|
||||
expectHideNearEvery(worker, 'spawn(input.request.nodeExecutable');
|
||||
});
|
||||
|
||||
test('icacls calls in file-permissions.ts pass windowsHide', () => {
|
||||
|
||||
Reference in New Issue
Block a user