mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
v1.90.0.0 feat: make browser cookie imports explicit and safe (#2964)
* fix(browse): prepare reliable cookie import wave for validation * ci: sequence quality and behavior for validation branch * fix(browse): isolate Windows qualification and preserve native diagnostics * test(browse): cover cookie workflow quality and isolate Windows user paths * test(browse): trace native member startup and initialize fresh folders * fix(browse): keep Windows member stdin alive through EOF * fix(browse): latch native timeouts and compare contained Edge startup * test(browse): verify native version metadata and actual Windows argv * test(browse): qualify Dia import on isolated macOS CI * fix(browse): require picker origin for session mutations * fix(browse): bound credential reads through stream completion * test(browse): inspect owned Windows process arguments natively * test(evals): preserve passing coverage during cookie repair reruns * test(browse): isolate Dia qualification in a fresh macOS account * test(browse): pass bounded integer timeouts to native Mac probes * test(browse): distinguish Windows profile initialization from containment * test(browse): await descendant pipe readiness before parent exit * test(browse): initialize and restore isolated macOS Keychain state * test(browse): initialize Windows fixture folders before qualification * test(ci): pin the same Node runtime across Windows checks * test(browse): distinguish native macOS browser preflight stages * test(browse): isolate Windows descendant console lifetime * test(browse): preserve native receipts and identify fixture lock holders * test(browse): prepare dependency resolution before native Mac worker startup * test(ci): include lock and close checks in native diagnostics * test(browse): preserve native owner probe stages and subprocess deadlines * fix(browse): classify Chromium profile-in-use exit precisely * test(browse): retain Mac qualification evidence through cleanup failures * test(browse): bound Mac fixture paths and retire its owned user domain * test(browse): accept vanished fixture entries without weakening cleanup * test(browse): identify probe-created macOS user domains safely * test(browse): observe Mac user domains without targeting them first * test(browse): use passive fresh-user ownership throughout Mac qualification * test(browse): distinguish profile and registered-home Keychain lookups * test(browse): qualify Dia under one registered account home * test(browse): identify Dia startup and owned process-group failures * test(browse): classify bounded Dia startup diagnostics without leaking output * fix(test): preserve native Mac sandboxing and reap owned browser children * fix(browse): preserve Chromium sandboxing for native profile imports * test(browse): inspect signed Mach-O architecture without launching Xcode tools * test(browse): sample pending Dia startup and reap on all cleanup paths * test(browse): compare protected Dia launches in fresh Bun and Node accounts * test(browse): inspect isolated Mac GUI readiness without browser access * v1.90.0.0 fix: bind cookie picker actions to their document * test: validate cookie guards and fit nested launch fixtures * ci: configure the bundled Chromium sandbox helper * fix(browse): classify Playwright authentication timeouts * test: retain bounded Windows lifecycle diagnostics * test(cso): reuse bounded NTFS precision candidates * test(review): handle explicit preservation choices safely * test(browse): remove owned fixture directories with explicit primitives * test(review): distinguish descriptive reuse from edit commitments * test: admit only the approved unscored cookie workflow refusal * test: keep the Office Hours judge mock export-complete * fix: keep dependency-free CI planners independent of the model SDK * test: observe the exact holder after a native fixture unlink failure * fix: start seeded PTY observations at owned readiness * test: acquire identity-bound Windows deletion admission before profile resets * test: preserve qualified Git index bits without authorizing mutations
This commit is contained in:
1 parent
730a1017d1
commit
a84b0b5b6d
111 files changed
+14996
-1057
No files matched your search
+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': {
|
||||
|
||||
Reference in new issue
Block a user