mirror of https://github.com/garrytan/gstack.git
Merge 13ff147233 into 2beb636f7c
This commit is contained in:
commit
4092458f68
|
|
@ -0,0 +1,391 @@
|
|||
/**
|
||||
* Automatic cookie persistence for the headless browse daemon (opt-in).
|
||||
*
|
||||
* Why this exists
|
||||
* ----------------
|
||||
* `BrowserManager.launch()` uses `chromium.launch()` + a NON-persistent
|
||||
* `browser.newContext()`, which Playwright defines as never writing browsing
|
||||
* data to disk. So when the daemon restarts (idle timeout, `/stop`, crash),
|
||||
* every in-memory cookie is gone. For a site whose device identity is bound to
|
||||
* a long-lived cookie (e.g. a `__Host-`-prefixed device-trust cookie), that
|
||||
* restart forces a fresh device registration on the next visit — the login
|
||||
* looks transparent, but the device cookie silently vanished.
|
||||
*
|
||||
* `recreateContext()` (viewport / user-agent change) already saves and restores
|
||||
* state in-memory, so it preserves cookies; only a full daemon restart loses
|
||||
* them. This layer closes that gap by persisting cookies to a small state file
|
||||
* and reloading them at launch.
|
||||
*
|
||||
* Design (validated against Playwright 1.58.2)
|
||||
* --------------------------------------------
|
||||
* - OPT-IN. Off unless BROWSE_AUTO_COOKIE_PERSIST is truthy. Auto-persisting
|
||||
* auth cookies to plaintext-on-disk for every user is a security escalation
|
||||
* from the current ephemeral-profile semantics, so it must be a conscious
|
||||
* choice. When enabled the daemon logs a one-time plaintext warning.
|
||||
* - PERSISTENT COOKIES ONLY. Session cookies (expires === -1) are excluded by
|
||||
* definition — turning a browser-session login into a durable one is wrong.
|
||||
* Expired cookies are dropped so a logout/expiry is never resurrected.
|
||||
* - Cookies are captured/restored as a Playwright `storageState`-shaped object
|
||||
* ({ cookies, origins: [] }); we intentionally never persist localStorage,
|
||||
* sessionStorage, IndexedDB, or open pages — only cookies.
|
||||
* - Fields are preserved verbatim (domain/path/httpOnly/secure/sameSite/
|
||||
* partitionKey); `__Host-` host-only cookies restore correctly via
|
||||
* `newContext({ storageState })` / `addCookies` with their bare domain.
|
||||
* - PER-WORKSPACE FILE + EXCLUSIVE LOCK. The state file is keyed by a hash of
|
||||
* the resolved project dir, and a sibling lock dir (mkdir-atomic) prevents
|
||||
* two concurrent daemons (separate git worktrees) from racing the same slot.
|
||||
* - ATOMIC WRITES. Every checkpoint is a temp-file + rename, so a crash or a
|
||||
* concurrent reader never sees a torn file (see writeSecureFileAtomic).
|
||||
* - MANUAL STATES ARE UNTOUCHED. This is a separate internal file, not a
|
||||
* reserved `state save|load <name>`; users can't clobber it and it can't
|
||||
* clobber theirs.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import type { BrowserContext, Cookie } from 'playwright';
|
||||
import type { BrowseConfig } from './config';
|
||||
import { mkdirSecure, writeSecureFileAtomic } from './file-permissions';
|
||||
import { safeUnlinkQuiet, isProcessAlive } from './error-handling';
|
||||
|
||||
const STATE_KIND = 'gstack-browse-auto-cookie-state';
|
||||
const STATE_VERSION = 1;
|
||||
|
||||
/** Persisted wrapper written to <stateDir>/browse-auto-cookies/<profileId>.json */
|
||||
interface AutoCookieState {
|
||||
version: number;
|
||||
kind: string;
|
||||
workspaceId: string;
|
||||
savedAt: string;
|
||||
storageState: { cookies: Cookie[]; origins: [] };
|
||||
}
|
||||
|
||||
/** Lock ownership metadata (pid + start time), written into the lock dir. */
|
||||
interface LockMeta {
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
let warnedOnce = false;
|
||||
|
||||
/**
|
||||
* Truthy check for the opt-in flag. Accepts 1/true/yes/on (case-insensitive);
|
||||
* everything else (unset, 0, false, empty) is off.
|
||||
*/
|
||||
export function isAutoCookiePersistEnabled(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): boolean {
|
||||
const v = (env.BROWSE_AUTO_COOKIE_PERSIST || '').trim().toLowerCase();
|
||||
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional host allowlist from BROWSE_AUTO_COOKIE_DOMAINS (comma-separated).
|
||||
* Supports exact hosts (example.com) and leading-wildcard (*.example.com,
|
||||
* which also matches the apex example.com). Empty/unset ⇒ null ⇒ all hosts.
|
||||
*/
|
||||
export function parseDomainAllowlist(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string[] | null {
|
||||
const raw = (env.BROWSE_AUTO_COOKIE_DOMAINS || '').trim();
|
||||
if (!raw) return null;
|
||||
const list = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
||||
return list.length > 0 ? list : null;
|
||||
}
|
||||
|
||||
function hostMatchesAllowlist(domain: string, allowlist: string[]): boolean {
|
||||
// Cookie domains may carry a leading dot (domain cookies); normalize it off.
|
||||
const host = (domain.startsWith('.') ? domain.slice(1) : domain).toLowerCase();
|
||||
for (const pattern of allowlist) {
|
||||
if (pattern.startsWith('*.')) {
|
||||
const base = pattern.slice(2);
|
||||
if (host === base || host.endsWith(`.${base}`)) return true;
|
||||
} else if (host === pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable per-workspace id. Hash of the resolved (realpath) project dir so two
|
||||
* different checkouts of the same repo don't share a slot, and a moved/renamed
|
||||
* dir gets its own. Falls back to the raw projectDir if realpath fails.
|
||||
*/
|
||||
export function computeProfileId(config: BrowseConfig): string {
|
||||
let base = config.projectDir;
|
||||
try {
|
||||
base = fs.realpathSync(config.projectDir);
|
||||
} catch {
|
||||
// Dir may not exist yet in some test setups — hash the declared path.
|
||||
}
|
||||
return createHash('sha256').update(base).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function autoDir(config: BrowseConfig): string {
|
||||
return path.join(config.stateDir, 'browse-auto-cookies');
|
||||
}
|
||||
function statePath(config: BrowseConfig): string {
|
||||
return path.join(autoDir(config), `${computeProfileId(config)}.json`);
|
||||
}
|
||||
function lockPath(config: BrowseConfig): string {
|
||||
return path.join(autoDir(config), `${computeProfileId(config)}.lock`);
|
||||
}
|
||||
|
||||
function normalizeCookieDomain(domain: string): string {
|
||||
const d = domain.startsWith('.') ? domain.slice(1) : domain;
|
||||
return d.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only cookies that are safe and sensible to persist:
|
||||
* - persistent (expires !== -1) and not already expired,
|
||||
* - well-formed (string name/value, non-empty string domain),
|
||||
* - not internal-network (mirrors the manual `state load` safety filter),
|
||||
* - within the optional host allowlist.
|
||||
*/
|
||||
export function filterPersistableCookies(
|
||||
cookies: Cookie[],
|
||||
allowlist: string[] | null,
|
||||
now: number = Date.now(),
|
||||
): Cookie[] {
|
||||
const nowSec = now / 1000;
|
||||
return cookies.filter((c) => {
|
||||
if (!c || typeof c.name !== 'string' || typeof c.value !== 'string') return false;
|
||||
if (typeof c.domain !== 'string' || !c.domain) return false;
|
||||
// Session cookie — Playwright reports expires === -1. Never persist.
|
||||
if (typeof c.expires !== 'number' || c.expires === -1 || !Number.isFinite(c.expires)) return false;
|
||||
if (c.expires <= nowSec) return false; // already expired
|
||||
const d = normalizeCookieDomain(c.domain);
|
||||
if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false;
|
||||
if (allowlist && !hostMatchesAllowlist(c.domain, allowlist)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic content hash of a cookie set — used to skip no-op writes.
|
||||
* Sorted by (domain, path, name) and reduced to the identity-bearing fields so
|
||||
* that a re-serialization with reordered cookies doesn't churn the file.
|
||||
*/
|
||||
export function cookieSetHash(cookies: Cookie[]): string {
|
||||
const norm = cookies
|
||||
.map(c => ({
|
||||
name: c.name, value: c.value, domain: c.domain, path: c.path,
|
||||
expires: c.expires, httpOnly: c.httpOnly, secure: c.secure,
|
||||
sameSite: c.sameSite, partitionKey: c.partitionKey ?? null,
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
a.domain.localeCompare(b.domain) || a.path.localeCompare(b.path) || a.name.localeCompare(b.name));
|
||||
return createHash('sha256').update(JSON.stringify(norm)).digest('hex');
|
||||
}
|
||||
|
||||
// ─── Locking (mkdir-atomic; conservative stale reclaim) ─────────────────────
|
||||
|
||||
/**
|
||||
* Try to acquire the per-workspace lock. `mkdir` of the lock dir is the atomic
|
||||
* primitive — it fails if the dir already exists. If a lock is held by a dead
|
||||
* pid (crash without cleanup) we reclaim it; a lock held by a live pid means a
|
||||
* peer daemon owns the slot and we back off. Returns true on acquisition.
|
||||
*/
|
||||
export function acquireLock(config: BrowseConfig): boolean {
|
||||
const dir = lockPath(config);
|
||||
const meta: LockMeta = { pid: process.pid, startedAt: new Date().toISOString() };
|
||||
// Ensure the parent (browse-auto-cookies/) exists; the lock dir itself is
|
||||
// created non-recursively so its creation stays the atomic acquire primitive.
|
||||
try { mkdirSecure(autoDir(config)); } catch { /* best-effort — mkdirSync below reports the real failure */ }
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: false });
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'EEXIST') return false;
|
||||
// Lock exists — reclaim only if its owner is provably dead.
|
||||
if (!reclaimIfStale(dir)) return false;
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: false });
|
||||
} catch {
|
||||
return false; // lost a race to another reclaimer
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'owner.json'), JSON.stringify(meta), { mode: 0o600 });
|
||||
} catch {
|
||||
// Metadata is advisory; the dir's existence is the actual lock.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readProcessStartTimeMs(pid: number): number | null {
|
||||
if (process.platform === 'win32') return null;
|
||||
try {
|
||||
const result = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'lstart='], {
|
||||
stdout: 'pipe', stderr: 'pipe', timeout: 2000,
|
||||
});
|
||||
if (result.exitCode !== 0) return null;
|
||||
const parsed = Date.parse(result.stdout.toString().trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecordedOwnerStillAlive(meta: LockMeta): boolean {
|
||||
if (typeof meta.pid !== 'number' || meta.pid <= 0) return false;
|
||||
if (meta.pid === process.pid) return true;
|
||||
if (!isProcessAlive(meta.pid)) return false;
|
||||
|
||||
const recordedMs = Date.parse(meta.startedAt);
|
||||
const liveStartMs = readProcessStartTimeMs(meta.pid);
|
||||
if (Number.isFinite(recordedMs) && liveStartMs !== null && liveStartMs > recordedMs) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reclaim a lock dir iff its recorded owner pid is no longer alive. */
|
||||
function reclaimIfStale(dir: string): boolean {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(dir, 'owner.json'), 'utf-8');
|
||||
const meta = JSON.parse(raw) as LockMeta;
|
||||
if (isRecordedOwnerStillAlive(meta)) {
|
||||
return false; // live owner — do not steal
|
||||
}
|
||||
} catch {
|
||||
// No/!parseable metadata — treat as stale (best-effort reclaim).
|
||||
}
|
||||
safeUnlinkQuiet(path.join(dir, 'owner.json'));
|
||||
try {
|
||||
fs.rmdirSync(dir);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Release the lock if we own it (pid match). Safe to call unconditionally. */
|
||||
export function releaseLock(config: BrowseConfig): void {
|
||||
const dir = lockPath(config);
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(dir, 'owner.json'), 'utf-8');
|
||||
const meta = JSON.parse(raw) as LockMeta;
|
||||
if (meta.pid !== process.pid) return; // not ours — don't remove
|
||||
} catch {
|
||||
// No metadata — fall through and attempt removal (we likely created it).
|
||||
}
|
||||
safeUnlinkQuiet(path.join(dir, 'owner.json'));
|
||||
try { fs.rmdirSync(dir); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// ─── Load / Save ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read persisted cookies for restore, as a Playwright storageState-shaped
|
||||
* object suitable for `newContext({ storageState })`. Returns null when the
|
||||
* feature is off, no file exists, the file is corrupt/foreign, or the workspace
|
||||
* id doesn't match. Never throws — a bad state file must not block launch.
|
||||
*/
|
||||
export function loadAutoCookieState(
|
||||
config: BrowseConfig,
|
||||
now: number = Date.now(),
|
||||
): { cookies: Cookie[]; origins: [] } | null {
|
||||
if (!isAutoCookiePersistEnabled()) return null;
|
||||
const file = statePath(config);
|
||||
let raw: string;
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null;
|
||||
raw = fs.readFileSync(file, 'utf-8');
|
||||
} catch (err: any) {
|
||||
// A read error here (e.g. EACCES) is NOT the benign corrupt-file case — the
|
||||
// device cookie silently won't restore, so surface it rather than swallow.
|
||||
console.warn(`[browse] auto-cookie state unreadable (${err?.code || err?.message}); starting without persisted cookies`);
|
||||
return null;
|
||||
}
|
||||
let parsed: AutoCookieState;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as AutoCookieState;
|
||||
} catch {
|
||||
return null; // corrupt JSON — ignore (a later save overwrites it)
|
||||
}
|
||||
if (!parsed || parsed.kind !== STATE_KIND || parsed.version !== STATE_VERSION) return null;
|
||||
if (parsed.workspaceId !== computeProfileId(config)) return null;
|
||||
const cookies = parsed.storageState?.cookies;
|
||||
if (!Array.isArray(cookies)) return null;
|
||||
// Re-filter on load: a cookie may have expired while the daemon was down,
|
||||
// and the allowlist may have tightened since the last save.
|
||||
const usable = filterPersistableCookies(cookies, parseDomainAllowlist(), now);
|
||||
return { cookies: usable, origins: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the live context's cookies and persist the persistable subset.
|
||||
* No-ops when disabled, when there's no context, or when the filtered set is
|
||||
* unchanged since the last write (hash compare). Returns a small status for
|
||||
* logging/tests. Best-effort: never throws into the caller (checkpoints run on
|
||||
* hot paths and during shutdown).
|
||||
*
|
||||
* `lastHash` lets the caller skip redundant writes; pass the value returned by
|
||||
* the previous call. An empty filtered set is still written once (to clear a
|
||||
* prior file) so a logout is not resurrected on next load.
|
||||
*/
|
||||
export async function saveAutoCookieState(
|
||||
config: BrowseConfig,
|
||||
context: BrowserContext | null,
|
||||
lastHash: string | null,
|
||||
): Promise<{ wrote: boolean; hash: string | null; count: number }> {
|
||||
try {
|
||||
if (!isAutoCookiePersistEnabled() || !context) {
|
||||
return { wrote: false, hash: lastHash, count: 0 };
|
||||
}
|
||||
const all = await context.cookies();
|
||||
const persistable = filterPersistableCookies(all, parseDomainAllowlist());
|
||||
const hash = cookieSetHash(persistable);
|
||||
if (hash === lastHash) {
|
||||
return { wrote: false, hash, count: persistable.length };
|
||||
}
|
||||
const state: AutoCookieState = {
|
||||
version: STATE_VERSION,
|
||||
kind: STATE_KIND,
|
||||
workspaceId: computeProfileId(config),
|
||||
savedAt: new Date().toISOString(),
|
||||
storageState: { cookies: persistable, origins: [] },
|
||||
};
|
||||
mkdirSecure(autoDir(config));
|
||||
writeSecureFileAtomic(statePath(config), JSON.stringify(state));
|
||||
return { wrote: true, hash, count: persistable.length };
|
||||
} catch {
|
||||
// Persistence is a convenience layer — a failure here must never break a
|
||||
// command or block shutdown.
|
||||
return { wrote: false, hash: lastHash, count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/** One-time plaintext warning when the feature is enabled. */
|
||||
export function warnPlaintextOnce(config: BrowseConfig): void {
|
||||
if (warnedOnce || !isAutoCookiePersistEnabled()) return;
|
||||
warnedOnce = true;
|
||||
console.warn(
|
||||
`[browse] BROWSE_AUTO_COOKIE_PERSIST is ON — persistent cookies are written ` +
|
||||
`in PLAINTEXT to ${autoDir(config)} (owner-only file perms only). ` +
|
||||
`These are auth credentials; anyone who can read the file can impersonate ` +
|
||||
`the logged-in sessions. Disable by unsetting the env var.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Opportunistic cleanup of leftover temp files from a crashed atomic write. */
|
||||
export function cleanStaleTempFiles(config: BrowseConfig): void {
|
||||
const dir = autoDir(config);
|
||||
try {
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (name.startsWith('.') && name.endsWith('.tmp')) {
|
||||
safeUnlinkQuiet(path.join(dir, name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Dir may not exist yet — nothing to clean.
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: reset the once-per-process warning gate. */
|
||||
export function __resetWarnedForTests(): void {
|
||||
warnedOnce = false;
|
||||
}
|
||||
|
|
@ -254,6 +254,14 @@ export class BrowserManager {
|
|||
|
||||
getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; }
|
||||
|
||||
/**
|
||||
* The live browser context, or null before launch / after close. Exposed for
|
||||
* the auto-cookie-persist layer (server.ts) to snapshot cookies on a
|
||||
* checkpoint without reaching into private state. Callers must null-check —
|
||||
* a checkpoint can fire between close() and the next launch().
|
||||
*/
|
||||
getContext(): import('playwright').BrowserContext | null { return this.context; }
|
||||
|
||||
// ─── Watch Mode Methods ─────────────────────────────────
|
||||
isWatching(): boolean { return this.watching; }
|
||||
|
||||
|
|
@ -401,6 +409,27 @@ export class BrowserManager {
|
|||
if (this.customUserAgent) {
|
||||
contextOptions.userAgent = this.customUserAgent;
|
||||
}
|
||||
|
||||
// Auto-cookie persistence (opt-in): seed the fresh context with previously
|
||||
// persisted cookies so a device-trust cookie survives a daemon restart.
|
||||
// Read-only + best-effort — the writer path (server.ts) owns the lock; a
|
||||
// concurrent read of an atomically-written file is always a complete file.
|
||||
// recreateContext() does NOT go through here (it restores in-memory state
|
||||
// itself), so there's no double-restore.
|
||||
try {
|
||||
const { resolveConfig } = await import('./config');
|
||||
const { loadAutoCookieState, warnPlaintextOnce } = await import('./auto-cookie-persist');
|
||||
const cfg = resolveConfig();
|
||||
warnPlaintextOnce(cfg);
|
||||
const restored = loadAutoCookieState(cfg);
|
||||
if (restored && restored.cookies.length > 0) {
|
||||
contextOptions.storageState = restored;
|
||||
console.log(`[browse] Restored ${restored.cookies.length} persisted cookie(s) from auto-state`);
|
||||
}
|
||||
} catch {
|
||||
// Never block launch on the persistence layer.
|
||||
}
|
||||
|
||||
this.context = await this.browser.newContext(contextOptions);
|
||||
|
||||
if (Object.keys(this.extraHeaders).length > 0) {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ export async function handleCookiePickerRoute(
|
|||
req: Request,
|
||||
bm: BrowserManager,
|
||||
authToken?: string,
|
||||
onCookieMutation?: () => void,
|
||||
): Promise<Response> {
|
||||
const pathname = url.pathname;
|
||||
const port = parseInt(url.port, 10) || 9400;
|
||||
|
|
@ -268,6 +269,7 @@ export async function handleCookiePickerRoute(
|
|||
// Add to Playwright context
|
||||
const page = bm.getActiveSession().getPage();
|
||||
await page.context().addCookies(result.cookies);
|
||||
onCookieMutation?.();
|
||||
|
||||
// Track what was imported
|
||||
for (const domain of Object.keys(result.domainCounts)) {
|
||||
|
|
@ -305,6 +307,7 @@ export async function handleCookiePickerRoute(
|
|||
importedDomains.delete(domain);
|
||||
importedCounts.delete(domain);
|
||||
}
|
||||
onCookieMutation?.();
|
||||
|
||||
console.log(`[cookie-picker] Removed cookies for ${domains.length} domains`);
|
||||
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@
|
|||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { randomBytes } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
let warnedOnce = false;
|
||||
|
||||
|
|
@ -123,6 +125,53 @@ export function writeSecureFile(
|
|||
restrictFilePermissions(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write a file and restrict it to owner-only access, cross-platform.
|
||||
*
|
||||
* `writeSecureFile` writes in place, so a crash (or a concurrent reader)
|
||||
* mid-write can observe a truncated/half-written file. For state that is
|
||||
* rewritten repeatedly by a long-lived daemon — e.g. the auto-cookie state,
|
||||
* which checkpoints on every mutating command — a torn write means the next
|
||||
* load reads corrupt JSON and silently drops a live auth cookie. This variant
|
||||
* writes to a same-directory temp file (owner-only), best-effort fsyncs it,
|
||||
* then `rename`s over the destination. `rename` within a directory is atomic
|
||||
* on POSIX and on NTFS (ReplaceFile semantics via Node), so a reader sees
|
||||
* either the old file or the new one, never a partial.
|
||||
*
|
||||
* Same-directory is load-bearing: a temp file on another filesystem would make
|
||||
* `rename` fall back to copy+unlink, which is NOT atomic. The temp name embeds
|
||||
* the pid plus random bytes so racing writes never share a staging path (the
|
||||
* destination itself still needs a lock — this only guarantees each write is
|
||||
* individually atomic).
|
||||
*/
|
||||
export function writeSecureFileAtomic(
|
||||
filePath: string,
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
): void {
|
||||
const dir = path.dirname(filePath);
|
||||
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(tmp, 'w', 0o600);
|
||||
fs.writeSync(fd, data as any);
|
||||
try { fs.fsyncSync(fd); } catch { /* fsync is best-effort — rename still gives atomicity */ }
|
||||
} finally {
|
||||
if (fd !== null) {
|
||||
try { fs.closeSync(fd); } catch { /* already closed / best-effort */ }
|
||||
}
|
||||
}
|
||||
// On Windows, openSync with mode does not set an ACL; restrict explicitly
|
||||
// before the rename so the destination never exists unrestricted.
|
||||
restrictFilePermissions(tmp);
|
||||
try {
|
||||
fs.renameSync(tmp, filePath);
|
||||
} catch (err) {
|
||||
// Rename failed — clean up the temp file so it doesn't accumulate.
|
||||
try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append to a file with owner-only permissions, cross-platform.
|
||||
* Replaces `fs.appendFileSync(path, data, { mode: 0o600 })` + Windows ACL.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ import {
|
|||
} from './token-registry';
|
||||
import { validateTempPath } from './path-security';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks } from './config';
|
||||
import {
|
||||
isAutoCookiePersistEnabled,
|
||||
acquireLock as acquireAutoCookieLock,
|
||||
releaseLock as releaseAutoCookieLock,
|
||||
saveAutoCookieState,
|
||||
cleanStaleTempFiles as cleanAutoCookieTempFiles,
|
||||
} from './auto-cookie-persist';
|
||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||
import { createSseEndpoint } from './sse-helpers';
|
||||
import { initAuditLog, writeAuditEntry } from './audit';
|
||||
|
|
@ -648,6 +655,64 @@ function idleCheckTick() {
|
|||
}
|
||||
const idleCheckInterval = setInterval(idleCheckTick, 60_000);
|
||||
|
||||
// ─── Auto-cookie persistence (opt-in) ──────────────────────────
|
||||
// Checkpoints the live context's persistent cookies to a per-workspace state
|
||||
// file so a device-trust cookie survives a daemon restart. Three triggers:
|
||||
// (1) a periodic checkpoint below, (2) a debounced checkpoint after mutating
|
||||
// commands (scheduleAutoCookieCheckpoint, called from handleCommandInternalImpl),
|
||||
// and (3) a final flush in shutdown() before the browser closes. The crash
|
||||
// path (emergencyCleanup) deliberately does NOT save — serializing a dying
|
||||
// context risks a corrupt write; we rely on the most recent good checkpoint.
|
||||
let autoCookieLockHeld = false;
|
||||
let autoCookieLastHash: string | null = null;
|
||||
let autoCookieDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
let autoCookieCheckpointInFlight: Promise<void> | null = null;
|
||||
|
||||
async function autoCookieCheckpoint(): Promise<void> {
|
||||
while (autoCookieCheckpointInFlight) {
|
||||
await autoCookieCheckpointInFlight;
|
||||
}
|
||||
if (!autoCookieLockHeld) return; // never write without owning the lock
|
||||
const run = (async () => {
|
||||
if (!autoCookieLockHeld) return;
|
||||
const res = await saveAutoCookieState(config, activeBrowserManager.getContext(), autoCookieLastHash);
|
||||
if (autoCookieLockHeld) autoCookieLastHash = res.hash;
|
||||
})();
|
||||
autoCookieCheckpointInFlight = run;
|
||||
try {
|
||||
await run;
|
||||
} finally {
|
||||
if (autoCookieCheckpointInFlight === run) autoCookieCheckpointInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced checkpoint: coalesces bursts of mutating commands into one write a
|
||||
// short while after the last one. unref'd so it never keeps the process alive.
|
||||
function scheduleAutoCookieCheckpoint(): void {
|
||||
if (!autoCookieLockHeld) return;
|
||||
if (autoCookieDebounce) clearTimeout(autoCookieDebounce);
|
||||
autoCookieDebounce = setTimeout(() => {
|
||||
autoCookieDebounce = null;
|
||||
void autoCookieCheckpoint();
|
||||
}, 1500);
|
||||
if (typeof autoCookieDebounce.unref === 'function') autoCookieDebounce.unref();
|
||||
}
|
||||
|
||||
// Periodic safety-net checkpoint (covers crash-before-shutdown where the
|
||||
// debounce/flush never runs). unref'd — must not hold the daemon open.
|
||||
const autoCookieInterval = setInterval(() => { void autoCookieCheckpoint(); }, 60_000);
|
||||
if (typeof autoCookieInterval.unref === 'function') autoCookieInterval.unref();
|
||||
|
||||
// Commands that can mutate cookies/browser state and thus warrant a checkpoint.
|
||||
const AUTO_COOKIE_MUTATING_COMMANDS = new Set([
|
||||
'chain', 'state', 'newtab', 'tab-each',
|
||||
'js', 'eval',
|
||||
]);
|
||||
|
||||
function shouldAutoCookieCheckpoint(command: string): boolean {
|
||||
return WRITE_COMMANDS.has(command) || AUTO_COOKIE_MUTATING_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
// Test-only surface for server-factory.test.ts. Lets the dual-instance
|
||||
// idle-timer behavior be exercised deterministically without mutating
|
||||
// Date.now (which would interact with the leaked module-level setInterval).
|
||||
|
|
@ -958,6 +1023,7 @@ async function handleCommandInternalImpl(
|
|||
// so the trail records what the agent actually typed.
|
||||
const command = canonicalizeCommand(rawCommand);
|
||||
const isAliased = command !== rawCommand;
|
||||
const shouldCheckpointAutoCookies = (opts?.chainDepth ?? 0) === 0 && shouldAutoCookieCheckpoint(command);
|
||||
|
||||
// ─── Recursion guard: reject nested chains ──────────────────
|
||||
if (command === 'chain' && (opts?.chainDepth ?? 0) > 0) {
|
||||
|
|
@ -1057,6 +1123,7 @@ async function handleCommandInternalImpl(
|
|||
// ─── newtab with ownership for scoped tokens ──────────────
|
||||
if (command === 'newtab' && tokenInfo && tokenInfo.clientId !== 'root') {
|
||||
const newId = await browserManager.newTab(args[0] || undefined, tokenInfo.clientId);
|
||||
if (shouldCheckpointAutoCookies) scheduleAutoCookieCheckpoint();
|
||||
return {
|
||||
status: 200, json: true,
|
||||
result: JSON.stringify({
|
||||
|
|
@ -1184,6 +1251,11 @@ async function handleCommandInternalImpl(
|
|||
};
|
||||
}
|
||||
|
||||
// Auto-cookie checkpoint (opt-in): schedule after the command attempt has
|
||||
// actually run, so slow navigations/login redirects do not snapshot the
|
||||
// pre-command cookie jar. No-op unless the lock is held.
|
||||
if (shouldCheckpointAutoCookies) scheduleAutoCookieCheckpoint();
|
||||
|
||||
// ─── Centralized content wrapping (single location for all commands) ───
|
||||
// Scoped tokens: content filter + enhanced envelope + datamarking
|
||||
// Root tokens: basic untrusted content wrapper (backward compat)
|
||||
|
|
@ -1255,6 +1327,8 @@ async function handleCommandInternalImpl(
|
|||
}
|
||||
return { status: 200, result };
|
||||
} catch (err: any) {
|
||||
if (shouldCheckpointAutoCookies) scheduleAutoCookieCheckpoint();
|
||||
|
||||
// Restore original active tab even on error
|
||||
if (savedTabId !== null) {
|
||||
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) {
|
||||
|
|
@ -1607,9 +1681,17 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
if (cfgBrowserManager.isWatching()) cfgBrowserManager.stopWatch();
|
||||
clearInterval(flushInterval);
|
||||
clearInterval(idleCheckInterval);
|
||||
clearInterval(autoCookieInterval);
|
||||
if (autoCookieDebounce) { clearTimeout(autoCookieDebounce); autoCookieDebounce = null; }
|
||||
if (agentWatchdogInterval) clearInterval(agentWatchdogInterval);
|
||||
await flushBuffers();
|
||||
|
||||
// Final cookie checkpoint BEFORE the context closes — this is the graceful
|
||||
// path all intentional shutdowns funnel through (idle timeout, SIGINT,
|
||||
// /stop). Must run while the context still exists. Then release the lock.
|
||||
await autoCookieCheckpoint();
|
||||
if (autoCookieLockHeld) { releaseAutoCookieLock(config); autoCookieLockHeld = false; }
|
||||
|
||||
await cfgBrowserManager.close();
|
||||
|
||||
cleanSingletonLocks(resolveChromiumProfile());
|
||||
|
|
@ -1640,6 +1722,20 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
// reports connectionMode === 'launched'.
|
||||
activeBrowserManager = cfgBrowserManager;
|
||||
|
||||
// Acquire the per-workspace auto-cookie lock once, at daemon start. Holding
|
||||
// it for the daemon's lifetime is what serializes concurrent daemons (e.g.
|
||||
// separate git worktrees) — only the lock holder writes the shared slot.
|
||||
// If a live peer owns it, disable auto-persistence for this daemon (loud) so
|
||||
// two daemons never last-writer-wins each other's cookies. Reads still work
|
||||
// without the lock (atomic writes make a concurrent read safe).
|
||||
if (isAutoCookiePersistEnabled()) {
|
||||
cleanAutoCookieTempFiles(config);
|
||||
autoCookieLockHeld = acquireAutoCookieLock(config);
|
||||
if (!autoCookieLockHeld) {
|
||||
console.warn('[browse] Auto-cookie persistence disabled for this daemon: another instance holds the workspace lock.');
|
||||
}
|
||||
}
|
||||
|
||||
// Wire the cfg-instance's onDisconnect to run shutdown when the user
|
||||
// closes the headed browser window. CHAIN any caller-provided handler
|
||||
// instead of overwriting it: gbrowser may have set its own onDisconnect
|
||||
|
|
@ -1725,7 +1821,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
|
||||
// Cookie picker routes — HTML page unauthenticated, data/action routes require auth
|
||||
if (url.pathname.startsWith('/cookie-picker')) {
|
||||
return handleCookiePickerRoute(url, req, browserManager, authToken);
|
||||
return handleCookiePickerRoute(url, req, browserManager, authToken, scheduleAutoCookieCheckpoint);
|
||||
}
|
||||
|
||||
// Welcome page — served when GStack Browser launches in headed mode
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
/**
|
||||
* Tests for opt-in automatic cookie persistence (auto-cookie-persist.ts).
|
||||
*
|
||||
* Unit tests cover the cookie filter (persistent-only, expiry, internal-network,
|
||||
* allowlist), the content hash, the mkdir-atomic lock (acquire / contend /
|
||||
* stale-reclaim / release), and the atomic-write helper. A Playwright
|
||||
* round-trip test proves a persistent __Host- cookie is saved and restored via
|
||||
* newContext({ storageState }), and that session/expired cookies are excluded.
|
||||
*
|
||||
* Static-grep tests pin the load-bearing wiring in server.ts / browser-manager.ts
|
||||
* (opt-in gate, no-save-on-crash-path, shutdown flush before close) so a refactor
|
||||
* can't silently regress them.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Cookie } from 'playwright';
|
||||
import { chromium } from 'playwright';
|
||||
import type { BrowseConfig } from '../src/config';
|
||||
import {
|
||||
isAutoCookiePersistEnabled,
|
||||
parseDomainAllowlist,
|
||||
filterPersistableCookies,
|
||||
cookieSetHash,
|
||||
computeProfileId,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
loadAutoCookieState,
|
||||
saveAutoCookieState,
|
||||
} from '../src/auto-cookie-persist';
|
||||
import { writeSecureFileAtomic } from '../src/file-permissions';
|
||||
|
||||
const META = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
|
||||
const BM = fs.readFileSync(path.join(import.meta.dir, '../src/browser-manager.ts'), 'utf-8');
|
||||
const PICKER = fs.readFileSync(path.join(import.meta.dir, '../src/cookie-picker-routes.ts'), 'utf-8');
|
||||
|
||||
function tmpConfig(): BrowseConfig {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-cookie-'));
|
||||
const stateDir = path.join(dir, '.gstack');
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
return {
|
||||
projectDir: dir,
|
||||
stateDir,
|
||||
stateFile: path.join(stateDir, 'browse.json'),
|
||||
consoleLog: '', networkLog: '', dialogLog: '', auditLog: '',
|
||||
};
|
||||
}
|
||||
|
||||
const future = Math.floor(Date.now() / 1000) + 60 * 24 * 60 * 60;
|
||||
function cookie(over: Partial<Cookie> = {}): Cookie {
|
||||
return {
|
||||
name: 'c', value: 'v', domain: 'example.com', path: '/',
|
||||
expires: future, httpOnly: true, secure: true, sameSite: 'Lax', ...over,
|
||||
} as Cookie;
|
||||
}
|
||||
|
||||
const savedEnv = { ...process.env };
|
||||
afterEach(() => {
|
||||
process.env = { ...savedEnv };
|
||||
});
|
||||
|
||||
describe('opt-in gate', () => {
|
||||
test('enabled only for truthy flag values', () => {
|
||||
for (const v of ['1', 'true', 'TRUE', 'yes', 'on']) {
|
||||
expect(isAutoCookiePersistEnabled({ BROWSE_AUTO_COOKIE_PERSIST: v })).toBe(true);
|
||||
}
|
||||
for (const v of [undefined, '', '0', 'false', 'no', 'off']) {
|
||||
expect(isAutoCookiePersistEnabled({ BROWSE_AUTO_COOKIE_PERSIST: v as any })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('loadAutoCookieState returns null when disabled', () => {
|
||||
const cfg = tmpConfig();
|
||||
delete process.env.BROWSE_AUTO_COOKIE_PERSIST;
|
||||
expect(loadAutoCookieState(cfg)).toBeNull();
|
||||
});
|
||||
|
||||
test('saveAutoCookieState is a no-op when disabled', async () => {
|
||||
const cfg = tmpConfig();
|
||||
delete process.env.BROWSE_AUTO_COOKIE_PERSIST;
|
||||
const res = await saveAutoCookieState(cfg, null, null);
|
||||
expect(res.wrote).toBe(false);
|
||||
expect(fs.existsSync(path.join(cfg.stateDir, 'browse-auto-cookies'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookie filter', () => {
|
||||
test('keeps persistent, drops session cookies', () => {
|
||||
const kept = filterPersistableCookies([cookie(), cookie({ name: 's', expires: -1 })], null);
|
||||
expect(kept.map(c => c.name)).toEqual(['c']);
|
||||
});
|
||||
|
||||
test('drops already-expired cookies', () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 10;
|
||||
const kept = filterPersistableCookies([cookie({ name: 'old', expires: past }), cookie()], null);
|
||||
expect(kept.map(c => c.name)).toEqual(['c']);
|
||||
});
|
||||
|
||||
test('rejects internal-network domains', () => {
|
||||
const kept = filterPersistableCookies([
|
||||
cookie({ name: 'lh', domain: 'localhost' }),
|
||||
cookie({ name: 'lh2', domain: 'LOCALHOST' }),
|
||||
cookie({ name: 'meta', domain: '169.254.169.254' }),
|
||||
cookie({ name: 'int', domain: 'foo.internal' }),
|
||||
cookie({ name: 'int2', domain: 'Foo.INTERNAL' }),
|
||||
cookie({ name: 'ok', domain: 'example.com' }),
|
||||
], null);
|
||||
expect(kept.map(c => c.name)).toEqual(['ok']);
|
||||
});
|
||||
|
||||
test('rejects malformed expires values', () => {
|
||||
const kept = filterPersistableCookies([
|
||||
cookie({ name: 'nan', expires: Number.NaN }),
|
||||
cookie({ name: 'inf', expires: Number.POSITIVE_INFINITY }),
|
||||
cookie({ name: 'ok' }),
|
||||
], null);
|
||||
expect(kept.map(c => c.name)).toEqual(['ok']);
|
||||
});
|
||||
|
||||
test('applies host allowlist incl. leading-wildcard + apex', () => {
|
||||
const cookies = [
|
||||
cookie({ name: 'a', domain: 'example.com' }),
|
||||
cookie({ name: 'b', domain: 'sub.example.com' }),
|
||||
cookie({ name: 'c', domain: 'other.com' }),
|
||||
];
|
||||
const kept = filterPersistableCookies(cookies, ['*.example.com']);
|
||||
expect(kept.map(c => c.name).sort()).toEqual(['a', 'b']); // apex + subdomain, not other.com
|
||||
});
|
||||
|
||||
test('parseDomainAllowlist splits and lowercases; empty ⇒ null', () => {
|
||||
expect(parseDomainAllowlist({ BROWSE_AUTO_COOKIE_DOMAINS: 'A.com, *.B.com' }))
|
||||
.toEqual(['a.com', '*.b.com']);
|
||||
expect(parseDomainAllowlist({ BROWSE_AUTO_COOKIE_DOMAINS: '' })).toBeNull();
|
||||
expect(parseDomainAllowlist({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content hash', () => {
|
||||
test('order-independent', () => {
|
||||
const a = cookie({ name: 'a', domain: 'a.com' });
|
||||
const b = cookie({ name: 'b', domain: 'b.com' });
|
||||
expect(cookieSetHash([a, b])).toBe(cookieSetHash([b, a]));
|
||||
});
|
||||
test('changes when a value changes', () => {
|
||||
const a = cookie({ name: 'a', value: '1' });
|
||||
const a2 = cookie({ name: 'a', value: '2' });
|
||||
expect(cookieSetHash([a])).not.toBe(cookieSetHash([a2]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('atomic write helper', () => {
|
||||
test('writes content and leaves no temp file', () => {
|
||||
const cfg = tmpConfig();
|
||||
const f = path.join(cfg.stateDir, 'atomic.json');
|
||||
writeSecureFileAtomic(f, JSON.stringify({ ok: 1 }));
|
||||
expect(JSON.parse(fs.readFileSync(f, 'utf-8'))).toEqual({ ok: 1 });
|
||||
const leftovers = fs.readdirSync(cfg.stateDir).filter(n => n.endsWith('.tmp'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace lock', () => {
|
||||
test('acquire, contend (same process re-acquire fails), release', () => {
|
||||
const cfg = tmpConfig();
|
||||
expect(acquireLock(cfg)).toBe(true);
|
||||
// Second acquire while a live pid (this process) owns it → refused.
|
||||
expect(acquireLock(cfg)).toBe(false);
|
||||
releaseLock(cfg);
|
||||
// After release the dir is gone and we can re-acquire.
|
||||
expect(acquireLock(cfg)).toBe(true);
|
||||
releaseLock(cfg);
|
||||
});
|
||||
|
||||
test('reclaims a lock owned by a dead pid', () => {
|
||||
const cfg = tmpConfig();
|
||||
const lockDir = path.join(cfg.stateDir, 'browse-auto-cookies', `${computeProfileId(cfg)}.lock`);
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
// PID 2^31-1 is not a live process → stale, reclaimable.
|
||||
fs.writeFileSync(path.join(lockDir, 'owner.json'), JSON.stringify({ pid: 2147483646, startedAt: 'x' }));
|
||||
expect(acquireLock(cfg)).toBe(true);
|
||||
releaseLock(cfg);
|
||||
});
|
||||
|
||||
test('reclaims a lock whose pid was reused by a newer process', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const cfg = tmpConfig();
|
||||
const lockDir = path.join(cfg.stateDir, 'browse-auto-cookies', `${computeProfileId(cfg)}.lock`);
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
const proc = Bun.spawn(['sleep', '5'], { stdout: 'ignore', stderr: 'ignore' });
|
||||
try {
|
||||
fs.writeFileSync(path.join(lockDir, 'owner.json'), JSON.stringify({
|
||||
pid: proc.pid,
|
||||
startedAt: '1970-01-01T00:00:00.000Z',
|
||||
}));
|
||||
expect(acquireLock(cfg)).toBe(true);
|
||||
releaseLock(cfg);
|
||||
} finally {
|
||||
proc.kill();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('save → load round-trip (real Playwright)', () => {
|
||||
test('persistent __Host- cookie survives; session/expired excluded', async () => {
|
||||
process.env.BROWSE_AUTO_COOKIE_PERSIST = '1';
|
||||
delete process.env.BROWSE_AUTO_COOKIE_DOMAINS;
|
||||
const cfg = tmpConfig();
|
||||
const origin = 'https://luseed.example.com';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const ctx = await browser.newContext();
|
||||
await ctx.addCookies([
|
||||
{ name: '__Host-lsd', value: 'tok.raw', url: origin, httpOnly: true, secure: true, sameSite: 'Lax', expires: future },
|
||||
{ name: 'authjs.session-token', value: 'sess', url: origin, httpOnly: true, secure: true, sameSite: 'Lax', expires: future },
|
||||
{ name: 'ephem', value: 'x', url: origin, httpOnly: false, secure: true, sameSite: 'Lax' }, // session
|
||||
]);
|
||||
const res = await saveAutoCookieState(cfg, ctx, null);
|
||||
await ctx.close();
|
||||
expect(res.wrote).toBe(true);
|
||||
expect(res.count).toBe(2); // ephem (session) filtered out
|
||||
|
||||
const loaded = loadAutoCookieState(cfg);
|
||||
expect(loaded).not.toBeNull();
|
||||
const names = loaded!.cookies.map(c => c.name).sort();
|
||||
expect(names).toEqual(['__Host-lsd', 'authjs.session-token']);
|
||||
|
||||
// The restored storageState actually rehydrates a fresh context.
|
||||
const ctx2 = await browser.newContext({ storageState: loaded! });
|
||||
const back = await ctx2.cookies();
|
||||
await ctx2.close();
|
||||
expect(back.some(c => c.name === '__Host-lsd')).toBe(true);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('unchanged cookie set skips the second write (hash short-circuit)', async () => {
|
||||
process.env.BROWSE_AUTO_COOKIE_PERSIST = '1';
|
||||
const cfg = tmpConfig();
|
||||
const origin = 'https://luseed.example.com';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const ctx = await browser.newContext();
|
||||
await ctx.addCookies([{ name: '__Host-lsd', value: 'tok', url: origin, httpOnly: true, secure: true, sameSite: 'Lax', expires: future }]);
|
||||
const first = await saveAutoCookieState(cfg, ctx, null);
|
||||
expect(first.wrote).toBe(true);
|
||||
const second = await saveAutoCookieState(cfg, ctx, first.hash);
|
||||
expect(second.wrote).toBe(false); // identical set → no write
|
||||
await ctx.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('BrowserManager.launch() integration (real browser + real daemon config)', () => {
|
||||
test('launch() restores a persisted __Host- cookie into the fresh context', async () => {
|
||||
process.env.BROWSE_AUTO_COOKIE_PERSIST = '1';
|
||||
delete process.env.BROWSE_AUTO_COOKIE_DOMAINS;
|
||||
const cfg = tmpConfig();
|
||||
// launch() calls resolveConfig() internally, which honors BROWSE_STATE_FILE —
|
||||
// point it at this isolated workspace.
|
||||
process.env.BROWSE_STATE_FILE = cfg.stateFile;
|
||||
|
||||
// Pre-seed the auto-state file exactly as a prior daemon's shutdown flush
|
||||
// would have (persistent __Host- cookie for an https origin).
|
||||
const origin = 'https://luseed.example.com';
|
||||
const browser0 = await chromium.launch({ headless: true });
|
||||
const seedCtx = await browser0.newContext();
|
||||
await seedCtx.addCookies([
|
||||
{ name: '__Host-lsd', value: 'device.token', url: origin, httpOnly: true, secure: true, sameSite: 'Lax', expires: future },
|
||||
]);
|
||||
const wrote = await saveAutoCookieState(cfg, seedCtx, null);
|
||||
await seedCtx.close();
|
||||
await browser0.close();
|
||||
expect(wrote.wrote).toBe(true);
|
||||
|
||||
// Launch a real BrowserManager against the same workspace — it should load
|
||||
// the file and seed the new context, simulating a daemon restart.
|
||||
const { BrowserManager } = await import('../src/browser-manager');
|
||||
const bm = new BrowserManager();
|
||||
try {
|
||||
await bm.launch();
|
||||
const ctx = bm.getContext();
|
||||
expect(ctx).not.toBeNull();
|
||||
const cookies = await ctx!.cookies();
|
||||
expect(cookies.some(c => c.name === '__Host-lsd' && c.value === 'device.token')).toBe(true);
|
||||
} finally {
|
||||
await bm.close();
|
||||
}
|
||||
}, 30_000); // real browser launch can take >5s (matches handoff integration timing)
|
||||
});
|
||||
|
||||
describe('wiring (static)', () => {
|
||||
test('save is gated on the opt-in flag at daemon start', () => {
|
||||
expect(META).toContain('isAutoCookiePersistEnabled()');
|
||||
expect(META).toContain('acquireAutoCookieLock(config)');
|
||||
});
|
||||
|
||||
test('final checkpoint runs in shutdown BEFORE the browser closes', () => {
|
||||
const shutIdx = META.indexOf('async function shutdown(');
|
||||
const flushIdx = META.indexOf('await autoCookieCheckpoint();', shutIdx);
|
||||
const closeIdx = META.indexOf('await cfgBrowserManager.close();', shutIdx);
|
||||
expect(flushIdx).toBeGreaterThan(shutIdx);
|
||||
expect(closeIdx).toBeGreaterThan(flushIdx); // flush precedes close
|
||||
});
|
||||
|
||||
test('crash path (emergencyCleanup) does NOT checkpoint cookies', () => {
|
||||
const start = META.indexOf('function emergencyCleanup()');
|
||||
const end = META.indexOf('\n}', start);
|
||||
const body = META.slice(start, end);
|
||||
expect(body).not.toContain('autoCookieCheckpoint');
|
||||
expect(body).not.toContain('saveAutoCookieState');
|
||||
});
|
||||
|
||||
test('launch() seeds the context from persisted cookies before newContext', () => {
|
||||
const loadIdx = BM.indexOf('loadAutoCookieState(cfg)');
|
||||
const newCtxIdx = BM.indexOf('this.context = await this.browser.newContext(contextOptions);');
|
||||
expect(loadIdx).toBeGreaterThan(0);
|
||||
expect(newCtxIdx).toBeGreaterThan(loadIdx); // restore assigned into contextOptions before use
|
||||
});
|
||||
|
||||
test('debounced command checkpoint runs after dispatch and covers real mutators', () => {
|
||||
expect(META).toContain('WRITE_COMMANDS.has(command)');
|
||||
expect(META).toContain("'js', 'eval'");
|
||||
expect(META).toContain("'chain', 'state', 'newtab', 'tab-each'");
|
||||
expect(META).not.toContain("'set-cookie'");
|
||||
const canonicalIdx = META.indexOf('const command = canonicalizeCommand(rawCommand);');
|
||||
const dispatchIdx = META.indexOf('if (READ_COMMANDS.has(command))', canonicalIdx);
|
||||
const scheduleIdx = META.indexOf('// Auto-cookie checkpoint (opt-in): schedule after', dispatchIdx);
|
||||
expect(dispatchIdx).toBeGreaterThan(canonicalIdx);
|
||||
expect(scheduleIdx).toBeGreaterThan(dispatchIdx);
|
||||
});
|
||||
|
||||
test('cookie picker imports/removals schedule auto-cookie persistence', () => {
|
||||
expect(META).toContain('handleCookiePickerRoute(url, req, browserManager, authToken, scheduleAutoCookieCheckpoint)');
|
||||
expect(PICKER).toContain('onCookieMutation?: () => void');
|
||||
expect(PICKER.match(/onCookieMutation\?\.\(\)/g)?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue