Skip to content

Instantly share code, notes, and snippets.

@anthonyrussano
Created April 5, 2026 05:30
Show Gist options
  • Select an option

  • Save anthonyrussano/07a8fe74e30759fd2a433d26889e7222 to your computer and use it in GitHub Desktop.

Select an option

Save anthonyrussano/07a8fe74e30759fd2a433d26889e7222 to your computer and use it in GitHub Desktop.
ft sync on Linux (Debian/Ubuntu) — extract Chrome/Brave cookies via GNOME Keyring to sync X/Twitter bookmarks with fieldtheory CLI

ft sync on Linux (Debian/Ubuntu)

fieldtheory (ft) is a CLI tool that syncs your X/Twitter bookmarks locally. Its built-in ft sync command reads cookies directly from Chrome — but that feature is macOS-only. On Linux you'll see:

Couldn't connect to your Chrome session.

This script is a drop-in workaround. It extracts the cookies from Chrome or Brave on Linux using the GNOME Secret Service (keyring), then calls fieldtheory's internal sync function directly.

Requirements

  • ft installed: npm install -g fieldtheory
  • Google Chrome or Brave, logged into x.com
  • Python 3 with dbus module (standard on Ubuntu/Debian GNOME desktops)
  • GNOME Keyring unlocked (it is if you're logged into your desktop session)

Install

# Download the script
curl -o ~/bin/ft-sync-linux.mjs https://gist.githubusercontent.com/anthonyrussano/ft-sync-linux/raw/ft-sync-linux.mjs

chmod +x ~/bin/ft-sync-linux.mjs

Or just save ft-sync-linux.mjs anywhere and run it with node.

Usage

# Incremental sync (default — only fetches new bookmarks)
node ~/bin/ft-sync-linux.mjs

# Full sync (re-fetches everything)
node ~/bin/ft-sync-linux.mjs --full

# Rebuild search index after syncing
ft index

The script tries Chrome first, then Brave. It picks up whichever has x.com cookies.

How it works

  1. Reads the Chrome/Brave cookie SQLite database using sql.js (bundled with ft — no sqlite3 binary needed)
  2. Retrieves the cookie encryption key from GNOME Keyring via Python dbus
  3. Decrypts the ct0 (CSRF token) and auth_token cookies — including Chrome 130+'s extra 32-byte SHA256 host prefix
  4. Calls syncBookmarksGraphQL directly from fieldtheory's internals with the extracted tokens

Troubleshooting

No X.com cookies found — Open Chrome/Brave, go to x.com, log in, then retry.

python3 -c ... fails — Make sure python3-dbus is installed:

sudo apt install python3-dbus

Wrong profile — The script uses the Default profile. If your x.com login is in a different Chrome profile, edit the cookieDb path in the script (e.g. Profile 1 instead of Default).

Keyring locked — This can happen in headless/SSH sessions. The script falls back to the peanuts key (Chrome's hardcoded Linux fallback), which works on older Chrome versions.

Note on ft updates

This script imports from fieldtheory's installed node_modules path directly. If ft is installed somewhere other than ~/n/lib/node_modules/fieldtheory, update the import paths at the top of the script.

To find your path:

node -e "require.resolve('fieldtheory')" 2>/dev/null || \
  ls $(npm root -g)/fieldtheory/dist/graphql-bookmarks.js 2>/dev/null
#!/usr/bin/env node
/**
* ft-sync-linux.mjs
* Extracts X/Twitter cookies from Chrome/Brave on Linux via GNOME Secret Service,
* then calls fieldtheory's syncBookmarksGraphQL directly.
*/
import { pbkdf2Sync, createDecipheriv } from 'node:crypto';
import { copyFileSync, existsSync, unlinkSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import { execSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
// ── Config ──────────────────────────────────────────────────────────────────
const BROWSERS = [
{
name: 'Chrome',
cookieDb: join(homedir(), '.config/google-chrome/Default/Cookies'),
secretApp: 'chrome',
},
{
name: 'Brave',
cookieDb: join(homedir(), '.config/BraveSoftware/Brave-Browser/Default/Cookies'),
secretApp: 'brave',
},
];
// ── Secret Service ───────────────────────────────────────────────────────────
function getKeyFromSecretService(appName) {
// Single-line Python — avoids whitespace issues with -c
const py = [
'import dbus,sys',
'bus=dbus.SessionBus()',
'svc=bus.get_object("org.freedesktop.secrets","/org/freedesktop/secrets")',
'iface=dbus.Interface(svc,"org.freedesktop.Secret.Service")',
'_,sess=iface.OpenSession("plain",dbus.String("",variant_level=1))',
'col=bus.get_object("org.freedesktop.secrets","/org/freedesktop/secrets/aliases/default")',
'ci=dbus.Interface(col,"org.freedesktop.Secret.Collection")',
`r=ci.SearchItems({"application":"${appName}"}) or ci.SearchItems({"xdg:schema":"${appName}_libsecret_os_crypt_password_v2"})`,
'item=bus.get_object("org.freedesktop.secrets",str(r[0]))',
'ii=dbus.Interface(item,"org.freedesktop.Secret.Item")',
's=ii.GetSecret(sess)',
'sys.stdout.write(bytes(s[2]).decode("utf-8"))',
].join(';');
try {
return execSync(`python3 -c '${py}'`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000,
}).trim();
} catch {
return null;
}
}
// ── Cookie decryption ────────────────────────────────────────────────────────
function deriveKey(password) {
// Linux: 1 iteration (macOS uses 1003)
return pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1');
}
function decryptValue(hexVal, key) {
if (!hexVal || hexVal.length === 0) return '';
const buf = Buffer.from(hexVal, 'hex');
if (buf[0] !== 0x76) return buf.toString('utf8'); // unencrypted
const ciphertext = buf.subarray(3); // strip v10/v11 prefix
const iv = Buffer.alloc(16, 0x20);
const decipher = createDecipheriv('aes-128-cbc', key, iv);
let dec = decipher.update(ciphertext);
dec = Buffer.concat([dec, decipher.final()]);
// Chrome 130+ prepends SHA256(host_key) — strip 32 bytes
if (dec.length > 32) {
const candidate = dec.subarray(32).toString('utf8').replace(/\0+$/, '').trim();
if (/^[\x21-\x7E]+$/.test(candidate)) return candidate;
}
return dec.toString('utf8').replace(/\0+$/, '').trim();
}
// ── SQL via sql.js ───────────────────────────────────────────────────────────
async function queryCookies(dbPath) {
const { default: initSqlJs } = await import(
'/home/anthony/n/lib/node_modules/fieldtheory/node_modules/sql.js/dist/sql-asm.js'
);
const SQL = await initSqlJs();
let buf;
try {
buf = readFileSync(dbPath);
} catch {
// DB locked — copy first
const tmp = join(tmpdir(), `ft-cookies-${randomUUID()}.db`);
copyFileSync(dbPath, tmp);
try { buf = readFileSync(tmp); } finally { try { unlinkSync(tmp); } catch {} }
}
const db = new SQL.Database(buf);
const results = db.exec(
`SELECT name, hex(encrypted_value) as ev, value FROM cookies
WHERE (host_key LIKE '%.x.com' OR host_key LIKE '%.twitter.com')
AND name IN ('ct0', 'auth_token')`
);
db.close();
if (!results.length) return [];
return results[0].values.map(([name, ev, value]) => ({ name, ev, value }));
}
// ── Main ─────────────────────────────────────────────────────────────────────
async function extractCookies(browser) {
if (!existsSync(browser.cookieDb)) return null;
const rows = await queryCookies(browser.cookieDb);
if (!rows.length) {
console.error(` No X.com cookies found in ${browser.name}.`);
return null;
}
const passwords = [];
const keyringPass = getKeyFromSecretService(browser.secretApp);
if (keyringPass) passwords.push(keyringPass);
passwords.push('peanuts');
for (const password of passwords) {
const key = deriveKey(password);
const decrypted = {};
let failed = false;
for (const row of rows) {
try {
const val = row.ev ? decryptValue(row.ev, key) : row.value;
if (!val || !/^[\x21-\x7E]+$/.test(val)) { failed = true; break; }
decrypted[row.name] = val;
} catch {
failed = true;
break;
}
}
if (!failed && decrypted.ct0 && decrypted.auth_token) {
return decrypted;
}
}
return null;
}
async function main() {
const args = process.argv.slice(2);
const full = args.includes('--full');
let cookies = null;
let browserName = null;
for (const browser of BROWSERS) {
console.log(` Trying ${browser.name}...`);
cookies = await extractCookies(browser);
if (cookies) { browserName = browser.name; break; }
}
if (!cookies) {
console.error(`
Could not extract X.com cookies from Chrome or Brave.
Make sure you are logged into x.com in one of those browsers,
and that GNOME Keyring is unlocked.
`);
process.exit(1);
}
console.log(` Got cookies from ${browserName}.`);
console.log(` Starting sync...\n`);
const { syncBookmarksGraphQL } = await import(
'/home/anthony/n/lib/node_modules/fieldtheory/dist/graphql-bookmarks.js'
);
const csrfToken = cookies.ct0;
const cookieHeader = `auth_token=${cookies.auth_token}; ct0=${cookies.ct0}`;
const result = await syncBookmarksGraphQL({
incremental: !full,
csrfToken,
cookieHeader,
onProgress: (status) => {
process.stderr.write(`\r Page ${status.page} — ${status.newAdded} new bookmarks...`);
if (status.done) process.stderr.write('\n');
},
});
console.log(`\n ✓ ${result.added} new bookmarks synced (${result.totalBookmarks} total)`);
console.log(` Stop reason: ${result.stopReason}`);
if (result.added > 0) {
console.log(` Run 'ft index' to rebuild the search index.`);
}
}
main().catch((err) => {
console.error(`\n Error: ${err.message}\n`);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment