Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit cfc0823643f85125239cd6766302131ffed927bd
parent 982c771a57dcad5ac033651fc414dd39226b9deb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 12:22:41 -0400

common: slice XL review fixes — the browser login is read as gallery-dl reads it, the Check says what gallery-dl cannot see yet, and a refresh never replaces a logged-in jar with an empty one

- M1: containers as gallery-dl reads them: no ::CONTAINER (or ::none) is only
  the cookies outside every container, ::all is no filter, ::NAME matched as
  gallery-dl matches (name, l10nId, l10nID; case-sensitive). The Check says when
  an x.com login sits only in a container the spec does not read.
- L1: one copy, two views — with the WAL (Firefox now; the Playwright fallback)
  and the main file alone (gallery-dl's immutable open). A login in the WAL only
  is said in one line.
- L3: a login counts as an auth_token on x.com only; twitter.com's is reported
  as the old domain's cookie, not counted.
- L4: the refresh reads a profile again with the recorded browser when the
  bundled read has no auth_token while the jar holds one, and keeps the jar and
  refuses when no read finds it (refreshXCookies takes an injectable chromium;
  xSessionBroker.test.ts, 6 cases).
- I1: --test-type described as Chromium's internal test-harness switch, not
  the documented (machine-wide policy) route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/social/xBrowser.ts | 13++++++++++---
Mcommon/social/xBrowserLogin.test.ts | 115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcommon/social/xBrowserLogin.ts | 299+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcommon/social/xPlaywrightFetcher.ts | 6+++---
Acommon/social/xSessionBroker.test.ts | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/social/xSessionBroker.ts | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Meditor/app/settings/components/XSessionSection.tsx | 2+-
Meditor/e2e/x-session.spec.ts | 3++-
8 files changed, 604 insertions(+), 120 deletions(-)

diff --git a/common/social/xBrowser.ts b/common/social/xBrowser.ts @@ -21,9 +21,16 @@ // `--enable-automation` gone (the pipe sets it). // - `--test-type` and, for the headed window, the sandbox on — a system // Chrome draws "You are using an unsupported command-line flag" for the -// blink flag above and for Playwright's default `--no-sandbox`; -// `--test-type` (what ChromeDriver passes) suppresses the first, the -// sandbox removes the second. The bundled build draws neither. +// blink flag above and for Playwright's default `--no-sandbox`. The +// sandbox removes the second. `--test-type` is Chromium's internal +// test-harness switch: it makes the browser skip its startup bars, the +// bad-flags one among them, and sets no automation signal (measured: +// navigator.webdriver, window.chrome, the user agent and the plugins are +// the same with and without it). ChromeDriver passes +// `--test-type=webdriver`; the bare switch is used here. It is not the +// documented route — that is the `CommandLineFlagSecurityWarningsEnabled` +// policy, which is machine-wide, needs root, and would silence the +// operator's everyday browser too. The bundled build draws neither bar. // None of this hides the debugging pipe itself; Google's sign-in may still // refuse an embedded browser, which the Settings section says. // diff --git a/common/social/xBrowserLogin.test.ts b/common/social/xBrowserLogin.test.ts @@ -14,12 +14,19 @@ import path from "node:path"; import type { Paths } from "../lib/paths"; import { firefoxExpirySeconds, + isOldDomainAuth, + isXComAuth, readFirefoxXCookies, + readFirefoxXStore, readXLoginStatus, xCookiesFromBrowser, } from "./xBrowserLogin"; import { xCookieFile, xProfileDir } from "./xSessionBroker"; -import { writeFirefoxCookieStore, type FixtureCookie } from "./__fixtures__/firefoxCookieStore"; +import { + insertFixtureCookies, + writeFirefoxCookieStore, + type FixtureCookie, +} from "./__fixtures__/firefoxCookieStore"; const TMP = mkdtempSync(path.join(os.tmpdir(), "xl-xbrowserlogin-")); after(() => rm(TMP, { recursive: true, force: true })); @@ -105,13 +112,14 @@ test("the browser's store is never written: no file in the profile changes, no t } }); -test("a Firefox container narrows the read; ::none reads outside every container", async () => { +test("containers are read as gallery-dl reads them: none by default, ::all, or one by name", async () => { const dir = fresh("profile-containers"); const { file } = await writeFirefoxCookieStore(dir, [ { host: ".x.com", name: "auth_token", value: "default" }, { host: ".x.com", name: "auth_token", value: "work", originAttributes: "^userContextId=2" }, { host: ".x.com", name: "auth_token", value: "work-fpd", originAttributes: "^firstPartyDomain=x.com&userContextId=2&x=1" }, { host: ".x.com", name: "auth_token", value: "personal", originAttributes: "^userContextId=1" }, + { host: ".x.com", name: "auth_token", value: "shopping", originAttributes: "^userContextId=12" }, ]); await writeFile( path.join(dir, "containers.json"), @@ -120,16 +128,58 @@ test("a Firefox container narrows the read; ::none reads outside every container identities: [ { userContextId: 1, public: true, l10nID: "userContextPersonal.label" }, { userContextId: 2, public: true, name: "Work" }, + { userContextId: 12, public: true, l10nId: "user-context-shopping" }, ], }), ); const values = async (container?: string) => (await readFirefoxXCookies(file, { container, tmpRoot: TMP })).map((c) => c.value).sort(); - assert.deepEqual(await values(), ["default", "personal", "work", "work-fpd"]); - assert.deepEqual(await values("Work"), ["work", "work-fpd"]); - assert.deepEqual(await values("personal"), ["personal"]); + // No container in the spec: ONLY cookies outside every container (gallery-dl's + // default; yt-dlp would read them all). + assert.deepEqual(await values(), ["default"]); assert.deepEqual(await values("none"), ["default"]); - await assert.rejects(values("Shopping"), /No Firefox container named "Shopping"/); + assert.deepEqual(await values("all"), ["default", "personal", "shopping", "work", "work-fpd"]); + // By name (`name`), by `l10nID` (userContext<X>.label) and by `l10nId` + // (user-context-<x>) — case-sensitive, as gallery-dl matches. Container 2 does + // not pick up container 12. + assert.deepEqual(await values("Work"), ["work", "work-fpd"]); + assert.deepEqual(await values("Personal"), ["personal"]); + assert.deepEqual(await values("shopping"), ["shopping"]); + await assert.rejects(values("personal"), /No Firefox container named "personal"/); + await assert.rejects(values("Banking"), /No Firefox container named "Banking"/); +}); + +test("the store read has two views: with the WAL (Firefox now) and the main file alone (gallery-dl's)", async () => { + const dir = fresh("profile-views"); + const { file, db } = await writeFirefoxCookieStore(dir, [ + { host: ".x.com", name: "guest_id", value: "g" }, + ]); + // The schema and guest_id are in the main file; the login lands in the WAL + // of the still-open store, as a fresh login does in a running Firefox. + assert.equal(db, undefined); + const live = await writeFirefoxCookieStore(fresh("profile-views-wal"), [], { keepOpen: true }); + try { + insertFixtureCookies(live.db!, [{ host: ".x.com", name: "auth_token", value: "fresh" }]); + const read = await readFirefoxXStore(live.file, { tmpRoot: TMP }); + assert.deepEqual(read.cookies.map((c) => c.value), ["fresh"]); + assert.deepEqual(read.mainFile, []); + } finally { + live.db?.close(); + } + const settled = await readFirefoxXStore(file, { tmpRoot: TMP }); + assert.deepEqual(settled.cookies.map((c) => c.name), ["guest_id"]); + assert.deepEqual(settled.mainFile.map((c) => c.name), ["guest_id"]); +}); + +test("a login counts on x.com only; twitter.com's auth_token is the old domain's", () => { + assert.equal(isXComAuth({ name: "auth_token", domain: ".x.com" }), true); + assert.equal(isXComAuth({ name: "auth_token", domain: "x.com" }), true); + assert.equal(isXComAuth({ name: "auth_token", domain: "api.x.com" }), true); + assert.equal(isXComAuth({ name: "auth_token", domain: ".twitter.com" }), false); + assert.equal(isXComAuth({ name: "auth_token", domain: ".notx.com" }), false); + assert.equal(isXComAuth({ name: "ct0", domain: ".x.com" }), false); + assert.equal(isOldDomainAuth({ name: "auth_token", domain: ".twitter.com" }), true); + assert.equal(isOldDomainAuth({ name: "auth_token", domain: ".x.com" }), false); }); test("Firefox's expiry reads as seconds, or as milliseconds when too large for seconds", () => { @@ -208,7 +258,58 @@ test("status — browser source by default: the login is visible, with when the assert.equal(s.label, "Browser login (firefox)"); assert.equal(s.authTokenVisible, true); assert.equal(s.lastSeenAt, "2026-10-01T08:30:00.000Z"); - assert.match(s.summary, /^An X login is visible in firefox \(its auth_token last used 2026-10-01 08:30:00 UTC\)\.$/); + assert.equal(s.walOnly, false); + assert.equal(s.oldDomainCookie, false); + assert.equal( + s.summary, + "An X login is visible in firefox, outside its containers (where gallery-dl reads); " + + "its auth_token was last used 2026-10-01 08:30:00 UTC.", + ); +}); + +test("status — a login only in Firefox's write-ahead log says gallery-dl will not see it yet", async () => { + const home = fresh("home-wal"); + const live = await writeFirefoxCookieStore(path.join(home, ".mozilla", "firefox", "p.default"), X_LOGIN, { + keepOpen: true, + }); + try { + const s = await readXLoginStatus(pathsFor(fresh("transcripts")), { cookiesFromBrowser: "firefox" }, { home, tmpRoot: TMP }); + assert.equal(s.authTokenVisible, true); + assert.equal(s.walOnly, true); + assert.match( + s.summary, + /Seen in Firefox's write-ahead log only; gallery-dl will see it after Firefox checkpoints \(closing Firefox does it\)\.$/, + ); + } finally { + live.db?.close(); + } +}); + +test("status — twitter.com's auth_token is reported as the old domain's, never counted", async () => { + const home = fresh("home-olddomain"); + await writeFirefoxCookieStore(path.join(home, ".mozilla", "firefox", "p.default"), [ + { host: ".twitter.com", name: "auth_token", value: "old" }, + ]); + const s = await readXLoginStatus(pathsFor(fresh("transcripts")), { cookiesFromBrowser: "firefox" }, { home, tmpRoot: TMP }); + assert.equal(s.authTokenVisible, false); + assert.equal(s.oldDomainCookie, true); + assert.match(s.summary, /^No X login in firefox, outside its containers \(where gallery-dl reads\): no auth_token cookie for x\.com\./); + assert.match(s.summary, /An old-domain cookie is present too \(an auth_token for twitter\.com\); gallery-dl does not use it\.$/); +}); + +test("status — a login only inside a container is not counted, and the Check says where it is", async () => { + const home = fresh("home-container"); + await writeFirefoxCookieStore(path.join(home, ".mozilla", "firefox", "p.default"), [ + { host: ".x.com", name: "auth_token", value: "in-a-container", originAttributes: "^userContextId=3" }, + ]); + const paths = pathsFor(fresh("transcripts")); + const s = await readXLoginStatus(paths, { cookiesFromBrowser: "firefox" }, { home, tmpRoot: TMP }); + assert.equal(s.authTokenVisible, false); + assert.equal(s.otherContainerLogin, true); + assert.match(s.summary, /One is in another Firefox container; gallery-dl reads it only when cookiesFromBrowser names that container \(firefox::<name>\) or firefox::all\./); + const all = await readXLoginStatus(paths, { cookiesFromBrowser: "firefox::all" }, { home, tmpRoot: TMP }); + assert.equal(all.authTokenVisible, true); + assert.match(all.summary, /^An X login is visible in firefox, in any container/); }); test("status — browser source with no X login in the browser", async () => { diff --git a/common/social/xBrowserLogin.ts b/common/social/xBrowserLogin.ts @@ -17,11 +17,13 @@ // written: `cookies.sqlite` and its `-wal` (Firefox writes ahead, so a fresh // login may live only in the WAL) are copied into a private temp dir, read // there, and the dir is removed. Only X's own rows are selected, so no other -// site's cookies leave SQLite. The same discovery gallery-dl and yt-dlp use -// picks the store: a profile path or name from the spec, else the most -// recently modified `cookies.sqlite` under Firefox's profile roots. +// site's cookies leave SQLite. The store is found much as gallery-dl finds it +// (a profile path or name from the spec, else the most recently modified +// `cookies.sqlite` under Firefox's profile roots) — not exactly: see +// findFirefoxCookieDb. The CONTAINER is read exactly as gallery-dl reads it +// (firefoxContainerScope), and so is the domain a login counts on (x.com). -import { copyFile, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; @@ -59,9 +61,11 @@ export type XBrowserCookie = { // --- Finding the store ------------------------------------------------------- -// Where Firefox keeps its profiles, in the order gallery-dl and yt-dlp search -// them: the XDG location newer releases use, the classic one, Snap, Flatpak, -// macOS. +// Where Firefox keeps its profiles: the XDG location newer releases use, the +// classic one, Snap, Flatpak, macOS. gallery-dl's list differs slightly (it +// honours $XDG_CONFIG_HOME and has a second Flatpak root), and for a profile +// NAME it takes the first root holding `<name>/cookies.sqlite` where this takes +// the newest; on a stock Linux Firefox the two pick the same store. export function firefoxProfileRoots(home: string): string[] { return [ path.join(home, ".config", "mozilla", "firefox"), @@ -125,17 +129,32 @@ export async function findFirefoxCookieDb( // --- Reading it ---------------------------------------------------------------- -// A Firefox container (`::NAME` in the spec) is a userContextId, named in -// containers.json beside cookies.sqlite. "none" means cookies outside every -// container. Mirrors yt-dlp's lookup. -async function containerFilter( +// WHICH CONTAINER, as gallery-dl reads it (gallery-dl/cookies.py, +// `_firefox_cookies_database`; 1.32.9) — gallery-dl is the fetcher this login +// feeds, so the readers here see what it sees: +// no `::CONTAINER`, or `::none` — only cookies that belong to no container +// (gallery-dl's default; yt-dlp's differs); +// `::all` — every container, no filter; +// `::NAME` — the container containers.json names so, +// matched as gallery-dl matches it (its +// `name`, else its `l10nId`, else `l10nID`; +// case-sensitive). +export type ContainerScope = { kind: "none" } | { kind: "all" } | { kind: "id"; id: number }; + +function extr(text: string, begin: string, end: string): string { + const i = text.indexOf(begin); + if (i < 0) return ""; + const from = i + begin.length; + const j = text.indexOf(end, from); + return j < 0 ? "" : text.slice(from, j); +} + +export async function firefoxContainerScope( dbFile: string, container: string | undefined, -): Promise<{ where: string; params: unknown[] }> { - if (!container) return { where: "", params: [] }; - if (container.toLowerCase() === "none") { - return { where: "NOT INSTR(originAttributes, 'userContextId=')", params: [] }; - } +): Promise<ContainerScope> { + if (!container || container === "none") return { kind: "none" }; + if (container === "all") return { kind: "all" }; let identities: Array<Record<string, unknown>> = []; try { const raw = JSON.parse( @@ -145,26 +164,58 @@ async function containerFilter( } catch { /* no containers.json: no container can match */ } - const want = container.toLowerCase(); + const str = (v: unknown) => (typeof v === "string" && v ? v : undefined); const hit = identities.find((c) => { - const name = typeof c.name === "string" ? c.name.toLowerCase() : undefined; - const l10n = typeof c.l10nID === "string" ? c.l10nID.toLowerCase() : undefined; - return name === want || l10n === `usercontext${want}.label`; + const name = str(c.name); + if (name) return name === container; + const l10nId = str(c.l10nId); + if (l10nId) { + return ( + (l10nId.startsWith("user-context-") && l10nId.slice(13) === container) || + l10nId.slice(l10nId.lastIndexOf("-") + 1) === container + ); + } + const l10nID = str(c.l10nID); + return l10nID ? extr(l10nID, "userContext", ".label") === container : false; }); if (typeof hit?.userContextId !== "number") { throw new Error(`No Firefox container named "${container}" in containers.json`); } - const id = hit.userContextId; - return { - where: "(originAttributes LIKE ? OR originAttributes LIKE ?)", - params: [`%userContextId=${id}`, `%userContextId=${id}&%`], - }; + return { kind: "id", id: hit.userContextId }; +} + +// gallery-dl's SQL, in JS: `NOT INSTR(originAttributes,'userContextId=')` for +// no container, `LIKE '%userContextId=<id>' OR LIKE '%userContextId=<id>&%'` +// for one. +export function inContainerScope(originAttributes: string, scope: ContainerScope): boolean { + if (scope.kind === "all") return true; + if (scope.kind === "none") return !originAttributes.includes("userContextId="); + const tag = `userContextId=${scope.id}`; + return originAttributes.endsWith(tag) || originAttributes.includes(`${tag}&`); } const X_HOSTS = "(host = 'x.com' OR host = '.x.com' OR host LIKE '%.x.com' OR " + "host = 'twitter.com' OR host = '.twitter.com' OR host LIKE '%.twitter.com')"; +// X's own domain. gallery-dl's twitter extractor looks its `auth_token` up on +// `.x.com` (extractor/twitter.py), so a login counts only there; twitter.com's +// is the old domain's, read but not counted. +export function isXComHost(host: string): boolean { + const h = host.replace(/^\./, "").toLowerCase(); + return h === "x.com" || h.endsWith(".x.com"); +} + +export function isXComAuth(c: { name: string; domain: string }): boolean { + return c.name === X_AUTH_COOKIE && isXComHost(c.domain); +} + +export function isOldDomainAuth(c: { name: string; domain: string }): boolean { + if (c.name !== X_AUTH_COOKIE) return false; + const h = c.domain.replace(/^\./, "").toLowerCase(); + return h === "twitter.com" || h.endsWith(".twitter.com"); +} + function num(v: unknown): number | undefined { if (typeof v === "number" && Number.isFinite(v)) return v; if (typeof v === "bigint") return Number(v); @@ -195,59 +246,117 @@ function firefoxSameSite(v: unknown, secure: boolean): XBrowserCookie["sameSite" return undefined; } -export async function readFirefoxXCookies( +type StoreRow = { cookie: XBrowserCookie; originAttributes: string }; + +function readRows( + DatabaseSync: Awaited<ReturnType<typeof loadSqlite>>["DatabaseSync"], + file: string, + nowS: number, +): StoreRow[] { + const db = new DatabaseSync(file); + try { + const rows = db.prepare(`SELECT * FROM moz_cookies WHERE ${X_HOSTS}`).all() as Array< + Record<string, unknown> + >; + const out: StoreRow[] = []; + for (const r of rows) { + if (typeof r.name !== "string" || typeof r.host !== "string") continue; + const secure = num(r.isSecure) === 1; + const expires = firefoxExpirySeconds(r.expiry); + if (expires > 0 && expires < nowS) continue; + const cookie: XBrowserCookie = { + name: r.name, + value: typeof r.value === "string" ? r.value : String(r.value ?? ""), + domain: r.host, + path: typeof r.path === "string" && r.path ? r.path : "/", + expires, + httpOnly: num(r.isHttpOnly) === 1, + secure, + }; + const sameSite = firefoxSameSite(r.sameSite, secure); + if (sameSite) cookie.sameSite = sameSite; + const last = firefoxTimeMs(r.lastAccessed); + if (last) cookie.lastAccessedMs = last; + out.push({ + cookie, + originAttributes: typeof r.originAttributes === "string" ? r.originAttributes : "", + }); + } + return out; + } finally { + db.close(); + } +} + +// ONE READ OF THE STORE, two views of X's rows: +// `cookies` — with the WAL: what Firefox holds now. The Playwright +// fallback uses these (a fresh login is in the WAL until +// Firefox checkpoints). +// `mainFile` — the main file alone: what gallery-dl sees. It opens +// cookies.sqlite `mode=ro&immutable=1`, which ignores the WAL +// (and its fallback copies the main file only). +// Both in the container scope; `otherScopeAuth` says whether an x.com +// auth_token exists OUTSIDE it (in a container the spec did not name). +export type FirefoxXRead = { + cookies: XBrowserCookie[]; + mainFile: XBrowserCookie[]; + otherScopeAuth: boolean; +}; + +export async function readFirefoxXStore( dbFile: string, opts: { container?: string; tmpRoot?: string; now?: number } = {}, -): Promise<XBrowserCookie[]> { +): Promise<FirefoxXRead> { const { DatabaseSync } = await loadSqlite(); - const filter = await containerFilter(dbFile, opts.container); - // mkdtemp makes the dir 0700: the copy is readable by this user only. + const scope = await firefoxContainerScope(dbFile, opts.container); + // mkdtemp makes the dir 0700: the copies are readable by this user only. const tmp = await mkdtemp(path.join(opts.tmpRoot ?? os.tmpdir(), "archilyzer-xcookies-")); try { - const copy = path.join(tmp, COOKIE_DB); - await copyFile(dbFile, copy); - await copyFile(`${dbFile}-wal`, `${copy}-wal`).catch((err: NodeJS.ErrnoException) => { + // The db and its WAL back to back (the shortest window for a checkpoint + // between them), then a second copy of the db alone, taken from the first. + const withWal = path.join(tmp, "wal", COOKIE_DB); + const mainOnly = path.join(tmp, "main", COOKIE_DB); + await mkdir(path.dirname(withWal)); + await mkdir(path.dirname(mainOnly)); + await copyFile(dbFile, withWal); + await copyFile(`${dbFile}-wal`, `${withWal}-wal`).catch((err: NodeJS.ErrnoException) => { if (err.code !== "ENOENT") throw err; }); - const db = new DatabaseSync(copy); - try { - const where = [X_HOSTS, filter.where].filter(Boolean).join(" AND "); - const rows = db.prepare(`SELECT * FROM moz_cookies WHERE ${where}`).all(...filter.params) as Array< - Record<string, unknown> - >; - const now = (opts.now ?? Date.now()) / 1000; - const out: XBrowserCookie[] = []; - for (const r of rows) { - if (typeof r.name !== "string" || typeof r.host !== "string") continue; - const secure = num(r.isSecure) === 1; - const expires = firefoxExpirySeconds(r.expiry); - if (expires > 0 && expires < now) continue; - const cookie: XBrowserCookie = { - name: r.name, - value: typeof r.value === "string" ? r.value : String(r.value ?? ""), - domain: r.host, - path: typeof r.path === "string" && r.path ? r.path : "/", - expires, - httpOnly: num(r.isHttpOnly) === 1, - secure, - }; - const sameSite = firefoxSameSite(r.sameSite, secure); - if (sameSite) cookie.sameSite = sameSite; - const last = firefoxTimeMs(r.lastAccessed); - if (last) cookie.lastAccessedMs = last; - out.push(cookie); - } - return out; - } finally { - db.close(); - } + await copyFile(withWal, mainOnly); + const nowS = (opts.now ?? Date.now()) / 1000; + const walRows = readRows(DatabaseSync, withWal, nowS); + const mainRows = readRows(DatabaseSync, mainOnly, nowS); + const scoped = (rows: StoreRow[]) => + rows.filter((r) => inContainerScope(r.originAttributes, scope)).map((r) => r.cookie); + return { + cookies: scoped(walRows), + mainFile: scoped(mainRows), + otherScopeAuth: walRows.some( + (r) => !inContainerScope(r.originAttributes, scope) && isXComAuth(r.cookie), + ), + }; } finally { await rm(tmp, { recursive: true, force: true }); } } +// X's cookies as Firefox holds them now (the WAL included), in the spec's +// container scope. +export async function readFirefoxXCookies( + dbFile: string, + opts: { container?: string; tmpRoot?: string; now?: number } = {}, +): Promise<XBrowserCookie[]> { + return (await readFirefoxXStore(dbFile, opts)).cookies; +} + export type BrowserCookieRead = - | { ok: true; cookies: XBrowserCookie[]; browser: string; store: string } + | ({ + ok: true; + browser: string; + store: string; + // The spec's container, as given (undefined = outside every container). + container?: string; + } & FirefoxXRead) | { ok: false; // no-spec: cookiesFromBrowser is empty. unsupported: a browser this repo @@ -296,12 +405,18 @@ export async function xCookiesFromBrowser( }; } try { - const cookies = await readFirefoxXCookies(found.file, { + const read = await readFirefoxXStore(found.file, { container: parsed.container, tmpRoot: opts.tmpRoot, now: opts.now, }); - return { ok: true, cookies, browser: parsed.browser, store: found.file }; + return { + ok: true, + browser: parsed.browser, + store: found.file, + ...(parsed.container ? { container: parsed.container } : {}), + ...read, + }; } catch (err) { return { ok: false, @@ -344,8 +459,18 @@ export type XLoginStatus = XCookieSourceView & { // last sent its auth_token (the store's lastAccessed); for the profile, when // the jar was last exported. lastSeenAt?: string; + // Browser source only. True when the x.com auth_token is in Firefox's + // write-ahead log alone: gallery-dl, which reads the main file, does not see + // it until Firefox checkpoints. + walOnly?: boolean; + // Browser source only. An auth_token for twitter.com (the old domain) is + // present; it is reported, never counted — gallery-dl looks on x.com. + oldDomainCookie?: boolean; + // Browser source only. An x.com auth_token sits in a Firefox container the + // spec does not read. + otherContainerLogin?: boolean; checkedAt: string; - // One sentence for the Settings section. + // A sentence or three for the Settings section. summary: string; }; @@ -388,23 +513,49 @@ export async function readXLoginStatus( if (!read.ok) { return { ...base, authTokenVisible: null, summary: read.message }; } - const auth = read.cookies - .filter((c) => c.name === X_AUTH_COOKIE) - .sort((a, b) => (b.lastAccessedMs ?? 0) - (a.lastAccessedMs ?? 0))[0]; + // Where the read looked, as gallery-dl looks (see firefoxContainerScope). + const scope = !read.container || read.container === "none" + ? `${read.browser}, outside its containers (where gallery-dl reads)` + : read.container === "all" + ? `${read.browser}, in any container` + : `${read.browser}'s container "${read.container}"`; + const newest = (list: XBrowserCookie[]) => + list.filter(isXComAuth).sort((a, b) => (b.lastAccessedMs ?? 0) - (a.lastAccessedMs ?? 0))[0]; + const auth = newest(read.cookies); + const oldDomainCookie = read.cookies.some(isOldDomainAuth); + const oldDomainLine = oldDomainCookie + ? " An old-domain cookie is present too (an auth_token for twitter.com); gallery-dl does not use it." + : ""; if (!auth) { return { ...base, authTokenVisible: false, - summary: `No X login in ${read.browser}: no auth_token cookie for x.com. Log in to x.com in that browser.`, + oldDomainCookie, + otherContainerLogin: read.otherScopeAuth, + summary: + `No X login in ${scope}: no auth_token cookie for x.com.` + + (read.otherScopeAuth + ? ` One is in another Firefox container; gallery-dl reads it only when cookiesFromBrowser ` + + `names that container (${read.browser}::<name>) or ${read.browser}::all.` + : " Log in to x.com in that browser.") + + oldDomainLine, }; } const lastSeenAt = auth.lastAccessedMs ? new Date(auth.lastAccessedMs).toISOString() : undefined; + const walOnly = !read.mainFile.some(isXComAuth); return { ...base, authTokenVisible: true, lastSeenAt, + walOnly, + oldDomainCookie, + otherContainerLogin: read.otherScopeAuth, summary: - `An X login is visible in ${read.browser}` + - (lastSeenAt ? ` (its auth_token last used ${when(lastSeenAt)}).` : "."), + `An X login is visible in ${scope}` + + (lastSeenAt ? `; its auth_token was last used ${when(lastSeenAt)}.` : ".") + + (walOnly + ? " Seen in Firefox's write-ahead log only; gallery-dl will see it after Firefox checkpoints (closing Firefox does it)." + : "") + + oldDomainLine, }; } diff --git a/common/social/xPlaywrightFetcher.ts b/common/social/xPlaywrightFetcher.ts @@ -40,7 +40,7 @@ import { type BrowserLike, } from "./playwrightRuntime"; import { buildXBrowserLaunchOptions } from "./xBrowser"; -import { X_AUTH_COOKIE, xCookiesFromBrowser } from "./xBrowserLogin"; +import { isXComAuth, xCookiesFromBrowser } from "./xBrowserLogin"; import { registerSocialFetcher, type PostFetchInput, @@ -170,10 +170,10 @@ export const xPlaywrightFetcher: SocialFetcher = { if (!read.ok) { throw new Error(`The X login source is the browser, and it cannot be read here: ${read.message}`); } - const hasAuth = read.cookies.some((c) => c.name === X_AUTH_COOKIE); + const hasAuth = read.cookies.some(isXComAuth); onLog?.( `Launching a headless browser with ${read.cookies.length} X cookie(s) from ${read.browser} ` + - `(fallback path)${hasAuth ? "" : " — WARNING: no auth_token, the browser is not logged in to X"}.`, + `(fallback path)${hasAuth ? "" : " — WARNING: no auth_token for x.com, the browser is not logged in to X"}.`, ); const { chromium } = await importPlaywright(); browser = await chromium.launch( diff --git a/common/social/xSessionBroker.test.ts b/common/social/xSessionBroker.test.ts @@ -0,0 +1,157 @@ +// The headless refresh never replaces a logged-in jar with one without a login +// (release 16 slice XL, review L4). Playwright is replaced by a fake chromium +// that answers per executable: nothing here launches a browser or loads x.com. +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test social/xSessionBroker.test.ts + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import type { ChromiumLike } from "./playwrightRuntime"; +import { writeXBrowserRecord } from "./xBrowser"; +import { refreshXCookies, xCookieFile, xProfileDir } from "./xSessionBroker"; + +const TMP = mkdtempSync(path.join(os.tmpdir(), "xl-broker-")); +after(() => rm(TMP, { recursive: true, force: true })); + +// A stand-in for the system browser the profile records: it only has to be an +// executable file. +const SYSTEM_EXE = path.join(TMP, "chromium"); +writeFileSync(SYSTEM_EXE, "#!/bin/sh\nexit 0\n"); +chmodSync(SYSTEM_EXE, 0o755); + +type Cookie = { name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }; +const cookie = (name: string, value: string): Cookie => ({ + name, + value, + domain: ".x.com", + path: "/", + expires: 1_900_000_000, + httpOnly: true, + secure: true, +}); + +// Answers launchPersistentContext by executable: "bundled" when the options +// name none. An Error answer throws, as a launch that cannot open the profile. +function fakeChromium(answers: Record<string, Cookie[] | Error>) { + const launches: string[] = []; + const chromium = { + launch: async () => { + throw new Error("not used"); + }, + launchPersistentContext: async (_dir: string, opts: Record<string, unknown>) => { + const who = typeof opts.executablePath === "string" ? opts.executablePath : "bundled"; + launches.push(who); + const answer = answers[who]; + if (answer instanceof Error) throw answer; + const page = { + goto: async () => undefined, + waitForSelector: async () => undefined, + waitForTimeout: async () => undefined, + evaluate: async () => undefined, + on: () => undefined, + }; + return { + pages: () => [page], + newPage: async () => page, + cookies: async () => answer ?? [], + addCookies: async () => undefined, + close: async () => undefined, + on: () => undefined, + }; + }, + } as unknown as ChromiumLike; + return { chromium, launches }; +} + +let n = 0; +async function corpus(opts: { jar?: "logged-in" | "logged-out"; recorded?: boolean }) { + const paths = { transcriptsDir: path.join(TMP, `corpus-${++n}`) } as Paths; + await mkdir(xProfileDir(paths), { recursive: true }); + if (opts.recorded) { + await writeXBrowserRecord( + xProfileDir(paths), + { kind: "system", executablePath: SYSTEM_EXE, from: "path" }, + "Chromium 153", + ); + } + if (opts.jar) { + await writeFile( + xCookieFile(paths), + "# Netscape HTTP Cookie File\n" + + (opts.jar === "logged-in" + ? ".x.com\tTRUE\t/\tTRUE\t1900000000\tauth_token\tOLD-LOGIN\n" + : ".x.com\tTRUE\t/\tTRUE\t1900000000\tguest_id\tg\n"), + ); + } + return paths; +} + +const jarText = (paths: Paths) => readFile(xCookieFile(paths), "utf8"); + +test("the bundled read has the login: written, the recorded browser never launched", async () => { + const paths = await corpus({ jar: "logged-in", recorded: true }); + const { chromium, launches } = fakeChromium({ bundled: [cookie("auth_token", "FRESH")] }); + const status = await refreshXCookies(paths, { chromium }); + assert.deepEqual(launches, ["bundled"]); + assert.equal(status.looksAuthenticated, true); + assert.match(await jarText(paths), /auth_token\tFRESH/); +}); + +test("an EMPTY bundled read of a logged-in profile is read again with the recorded browser", async () => { + const paths = await corpus({ jar: "logged-in", recorded: true }); + const { chromium, launches } = fakeChromium({ + bundled: [], + [SYSTEM_EXE]: [cookie("auth_token", "FROM-SYSTEM"), cookie("ct0", "c")], + }); + const lines: string[] = []; + await refreshXCookies(paths, { chromium, onLog: (l) => lines.push(l) }); + assert.deepEqual(launches, ["bundled", SYSTEM_EXE]); + assert.match(await jarText(paths), /auth_token\tFROM-SYSTEM/); + assert.ok(lines.some((l) => /read no X login from a profile whose exported jar holds one/.test(l))); +}); + +test("no read finds the login: the logged-in jar is KEPT and the refresh refuses", async () => { + const paths = await corpus({ jar: "logged-in", recorded: true }); + const before = await jarText(paths); + const { chromium, launches } = fakeChromium({ bundled: [cookie("guest_id", "g")], [SYSTEM_EXE]: [] }); + await assert.rejects( + refreshXCookies(paths, { chromium }), + /The profile gave no X login \(no auth_token\), so the logged-in cookie jar exported .* was kept, not replaced\./, + ); + assert.deepEqual(launches, ["bundled", SYSTEM_EXE]); + assert.equal(await jarText(paths), before); +}); + +test("no record to fall back on: the logged-in jar is kept all the same", async () => { + const paths = await corpus({ jar: "logged-in" }); + const before = await jarText(paths); + const { chromium, launches } = fakeChromium({ bundled: [] }); + await assert.rejects(refreshXCookies(paths, { chromium }), /was kept, not replaced/); + assert.deepEqual(launches, ["bundled"]); + assert.equal(await jarText(paths), before); +}); + +test("a jar without a login is replaced as before (nothing to protect)", async () => { + const paths = await corpus({ jar: "logged-out", recorded: true }); + const { chromium, launches } = fakeChromium({ bundled: [cookie("guest_id", "new")] }); + const status = await refreshXCookies(paths, { chromium }); + assert.deepEqual(launches, ["bundled"]); + assert.equal(status.looksAuthenticated, false); + assert.match(await jarText(paths), /guest_id\tnew/); +}); + +test("the bundled build cannot open the profile: the recorded browser reads it", async () => { + const paths = await corpus({ jar: "logged-in", recorded: true }); + const { chromium, launches } = fakeChromium({ + bundled: new Error("profile is from a newer version"), + [SYSTEM_EXE]: [cookie("auth_token", "VIA-RECORD")], + }); + await refreshXCookies(paths, { chromium }); + assert.deepEqual(launches, ["bundled", SYSTEM_EXE]); + assert.match(await jarText(paths), /auth_token\tVIA-RECORD/); +}); diff --git a/common/social/xSessionBroker.ts b/common/social/xSessionBroker.ts @@ -29,7 +29,11 @@ import { mkdir, readFile, rm, stat } from "node:fs/promises"; import { execa } from "execa"; import { writeFileAtomic } from "../lib/jsonFile-server"; import type { Paths } from "../lib/paths"; -import { importPlaywright, type BrowserContextLike } from "./playwrightRuntime"; +import { + importPlaywright, + type BrowserContextLike, + type ChromiumLike, +} from "./playwrightRuntime"; import { buildXBrowserLaunchOptions, describeXBrowser, @@ -233,24 +237,37 @@ export async function connectXAccount( return { ...(await readXSessionStatus(paths)), browser: label }; } -// THE PROFILE, OPENED HEADLESS — the refresh below and the Playwright fallback -// fetcher (profile source) both come through here. The bundled build first -// (it never logs in, and its headless shell needs no display), without the -// automation signals; when it cannot open the profile and the profile records -// a system executable, that one, headless. +// THE PROFILE, OPENED HEADLESS — the Playwright fallback fetcher (profile +// source) comes through here, and the refresh below reads the profile the same +// way. The bundled build first (it never logs in, and its headless shell needs +// no display), without the automation signals; when it cannot open the profile +// and the profile records a system executable, that one, headless. +// +// `chromium` is injectable so the choice of browser is testable without one. +type ProfileLaunchOpts = { onLog?: (line: string) => void; chromium?: ChromiumLike }; + +const BUNDLED: XBrowserChoice = { kind: "bundled" }; + +function openProfile( + chromium: ChromiumLike, + profileDir: string, + browser: XBrowserChoice, +): Promise<BrowserContextLike> { + return chromium.launchPersistentContext( + profileDir, + buildXBrowserLaunchOptions({ browser, headless: true }), + ); +} + export async function launchXProfile( paths: Paths, - opts: { onLog?: (line: string) => void } = {}, + opts: ProfileLaunchOpts = {}, ): Promise<BrowserContextLike> { const log = opts.onLog ?? (() => {}); const profileDir = xProfileDir(paths); - const { chromium } = await importPlaywright(); - const bundled: XBrowserChoice = { kind: "bundled" }; + const chromium = opts.chromium ?? (await importPlaywright()).chromium; try { - const context = await chromium.launchPersistentContext( - profileDir, - buildXBrowserLaunchOptions({ browser: bundled, headless: true }), - ); + const context = await openProfile(chromium, profileDir, BUNDLED); log("Opened the X session profile with Playwright's bundled Chromium (headless)."); return context; } catch (err) { @@ -261,18 +278,48 @@ export async function launchXProfile( `Playwright's bundled Chromium could not open the profile (${firstLine(err)}); ` + `using ${describeXBrowser(recorded, record?.version)}, headless.`, ); - return chromium.launchPersistentContext( - profileDir, - buildXBrowserLaunchOptions({ browser: recorded, headless: true }), - ); + return openProfile(chromium, profileDir, recorded); + } +} + +// One headless pass: open the profile, touch x.com/home (X rotates/renews the +// session cookies on a visit, which is the whole point of keeping a live +// profile), read X's cookies, close. +async function exportProfileCookies( + chromium: ChromiumLike, + profileDir: string, + browser: XBrowserChoice, + log: (line: string) => void, +): Promise<BrowserCookie[]> { + const context = await openProfile(chromium, profileDir, browser); + try { + const page = context.pages()[0] ?? (await context.newPage()); + await page + .goto("https://x.com/home", { waitUntil: "domcontentloaded", timeout: 45_000 }) + .catch(() => { + log("Could not load x.com/home; exporting whatever the profile holds."); + }); + return ((await context.cookies()) as BrowserCookie[]).filter(isXCookie); + } finally { + await context.close().catch(() => {}); } } +const hasAuthCookie = (cookies: ReadonlyArray<BrowserCookie>) => + cookies.some((c) => c.name === AUTH_COOKIE); + // Re-export cookies from the stored profile WITHOUT any human interaction — // this is the call gallery-dl's cookie refresh actually uses. Runs headless. +// +// A LOGGED-IN JAR IS NEVER REPLACED BY ONE WITHOUT A LOGIN. A profile written +// by a newer system browser can open in the bundled build without an error and +// without its cookies (a store that build cannot read). So when the bundled +// read comes back with no auth_token while the jar holds one, the profile is +// read again with the browser that wrote it (the record); and when no read +// finds the login, the jar is kept and the refresh refuses, saying so. export async function refreshXCookies( paths: Paths, - opts: { onLog?: (line: string) => void } = {}, + opts: ProfileLaunchOpts = {}, ): Promise<XSessionStatus> { const log = opts.onLog ?? (() => {}); const profileDir = xProfileDir(paths); @@ -281,21 +328,41 @@ export async function refreshXCookies( "No X session profile yet — run the headed 'Connect X account' flow first.", ); } - const context = await launchXProfile(paths, { onLog: log }); + const chromium = opts.chromium ?? (await importPlaywright()).chromium; + const before = await readXSessionStatus(paths); + const record = await readXBrowserRecord(profileDir); + const recorded = recordedXBrowser(record); + const recordedLabel = recorded ? describeXBrowser(recorded, record?.version) : ""; + + let cookies: BrowserCookie[]; + let readWithRecorded = false; try { - // Touching the site lets X rotate/renew the session cookies before we read - // them, which is the whole point of keeping a live profile. - const page = context.pages()[0] ?? (await context.newPage()); - await page - .goto("https://x.com/home", { waitUntil: "domcontentloaded", timeout: 45_000 }) - .catch(() => { - log("Could not load x.com/home; exporting whatever the profile holds."); - }); - const cookies = (await context.cookies()) as BrowserCookie[]; - await writeCookieJar(paths, cookies.filter(isXCookie), log); - } finally { - await context.close().catch(() => {}); + cookies = await exportProfileCookies(chromium, profileDir, BUNDLED, log); + log("Read the X session profile with Playwright's bundled Chromium (headless)."); + } catch (err) { + if (!recorded) throw err; + log( + `Playwright's bundled Chromium could not open the profile (${firstLine(err)}); ` + + `using ${recordedLabel}, headless.`, + ); + cookies = await exportProfileCookies(chromium, profileDir, recorded, log); + readWithRecorded = true; + } + if (!hasAuthCookie(cookies) && before.looksAuthenticated && recorded && !readWithRecorded) { + log( + "Playwright's bundled Chromium read no X login from a profile whose exported jar holds " + + `one; reading it again with ${recordedLabel}, headless.`, + ); + cookies = await exportProfileCookies(chromium, profileDir, recorded, log); + } + if (!hasAuthCookie(cookies) && before.looksAuthenticated) { + throw new Error( + "The profile gave no X login (no auth_token), so the logged-in cookie jar" + + (before.cookiesUpdatedAt ? ` exported ${before.cookiesUpdatedAt}` : "") + + " was kept, not replaced. If the X session has ended, Connect again (or Forget session).", + ); } + await writeCookieJar(paths, cookies, log); return readXSessionStatus(paths); } diff --git a/editor/app/settings/components/XSessionSection.tsx b/editor/app/settings/components/XSessionSection.tsx @@ -174,7 +174,7 @@ export function XSessionSection({ aria-label="x login status" className={ "text-xs " + - (login.authTokenVisible === true + (login.authTokenVisible === true && !login.walOnly ? "text-success" : login.authTokenVisible === false ? "text-destructive" diff --git a/editor/e2e/x-session.spec.ts b/editor/e2e/x-session.spec.ts @@ -53,7 +53,8 @@ test("the login source select persists, and Check shows a status line", async ({ await page.getByLabel("check x login").click(); await expect(page.getByLabel("x login status")).toContainText( - "An X login is visible in firefox (its auth_token last used 2026-10-01 08:30:00 UTC).", + "An X login is visible in firefox, outside its containers (where gallery-dl reads); " + + "its auth_token was last used 2026-10-01 08:30:00 UTC.", ); // Choose the profile: stored, shown, and still chosen after a reload.