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:
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.