social-autoposter 1.6.34 → 1.6.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -1116,11 +1116,49 @@ function doctor() {
1116
1116
  `print(c.execute("SELECT COUNT(*) FROM cookies WHERE host_key LIKE '%x.com' OR host_key LIKE '%twitter.com'").fetchone()[0])`,
1117
1117
  ], { encoding: 'utf8', timeout: 10000 });
1118
1118
  const n = parseInt((r.stdout || '0').trim(), 10);
1119
- if (n > 0) return { ok: true, detail: `${n} rows persisted (durable across Chrome restart)` };
1119
+ if (n > 0) return { ok: true, detail: `${n} rows persisted (Chrome's encrypted store)` };
1120
1120
  return {
1121
1121
  ok: false,
1122
1122
  detail: '0 x.com rows in SQLite',
1123
- fix: 'run setup_twitter_auth.py connect to import + auto-flush via #2 (1.6.34+)',
1123
+ fix: 'run setup_twitter_auth.py connect to import (durability is backed by the cookie mirror below)',
1124
+ };
1125
+ });
1126
+
1127
+ // Gap B durability layer (1.6.35+): the keychain-independent local cookie
1128
+ // mirror is what survives a re-locked keychain wiping Chrome's encrypted
1129
+ // Cookies DB on relaunch. Read it directly (plaintext 0600 JSON).
1130
+ const mirrorPath = path.join(HOME, '.claude', 'browser-profiles', 'browser-harness.x-cookies.json');
1131
+ const mirrorCount = () => {
1132
+ try {
1133
+ const data = JSON.parse(fs.readFileSync(mirrorPath, 'utf8'));
1134
+ return Array.isArray(data.cookies) ? data.cookies.length : 0;
1135
+ } catch { return -1; }
1136
+ };
1137
+
1138
+ add('X cookie mirror (durable across keychain re-lock)', () => {
1139
+ const n = mirrorCount();
1140
+ if (n > 0) return { ok: true, detail: `${n} cookies mirrored — cycle preflight auto-restores after a wipe` };
1141
+ if (n === 0) return { ok: false, detail: 'mirror file present but empty', fix: 'run setup_twitter_auth.py connect to (re)populate the mirror' };
1142
+ return { ok: false, detail: `no mirror at ${mirrorPath}`, fix: 'run setup_twitter_auth.py connect (1.6.35+) to create the durable cookie mirror' };
1143
+ });
1144
+
1145
+ add('macOS Keychain: login keychain auto-lock', () => {
1146
+ if (process.platform !== 'darwin') return { ok: true, detail: 'skipped (non-macOS)' };
1147
+ const kc = path.join(HOME, 'Library', 'Keychains', 'login.keychain-db');
1148
+ const r = spawnSync('security', ['show-keychain-info', kc], { encoding: 'utf8', timeout: 10000 });
1149
+ const out = `${r.stdout || ''}${r.stderr || ''}`;
1150
+ const m = out.match(/timeout=(\d+)s/);
1151
+ if (!m) return { ok: true, detail: 'no auto-lock timeout (encrypted cookie store stays decryptable)' };
1152
+ const secs = parseInt(m[1], 10);
1153
+ // Only a real problem if the keychain re-locks AND the mirror isn't there to
1154
+ // cover the resulting Cookies-DB wipe. With a populated mirror this is benign.
1155
+ if (mirrorCount() > 0) {
1156
+ return { ok: true, detail: `auto-locks after ${secs}s, but the cookie mirror covers the relaunch-wipe case` };
1157
+ }
1158
+ return {
1159
+ ok: false,
1160
+ detail: `auto-locks after ${secs}s — Chrome's encrypted cookie store can wipe on relaunch with no mirror to restore from`,
1161
+ fix: `run connect_x to create the cookie mirror, or disable auto-lock: security set-keychain-settings "${kc}"`,
1124
1162
  };
1125
1163
  });
1126
1164
 
@@ -128,6 +128,39 @@ def _log(msg: str) -> None:
128
128
 
129
129
  # --- Chrome lifecycle ---
130
130
 
131
+ # Hardcoded fallbacks used only the very first time a profile launches (before
132
+ # Chrome has written any window_placement to its Preferences).
133
+ DEFAULT_WINDOW_POS = "3042,-1032"
134
+ DEFAULT_WINDOW_SIZE = "1024,1013"
135
+
136
+
137
+ def _persisted_window_geometry() -> tuple[str | None, str | None]:
138
+ """Read the window position+size Chrome last persisted for THIS profile.
139
+
140
+ Chrome writes the live window bounds to <profile>/Default/Preferences ->
141
+ browser.window_placement (left/top/right/bottom, in screen coords) whenever
142
+ the user moves/resizes the window. By reading that back and feeding it into
143
+ the launch flags, a user's manual placement survives SIGKILL+relaunch
144
+ instead of snapping back to the hardcoded default. Returns ("X,Y", "W,H")
145
+ or (None, None) when nothing usable is persisted yet.
146
+ """
147
+ pref = PROFILE_DIR / "Default" / "Preferences"
148
+ try:
149
+ wp = json.loads(pref.read_text()).get("browser", {}).get("window_placement")
150
+ except (FileNotFoundError, ValueError, OSError):
151
+ return (None, None)
152
+ if not isinstance(wp, dict) or wp.get("maximized"):
153
+ return (None, None)
154
+ try:
155
+ left, top = int(wp["left"]), int(wp["top"])
156
+ width, height = int(wp["right"]) - left, int(wp["bottom"]) - top
157
+ except (KeyError, TypeError, ValueError):
158
+ return (None, None)
159
+ if width <= 0 or height <= 0:
160
+ return (f"{left},{top}", None)
161
+ return (f"{left},{top}", f"{width},{height}")
162
+
163
+
131
164
  def _port_open(port: int) -> bool:
132
165
  s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
133
166
  s.settimeout(0.5)
@@ -187,12 +220,24 @@ def _build_chrome_cmd() -> list[str]:
187
220
  cmd.append("--no-sandbox")
188
221
  cmd.append("--disable-dev-shm-usage")
189
222
 
190
- # 2026-05-13: persistent window placement on macOS multi-monitor setups.
223
+ # Persistent window placement on macOS multi-monitor setups.
191
224
  # Skip on headless / Linux where positioning is meaningless and the
192
225
  # off-screen values would just hide the window on a single-monitor setup.
226
+ # Position priority (2026-06-02):
227
+ # 1. BH_WINDOW_POS / BH_WINDOW_SIZE env (explicit hard override)
228
+ # 2. whatever Chrome last persisted for this profile (the user's own
229
+ # manually-dragged position) -> so user placement survives relaunch
230
+ # 3. hardcoded default (first-ever launch only)
231
+ # We still pass an explicit --window-position flag (rather than letting
232
+ # Chrome restore on its own), so SIGKILL+relaunch can't cascade/drift the
233
+ # window: we control the exact value, but that value now tracks the user's
234
+ # last placement instead of a fixed constant.
193
235
  if not HEADLESS and not _IS_LINUX:
194
- cmd.append(f"--window-position={os.environ.get('BH_WINDOW_POS', '3042,-1032')}")
195
- cmd.append(f"--window-size={os.environ.get('BH_WINDOW_SIZE', '1024,1013')}")
236
+ saved_pos, saved_size = _persisted_window_geometry()
237
+ win_pos = os.environ.get("BH_WINDOW_POS") or saved_pos or DEFAULT_WINDOW_POS
238
+ win_size = os.environ.get("BH_WINDOW_SIZE") or saved_size or DEFAULT_WINDOW_SIZE
239
+ cmd.append(f"--window-position={win_pos}")
240
+ cmd.append(f"--window-size={win_size}")
196
241
 
197
242
  # Open a real tab so CDP has something to attach to immediately.
198
243
  cmd.append("about:blank")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.34",
3
+ "version": "1.6.35",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -19,6 +19,8 @@
19
19
  "!scripts/backfill_real_clicks.py",
20
20
  "!scripts/historical_engagement.py",
21
21
  "!scripts/style_length_report.py",
22
+ "!scripts/install_lane_monitor.py",
23
+ "!scripts/li_discover_insert.py",
22
24
  "!scripts/_dm_record_sent.sh",
23
25
  "!scripts/send_batch_dms.sh",
24
26
  "!scripts/mint_podlog_subpage_*.py",
@@ -31,6 +31,14 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
31
  from http_api import api_get # noqa: E402
32
32
  from twitter_account import resolve_handle # noqa: E402
33
33
 
34
+ # Local 0600 cookie mirror — the keychain-independent restore source for
35
+ # persistent machines (Gap B). Tried before the server store. Stdlib-only;
36
+ # guarded so a path quirk never breaks the cycle preflight.
37
+ try:
38
+ import twitter_cookie_mirror # noqa: E402
39
+ except Exception:
40
+ twitter_cookie_mirror = None
41
+
34
42
  try:
35
43
  from websocket import create_connection
36
44
  except ImportError:
@@ -48,7 +56,12 @@ def _attach():
48
56
  new = json.load(urllib.request.urlopen(
49
57
  urllib.request.Request(f"{CDP}/json/new?about:blank", method="PUT"), timeout=10))
50
58
  page = new
51
- ws = create_connection(page["webSocketDebuggerUrl"], timeout=20)
59
+ # suppress_origin: Chrome 111+ enforces CDP WebSocket origin checking and
60
+ # rejects the handshake with 403 unless Chrome was launched with
61
+ # --remote-allow-origins. The harness Chrome (twitter-backend.sh) is launched
62
+ # without that flag, so we must suppress the Origin header (localhost CDP is
63
+ # already privileged), matching setup_twitter_auth.py / copy_browser_cookies.py.
64
+ ws = create_connection(page["webSocketDebuggerUrl"], timeout=20, suppress_origin=True)
52
65
  state = {"id": 0}
53
66
 
54
67
  def send(method, params=None):
@@ -96,12 +109,50 @@ def _logged_in(send):
96
109
  return _has_auth_cookie(send)
97
110
 
98
111
 
99
- def main():
100
- handle = resolve_handle()
101
- if not handle:
102
- print("restore_twitter_session: no handle configured; skipping", file=sys.stderr)
103
- return 0
112
+ def _inject(send, cookies) -> int:
113
+ """Inject CDP-shaped cookies via Network.setCookie. Returns accepted count."""
114
+ send("Network.enable")
115
+ ok_count = 0
116
+ for c in cookies:
117
+ params = {k: c[k] for k in (
118
+ "name", "value", "domain", "path", "secure", "httpOnly",
119
+ "sameSite", "expires") if k in c and c[k] is not None}
120
+ r = send("Network.setCookie", params)
121
+ if r.get("result", {}).get("success", True):
122
+ ok_count += 1
123
+ return ok_count
124
+
125
+
126
+ def _stored_cookies():
127
+ """Return (cookies, source). Tries the LOCAL mirror first — it's the only
128
+ durable source on a persistent machine, where the server store is skipped
129
+ for lack of a social_accounts row — then falls back to the server store
130
+ (the durable source on hourly-reseeded AppMaker VMs)."""
131
+ if twitter_cookie_mirror is not None:
132
+ try:
133
+ mirrored = twitter_cookie_mirror.load_cookies()
134
+ except Exception:
135
+ mirrored = []
136
+ if mirrored:
137
+ return mirrored, f"local mirror ({twitter_cookie_mirror.MIRROR_PATH.name})"
104
138
 
139
+ handle = None
140
+ try:
141
+ handle = resolve_handle()
142
+ except Exception:
143
+ handle = None
144
+ if handle:
145
+ try:
146
+ resp = api_get("/api/v1/twitter/session-cookies", query={"handle": handle})
147
+ cookies = ((resp or {}).get("data") or {}).get("cookies") or []
148
+ if cookies:
149
+ return cookies, f"server store (@{handle})"
150
+ except Exception as e:
151
+ print(f"restore_twitter_session: server store fetch failed ({e})", file=sys.stderr)
152
+ return [], None
153
+
154
+
155
+ def main():
105
156
  try:
106
157
  ws, send = _attach()
107
158
  except Exception as e:
@@ -110,34 +161,24 @@ def main():
110
161
 
111
162
  try:
112
163
  if _logged_in(send):
113
- print(f"restore_twitter_session: already logged in as @{handle}; no-op")
164
+ print("restore_twitter_session: already logged in; no-op")
114
165
  return 0
115
166
 
116
- print(f"restore_twitter_session: logged out, fetching stored cookies for @{handle}...")
117
- resp = api_get("/api/v1/twitter/session-cookies", query={"handle": handle})
118
- data = (resp or {}).get("data") or {}
119
- cookies = data.get("cookies") or []
167
+ cookies, source = _stored_cookies()
120
168
  if not cookies:
121
- print("restore_twitter_session: no stored cookies; manual re-login required", file=sys.stderr)
169
+ print("restore_twitter_session: no stored cookies (local mirror empty + no "
170
+ "server store); manual connect_x required", file=sys.stderr)
122
171
  return 1
123
172
 
124
- # CDP Network.setCookies wants url or domain/path. The stored cookies are
125
- # already CDP-shaped (from Network.getAllCookies), so pass them straight.
126
- send("Network.enable")
127
- ok_count = 0
128
- for c in cookies:
129
- params = {k: c[k] for k in (
130
- "name", "value", "domain", "path", "secure", "httpOnly",
131
- "sameSite", "expires") if k in c and c[k] is not None}
132
- r = send("Network.setCookie", params)
133
- if r.get("result", {}).get("success", True):
134
- ok_count += 1
173
+ print(f"restore_twitter_session: logged out, restoring from {source}...")
174
+ ok_count = _inject(send, cookies)
135
175
  print(f"restore_twitter_session: injected {ok_count}/{len(cookies)} cookies")
136
176
 
137
177
  if _logged_in(send):
138
- print(f"restore_twitter_session: RESTORED @{handle} session")
178
+ print(f"restore_twitter_session: RESTORED session from {source}")
139
179
  return 0
140
- print("restore_twitter_session: injection done but still logged out (cookies may be expired)", file=sys.stderr)
180
+ print("restore_twitter_session: injection done but still logged out "
181
+ "(cookies may be expired); manual connect_x required", file=sys.stderr)
141
182
  return 1
142
183
  finally:
143
184
  try:
@@ -74,6 +74,13 @@ except Exception:
74
74
  api_post = None
75
75
  resolve_handle = None
76
76
 
77
+ # Local 0600 cookie mirror — the keychain-independent durability layer (Gap B).
78
+ # Always importable (stdlib only); guarded so a path quirk never breaks setup.
79
+ try:
80
+ import twitter_cookie_mirror # noqa: E402
81
+ except Exception:
82
+ twitter_cookie_mirror = None
83
+
77
84
  # --- Config -----------------------------------------------------------------
78
85
 
79
86
  # Same managed Chrome the twitter-harness pipeline uses (skill/lib/twitter-backend.sh).
@@ -277,48 +284,75 @@ def _has_session_quick() -> bool:
277
284
  pass
278
285
 
279
286
 
280
- def _save_session_to_store() -> None:
281
- """Best-effort: persist the live x.com cookies to the server-side session
282
- store (social_accounts.session_cookies, via POST /api/v1/twitter/session-cookies)
283
- so restore_twitter_session.py can auto-re-inject them on the next preflight
284
- after ANY logout — hard kill, crash, or AppMaker VM reseed. Non-fatal: the
285
- local session is already valid; this only enables future auto-recovery.
286
- Without it the restore rail has nothing to read and every logout needs a
287
- manual connect_x."""
288
- if api_post is None or resolve_handle is None:
289
- return
290
- try:
291
- handle = resolve_handle()
292
- except Exception:
293
- handle = None
294
- if not handle:
295
- return
287
+ def _collect_x_cookies(send) -> list:
288
+ """Read the live x.com/twitter.com cookies (CDP shape) from the managed
289
+ Chrome. Returns [] if none. Shared by the mirror + server-store writers."""
290
+ send("Network.enable")
291
+ r = send("Network.getAllCookies")
292
+ cks = r.get("result", {}).get("cookies", []) or []
293
+ wanted = tuple(d.strip() for d in DOMAINS.split(",") if d.strip())
294
+ return [c for c in cks if any(w in (c.get("domain") or "") for w in wanted)]
295
+
296
+
297
+ def _persist_session() -> None:
298
+ """Persist the validated live X session for auto-restore after ANY logout
299
+ (hard kill, crash, keychain re-lock wiping Chrome's Cookies DB, or AppMaker
300
+ VM reseed). One CDP attach feeds two sinks:
301
+
302
+ 1. LOCAL 0600 mirror (twitter_cookie_mirror) — ALWAYS written. This is the
303
+ keychain-independent durability layer that fixes Gap B on a persistent
304
+ machine: restore_twitter_session.py re-injects from it on the next cycle
305
+ preflight even after Chrome wiped its own encrypted store.
306
+ 2. Server-side session store (POST /api/v1/twitter/session-cookies) —
307
+ best-effort. Enables VM auto-restore where the profile is reseeded
308
+ hourly. No-op on a persistent machine with no social_accounts row.
309
+
310
+ Non-fatal end-to-end: the local session is already valid; this only enables
311
+ future auto-recovery, so nothing here may abort connect_x."""
296
312
  try:
297
313
  ws, send = _attach()
298
314
  except Exception:
299
315
  return
300
316
  try:
301
- send("Network.enable")
302
- r = send("Network.getAllCookies")
303
- cks = r.get("result", {}).get("cookies", []) or []
304
- wanted = tuple(d.strip() for d in DOMAINS.split(",") if d.strip())
305
- cookies = [c for c in cks if any(w in (c.get("domain") or "") for w in wanted)]
306
- if not cookies:
307
- return
308
- api_post("/api/v1/twitter/session-cookies", {"handle": handle, "cookies": cookies})
309
- print(f"setup_twitter_auth: saved {len(cookies)} session cookies for @{handle} "
310
- "(auto-restore enabled)", file=sys.stderr)
311
- # api_post raises SystemExit (BaseException, NOT Exception) on a 4xx/5xx —
312
- # e.g. "no social_accounts row" on a persistent machine that never registered
313
- # this handle. The save is best-effort and must never abort connect_x, so
314
- # catch SystemExit too.
315
- except (Exception, SystemExit) as e:
316
- print(f"setup_twitter_auth: session-store save skipped ({e})", file=sys.stderr)
317
+ cookies = _collect_x_cookies(send)
318
+ except Exception:
319
+ cookies = []
317
320
  finally:
318
321
  try:
319
322
  ws.close()
320
323
  except Exception:
321
324
  pass
325
+ if not cookies:
326
+ return
327
+
328
+ handle = None
329
+ if resolve_handle is not None:
330
+ try:
331
+ handle = resolve_handle()
332
+ except Exception:
333
+ handle = None
334
+
335
+ # 1. Local mirror — always, keychain-independent.
336
+ if twitter_cookie_mirror is not None:
337
+ try:
338
+ n = twitter_cookie_mirror.save_cookies(cookies, handle=handle)
339
+ print(f"setup_twitter_auth: mirrored {n} x.com cookies to "
340
+ f"{twitter_cookie_mirror.MIRROR_PATH} (survives keychain re-lock "
341
+ "/ Cookies-DB wipe on relaunch)", file=sys.stderr)
342
+ except Exception as e:
343
+ print(f"setup_twitter_auth: local mirror save skipped ({e})", file=sys.stderr)
344
+
345
+ # 2. Server store — best-effort, only when a handle resolves.
346
+ if api_post is not None and handle:
347
+ try:
348
+ api_post("/api/v1/twitter/session-cookies", {"handle": handle, "cookies": cookies})
349
+ print(f"setup_twitter_auth: saved {len(cookies)} session cookies for @{handle} "
350
+ "(server auto-restore enabled)", file=sys.stderr)
351
+ # api_post raises SystemExit (BaseException, NOT Exception) on a 4xx/5xx —
352
+ # e.g. "no social_accounts row" on a persistent machine that never
353
+ # registered this handle. Best-effort: must never abort connect_x.
354
+ except (Exception, SystemExit) as e:
355
+ print(f"setup_twitter_auth: session-store save skipped ({e})", file=sys.stderr)
322
356
 
323
357
 
324
358
  def _show_window_and_open_login() -> bool:
@@ -475,19 +509,76 @@ def _classify_import_error(detail: str | None) -> str:
475
509
  return "unknown"
476
510
 
477
511
 
478
- def _force_cookie_flush() -> tuple[bool, str]:
479
- """Trigger Chrome's cookie-store flush via CDP Browser.close (#2).
512
+ def _cookies_db_path() -> Path | None:
513
+ """Resolve the harness profile's on-disk Cookies SQLite. Newer Chrome nests
514
+ it under Default/Network/; older builds keep it at Default/. Returns whichever
515
+ exists (most-recently-modified wins if both linger), or None."""
516
+ candidates = [
517
+ PROFILE_DIR / "Default" / "Network" / "Cookies",
518
+ PROFILE_DIR / "Default" / "Cookies",
519
+ ]
520
+ existing = [p for p in candidates if p.exists()]
521
+ if not existing:
522
+ return None
523
+ return max(existing, key=lambda p: p.stat().st_mtime)
524
+
525
+
526
+ def _count_x_cookies_on_disk() -> int:
527
+ """Count x.com/twitter.com rows committed to the on-disk Cookies SQLite.
528
+
529
+ Reads a temp COPY of the DB (+ -wal/-shm) so an in-flight write by the live
530
+ Chrome can't lock us out, and opens it read-write on the copy so WAL-resident
531
+ rows are visible (a read-only open would miss not-yet-checkpointed writes —
532
+ exactly the rows we are polling for). Returns the count, or -1 if the DB is
533
+ missing/unreadable."""
534
+ db = _cookies_db_path()
535
+ if not db:
536
+ return -1
537
+ import shutil
538
+ import sqlite3
539
+ import tempfile
540
+ tmpdir = None
541
+ try:
542
+ tmpdir = Path(tempfile.mkdtemp(prefix="saps_flushchk_"))
543
+ dst = tmpdir / "Cookies"
544
+ shutil.copy2(db, dst)
545
+ for suffix in ("-wal", "-shm"):
546
+ w = db.parent / (db.name + suffix)
547
+ if w.exists():
548
+ shutil.copy2(w, tmpdir / ("Cookies" + suffix))
549
+ conn = sqlite3.connect(str(dst))
550
+ try:
551
+ n = conn.execute(
552
+ "SELECT COUNT(*) FROM cookies "
553
+ "WHERE host_key LIKE '%x.com' OR host_key LIKE '%twitter.com'"
554
+ ).fetchone()[0]
555
+ finally:
556
+ conn.close()
557
+ return int(n)
558
+ except Exception:
559
+ return -1
560
+ finally:
561
+ if tmpdir is not None:
562
+ shutil.rmtree(tmpdir, ignore_errors=True)
480
563
 
481
- Verified empirically on Chrome 148/macOS 26: Browser.close synchronously
482
- commits the in-memory CookieMonster to the on-disk SQLite, but does NOT
483
- actually terminate the process. We rely on the flush side-effect, so a
484
- SIGKILL immediately after import no longer wipes the imported cookies.
485
564
 
486
- Returns (ok, detail). ok=True if the RPC was issued cleanly; the process
487
- still being alive afterwards is expected behavior, not a failure."""
565
+ def _force_cookie_flush() -> tuple[bool, str]:
566
+ """Flush Chrome's in-memory cookie store to disk via CDP Browser.close, then
567
+ VERIFY the x.com cookies actually landed in the on-disk SQLite before
568
+ returning (Gap A, 2026-06-02).
569
+
570
+ The bug this fixes: Browser.close acks immediately, but Chrome commits the
571
+ CookieMonster -> SQLite write ASYNCHRONOUSLY (~0.5-5s under load). The old
572
+ code treated the RPC ack as proof of persistence and reported
573
+ flushed_to_disk=true while the disk was still empty, so a doctor run or a
574
+ SIGKILL in that window saw zero cookies. We now poll the on-disk row count
575
+ until the flush is observably durable (or a timeout proves it isn't).
576
+
577
+ Returns (ok, detail). ok=True only when x.com rows are confirmed on disk."""
488
578
  bh = Path.home() / ".local" / "bin" / "browser-harness"
489
579
  if not bh.exists():
490
580
  return False, f"browser-harness CLI missing at {bh}"
581
+ before = _count_x_cookies_on_disk()
491
582
  env = os.environ.copy()
492
583
  env["BU_CDP_URL"] = CDP
493
584
  env.setdefault("BU_NAME", "twitter-harness")
@@ -502,7 +593,23 @@ def _force_cookie_flush() -> tuple[bool, str]:
502
593
  return False, f"browser-harness invocation failed: {e}"
503
594
  if r.returncode != 0:
504
595
  return False, (r.stderr or r.stdout).strip()[:300]
505
- return True, "Browser.close issued; cookie store flushed to SQLite"
596
+
597
+ # Poll the disk for the async commit to land. Accept as soon as we observe
598
+ # x.com rows on disk (and, if we had a baseline, that it didn't regress).
599
+ deadline = time.time() + 8.0
600
+ last = before
601
+ while time.time() < deadline:
602
+ n = _count_x_cookies_on_disk()
603
+ if n > 0 and (before <= 0 or n >= before):
604
+ return True, f"verified {n} x.com cookies committed to on-disk SQLite"
605
+ last = n
606
+ time.sleep(0.5)
607
+ if last > 0:
608
+ return True, f"verified {last} x.com cookies on disk (slow flush)"
609
+ return False, (
610
+ f"Browser.close issued but on-disk x.com cookie count is {last} after 8s "
611
+ "(flush not confirmed; relying on the local cookie mirror for durability)"
612
+ )
506
613
 
507
614
 
508
615
  # --- Commands ---------------------------------------------------------------
@@ -543,7 +650,7 @@ def cmd_connect(args) -> dict:
543
650
  # 1. Already logged in? Nothing to import.
544
651
  try:
545
652
  if _is_session_valid():
546
- _save_session_to_store()
653
+ _persist_session()
547
654
  return {
548
655
  "ok": True,
549
656
  "connected": True,
@@ -609,13 +716,17 @@ def cmd_connect(args) -> dict:
609
716
  # 3. Re-validate after this source.
610
717
  try:
611
718
  if _is_session_valid():
612
- _save_session_to_store()
719
+ _persist_session()
613
720
  # #2: force a cookie-store flush via CDP Browser.close so the
614
721
  # imported session survives any subsequent SIGKILL (e.g. the
615
722
  # autoposter cron stopping Chrome with no grace window). Empty
616
723
  # result on this build is success — Browser.close triggers the
617
724
  # flush synchronously but doesn't actually terminate Chrome.
618
725
  flush_ok, flush_detail = _force_cookie_flush()
726
+ mirror_count = (
727
+ twitter_cookie_mirror.load_meta().get("count")
728
+ if twitter_cookie_mirror is not None else None
729
+ )
619
730
  return {
620
731
  "ok": True,
621
732
  "connected": True,
@@ -624,10 +735,16 @@ def cmd_connect(args) -> dict:
624
735
  "attempts": attempts,
625
736
  "flushed_to_disk": flush_ok,
626
737
  "flush_detail": flush_detail,
738
+ "mirrored_cookies": mirror_count,
627
739
  "note": f"Imported your X session from {src} into the autoposter browser. "
628
- + ("Cookies flushed to disk (persists across Chrome restart)."
740
+ + ("Cookies verified on disk AND mirrored locally; "
629
741
  if flush_ok else
630
- "Cookies are in RAM; a clean stop_chrome (1.6.32+) will flush them."),
742
+ "Chrome's encrypted store didn't confirm the flush, but ")
743
+ + (f"{mirror_count} cookies are saved to a keychain-independent "
744
+ "mirror, so the cycle preflight auto-restores the session even if "
745
+ "Chrome re-launches logged out."
746
+ if mirror_count else
747
+ "the session is live in the running browser."),
631
748
  "cdp": CDP,
632
749
  }
633
750
  except Exception:
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """twitter_cookie_mirror.py - local 0600 mirror of the managed X session cookies.
3
+
4
+ Why this exists (Gap B, 2026-06-02)
5
+ -----------------------------------
6
+ On a persistent (non-VM) machine the server-side session store
7
+ (social_accounts.session_cookies) is SKIPPED during connect_x because there is no
8
+ social_accounts row to attach the cookies to. That left Chrome's own encrypted
9
+ Cookies SQLite as the ONLY thing keeping the X session across a Chrome relaunch.
10
+
11
+ That store is not durable on a headless / SSH box: macOS encrypts Chrome cookies
12
+ with the per-app `Chrome Safe Storage` key, which lives in the login keychain.
13
+ When the keychain re-locks (idle ~5 min) between the import and the next Chrome
14
+ launch, the freshly-launched Chrome cannot read the Safe Storage key, cannot
15
+ decrypt the existing blobs, and reinitializes the Cookies DB to an empty schema.
16
+ The imported session silently evaporates between `connect_x` and the first cycle.
17
+
18
+ This module is the keychain-independent durability layer. On a successful import
19
+ connect_x writes the validated x.com/twitter.com cookies (CDP-shaped, straight
20
+ from Network.getAllCookies) here as plaintext JSON, and the cycle preflight
21
+ (restore_twitter_session.py, invoked from skill/lib/twitter-backend.sh) re-injects
22
+ them via CDP whenever the live session comes up logged out. A keychain re-lock or
23
+ a wiped Cookies DB is therefore no longer fatal — the next cycle restores.
24
+
25
+ Security
26
+ --------
27
+ The file grants access to the X account: it is exactly as sensitive as the Chrome
28
+ profile itself, and is written 0600 (owner read/write only). Treat it like a
29
+ token. It is intentionally NOT encrypted — the whole point is to survive a locked
30
+ keychain, so adding a keychain-derived key would reintroduce the dependency this
31
+ file exists to remove. On a multi-user host, restrict the home directory.
32
+
33
+ CLI (debug / doctor):
34
+ python3 twitter_cookie_mirror.py count # prints the mirrored cookie count
35
+ python3 twitter_cookie_mirror.py path # prints the mirror file path
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import os
41
+ import sys
42
+ import time
43
+ from pathlib import Path
44
+
45
+ # Sibling of the harness profile dir, NOT inside it: a VM profile reseed wipes
46
+ # the profile but a persistent machine keeps this file across Chrome relaunches.
47
+ # (On a VM the server-side store is the durable path; the mirror just stays empty
48
+ # there and restore_twitter_session falls through to the API.)
49
+ MIRROR_PATH = (
50
+ Path.home() / ".claude" / "browser-profiles" / "browser-harness.x-cookies.json"
51
+ )
52
+
53
+
54
+ def save_cookies(cookies, handle: str | None = None) -> int:
55
+ """Write the given CDP-shaped cookies to the 0600 mirror. Returns count saved.
56
+
57
+ Atomic (temp file + os.replace) so a crash mid-write never leaves a partial
58
+ JSON that the reader would choke on. No-op (returns 0) on an empty list."""
59
+ clean = [c for c in (cookies or []) if isinstance(c, dict) and c.get("name")]
60
+ if not clean:
61
+ return 0
62
+ MIRROR_PATH.parent.mkdir(parents=True, exist_ok=True)
63
+ payload = {"handle": handle, "saved_at": int(time.time()), "cookies": clean}
64
+ tmp = MIRROR_PATH.with_name(MIRROR_PATH.name + ".tmp")
65
+ # Create with 0600 from the start so the secret is never briefly world-readable.
66
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
67
+ with os.fdopen(fd, "w") as f:
68
+ json.dump(payload, f)
69
+ os.replace(tmp, MIRROR_PATH)
70
+ try:
71
+ os.chmod(MIRROR_PATH, 0o600)
72
+ except OSError:
73
+ pass
74
+ return len(clean)
75
+
76
+
77
+ def _read() -> dict:
78
+ try:
79
+ with open(MIRROR_PATH) as f:
80
+ data = json.load(f)
81
+ except (OSError, ValueError):
82
+ return {}
83
+ return data if isinstance(data, dict) else {}
84
+
85
+
86
+ def load_cookies() -> list:
87
+ """Return the mirrored CDP-shaped cookies, or [] if no/invalid mirror."""
88
+ cks = _read().get("cookies")
89
+ return cks if isinstance(cks, list) else []
90
+
91
+
92
+ def load_meta() -> dict:
93
+ """Return {handle, saved_at, count} for the mirror, or {} if absent."""
94
+ data = _read()
95
+ if not data:
96
+ return {}
97
+ return {
98
+ "handle": data.get("handle"),
99
+ "saved_at": data.get("saved_at"),
100
+ "count": len(data.get("cookies") or []),
101
+ }
102
+
103
+
104
+ def _cli(argv: list[str] | None = None) -> int:
105
+ argv = argv if argv is not None else sys.argv[1:]
106
+ cmd = argv[0] if argv else "count"
107
+ if cmd == "path":
108
+ print(MIRROR_PATH)
109
+ return 0
110
+ if cmd == "count":
111
+ print(len(load_cookies()))
112
+ return 0
113
+ if cmd == "meta":
114
+ print(json.dumps(load_meta()))
115
+ return 0
116
+ print(f"usage: {Path(sys.argv[0]).name} [count|path|meta]", file=sys.stderr)
117
+ return 2
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(_cli())
@@ -214,6 +214,15 @@ ensure_twitter_browser_for_backend() {
214
214
  fi
215
215
  echo "[$(date +%H:%M:%S)] Harness Chrome up on port 9555" >&2
216
216
  fi
217
+ # Re-inject the stored X session if the harness Chrome is logged out — e.g. a
218
+ # keychain re-lock wiped Chrome's encrypted Cookies SQLite on this launch
219
+ # (Gap B, 2026-06-02). restore_twitter_session.py reads the keychain-
220
+ # independent local cookie mirror (written by connect_x) and injects via CDP.
221
+ # No-op when already logged in; never blocks the cycle on failure. Runs on
222
+ # both the freshly-launched and already-up paths so a mid-life logout heals.
223
+ TWITTER_CDP_URL="http://127.0.0.1:9555" \
224
+ python3 "$HOME/social-autoposter/scripts/restore_twitter_session.py" 2>&1 \
225
+ | sed 's/^/[restore] /' >&2 || true
217
226
  # Always close leftover tabs from prior runs. Safe under acquire_lock
218
227
  # "twitter-browser" serialization (every caller of this function holds
219
228
  # that lock), so we will not race with another active twitter run.
@@ -1,111 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Watch the install lane canary in real time.
3
-
4
- Run any time:
5
- python3 scripts/install_lane_monitor.py
6
-
7
- Checks:
8
- 1. Heartbeat freshness — alerts if last beat > 30min old.
9
- 2. Install attribution coverage — % of replies created in the last 24h
10
- for each platform that have install_id stamped (canary github should
11
- trend to ~100%; the other 3 should stay at 0% until we flip them).
12
- 3. Stuck-in-processing — replies left in 'processing' > 30min are the
13
- classic failure mode for the new lane (server claimed the row, then
14
- a downstream step failed silently).
15
- 4. Recent install lane errors — scans launchd-heartbeat logs for FAIL.
16
-
17
- Exit code 0 if everything green, 1 if any check fails. Safe in cron.
18
- """
19
- import os, sys, subprocess
20
- sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
21
- from http_api import api_get, load_env
22
-
23
- load_env()
24
- _digest = (api_get("/api/v1/install-lane/digest", query={"within_hours": 24}).get("data") or {})
25
-
26
- OK = "\033[32m✓\033[0m"
27
- WARN = "\033[33m!\033[0m"
28
- FAIL = "\033[31m✗\033[0m"
29
- status = 0
30
-
31
- print("=" * 64)
32
- HTTP_LANE_PLATFORMS = {"github", "reddit", "x", "linkedin", "moltbook"}
33
- print(f"INSTALL LANE CANARY (HTTP: {', '.join(sorted(HTTP_LANE_PLATFORMS))} / others SQL)")
34
- print("=" * 64)
35
-
36
- # 1. Heartbeat freshness
37
- rows = _digest.get("heartbeat") or []
38
- print("\n[1] HEARTBEAT")
39
- if not rows:
40
- print(f" {FAIL} no installations rows yet")
41
- status = 1
42
- else:
43
- head = rows[0]
44
- age = head["age_sec"]
45
- age_disp = f"{age}s" if age < 120 else f"{age // 60}m {age % 60}s"
46
- flag = OK if age < 1800 else (WARN if age < 3600 else FAIL)
47
- if age >= 1800:
48
- status = 1
49
- print(f" {flag} latest beat {age_disp} ago")
50
- print(f" install_id {head['install_id']}")
51
- print(f" hostname {head['hostname']}")
52
- print(f" beats total {head['request_count']}")
53
- print(f" last_ip {head['last_ip']} ({head['last_city'] or '?'} / {head['last_country'] or '?'})")
54
- if len(rows) > 1:
55
- print(f" +{len(rows)-1} other install(s) seen recently")
56
-
57
- # 2. Per-platform attribution coverage (last 24h created replies)
58
- rows = _digest.get("platforms") or []
59
- print(f"\n[2] LAST 24H REPLIES BY PLATFORM (HTTP-lane: {', '.join(sorted(HTTP_LANE_PLATFORMS))})")
60
- print(f" {'platform':<10} {'total':>5} {'attrib':>7} {'rep':>5} {'skp':>5} {'prc':>5} {'pnd':>5} notes")
61
- for r in rows:
62
- plat, total, attrib = r["platform"], r["total"], r["attributed"]
63
- replied, skipped, proc, pend = r["replied"], r["skipped"], r["processing"], r["pending"]
64
- pct = (attrib / total * 100) if total else 0
65
- note = ""
66
- if plat in HTTP_LANE_PLATFORMS:
67
- if total == 0:
68
- note = "(no traffic yet)"
69
- elif pct < 80:
70
- note = f" {WARN} only {pct:.0f}% attributed; expected ~100%"
71
- status = 1
72
- else:
73
- note = f" {OK} {pct:.0f}% attributed"
74
- else:
75
- if attrib > 0:
76
- note = f" {WARN} {attrib} unexpected install_id rows on a SQL-lane platform"
77
- print(f" {plat:<10} {total:>5} {attrib:>7} {replied:>5} {skipped:>5} {proc:>5} {pend:>5}{note}")
78
-
79
- # 3. Stuck in 'processing' > 30min — the canonical failure mode of a new claim path
80
- rows = _digest.get("stuck") or []
81
- print("\n[3] STUCK IN 'processing' > 30min")
82
- if not rows:
83
- print(f" {OK} none")
84
- else:
85
- print(f" {WARN} {len(rows)} stuck rows (revert with: UPDATE replies SET status='pending' WHERE id IN (...))")
86
- for r in rows:
87
- rid, plat, iid, age = r["id"], r["platform"], r["install_id"], r["age_sec"]
88
- age_disp = f"{age // 60}m" if age < 7200 else f"{age // 3600}h"
89
- print(f" id={rid:<6} {plat:<8} iid={(iid or '-')[:8]} {age_disp} ago")
90
-
91
- # 4. Heartbeat log errors in the last 100 lines
92
- log_path = os.path.expanduser("~/social-autoposter/skill/logs/heartbeat.log")
93
- print("\n[4] HEARTBEAT LOG (recent FAILs)")
94
- if os.path.exists(log_path):
95
- try:
96
- out = subprocess.check_output(["tail", "-200", log_path], text=True, timeout=5)
97
- fails = [ln for ln in out.splitlines() if "FAIL" in ln]
98
- if not fails:
99
- print(f" {OK} no failures in last 200 lines")
100
- else:
101
- print(f" {FAIL} {len(fails)} failures:")
102
- for ln in fails[-5:]:
103
- print(f" {ln}")
104
- status = 1
105
- except Exception as e:
106
- print(f" {WARN} couldn't read log: {e}")
107
- else:
108
- print(f" {WARN} log not yet created at {log_path}")
109
-
110
- print()
111
- sys.exit(status)
@@ -1,183 +0,0 @@
1
- #!/usr/bin/env python3
2
- import json
3
- import os
4
- import sys
5
- import re
6
-
7
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
8
- from http_api import api_post
9
-
10
- EXCLUDED_AUTHORS = {"louis030195", "louis3195"}
11
- OWN_NAME = "Matthew Diakonov"
12
- OWN_HANDLES = {"m13v", "matthew-diakonov"}
13
-
14
- def load_existing_comment_ids():
15
- s = set()
16
- with open("/tmp/li_existing_comment_ids.txt") as f:
17
- for line in f:
18
- line = line.strip()
19
- if line:
20
- s.add(line)
21
- return s
22
-
23
- def load_engaged_pairs():
24
- s = set()
25
- with open("/tmp/li_engaged_pairs.txt") as f:
26
- for line in f:
27
- line = line.strip()
28
- if line:
29
- s.add(line)
30
- return s
31
-
32
- def load_posts():
33
- """Build mapping by activity_id and ugc_id from our_url."""
34
- by_id = {}
35
- with open("/tmp/li_posts.txt") as f:
36
- for line in f:
37
- line = line.strip()
38
- if not line:
39
- continue
40
- pid_str, _, our_url = line.partition("|")
41
- try:
42
- pid = int(pid_str)
43
- except ValueError:
44
- continue
45
- ids = set()
46
- for m in re.findall(r"urn:li:activity:(\d+)", our_url):
47
- ids.add(("activity", m))
48
- for m in re.findall(r"urn:li:ugcPost:(\d+)", our_url):
49
- ids.add(("ugcPost", m))
50
- for m in re.findall(r"/feed/update/urn:li:(activity|ugcPost):(\d+)", our_url):
51
- ids.add((m[0], m[1]))
52
- for m in re.findall(r"/posts/[^/?#]*?(\d{15,})", our_url):
53
- ids.add(("any", m))
54
- for m in re.findall(r"(\d{18,20})", our_url):
55
- ids.add(("any", m))
56
- for kind, urn_id in ids:
57
- by_id.setdefault(urn_id, (pid, our_url))
58
- return by_id
59
-
60
- def main():
61
- existing = load_existing_comment_ids()
62
- engaged = load_engaged_pairs()
63
- posts_by_id = load_posts()
64
-
65
- items = []
66
- for fn in ("/tmp/li_notifications_batch1.json", "/tmp/li_notifications_batch2.json"):
67
- with open(fn) as f:
68
- items.extend(json.load(f))
69
-
70
- counts = {
71
- "discovered": 0,
72
- "already_tracked": 0,
73
- "author_already_engaged": 0,
74
- "excluded": 0,
75
- "own_account": 0,
76
- "no_comment_urn": 0,
77
- "post_not_found_skipped": 0,
78
- "post_created": 0,
79
- }
80
-
81
- for it in items:
82
- author = (it.get("author") or "").strip()
83
- comment_urn = it.get("comment_urn")
84
- href = it.get("href")
85
- snippet = it.get("snippet") or ""
86
- activity_id = it.get("activity_id")
87
- ugc_id = it.get("ugc_id")
88
-
89
- if not comment_urn or not (activity_id or ugc_id):
90
- counts["no_comment_urn"] += 1
91
- continue
92
-
93
- if author == OWN_NAME or author.lower() in OWN_HANDLES:
94
- counts["own_account"] += 1
95
- continue
96
-
97
- author_lower = author.lower()
98
- if any(ex in author_lower for ex in EXCLUDED_AUTHORS):
99
- counts["excluded"] += 1
100
- continue
101
-
102
- if comment_urn in existing:
103
- counts["already_tracked"] += 1
104
- continue
105
-
106
- # Find post by activity_id first, then ugc_id
107
- post_id = None
108
- our_url = None
109
- for candidate in (activity_id, ugc_id):
110
- if candidate and candidate in posts_by_id:
111
- post_id, our_url = posts_by_id[candidate]
112
- break
113
-
114
- if post_id is None:
115
- # Need to insert a new post row
116
- urn_for_url = activity_id or ugc_id
117
- kind = "activity" if activity_id else "ugcPost"
118
- our_url = f"https://www.linkedin.com/feed/update/urn:li:{kind}:{urn_for_url}/"
119
- # thread_author: best signal we have is the notification author
120
- # (the replier). It isn't the actual OP, but it's not us, so the
121
- # dashboard "threads vs comments" filter (server.js /api/top)
122
- # correctly classifies these as comments under someone else's post.
123
- thread_author = author or "(unknown)"
124
- resp = api_post(
125
- "/api/v1/posts",
126
- {
127
- "platform": "linkedin",
128
- "thread_url": our_url,
129
- "our_url": our_url,
130
- "our_content": "[discovered via notification, no original content tracked]",
131
- "project": "general",
132
- "thread_author": thread_author,
133
- "our_account": "Matthew Diakonov",
134
- "engagement_style": "discovered_via_notification",
135
- "status": "active",
136
- },
137
- ok_on_conflict=True,
138
- )
139
- if (resp.get("error") or {}).get("code") == "duplicate_thread":
140
- # Post already exists in DB but wasn't in our /tmp/li_posts.txt
141
- # local map; reuse the existing row instead of creating a dup.
142
- post_id = (resp["error"].get("details") or {}).get("existing_post_id")
143
- else:
144
- post_id = ((resp.get("data") or {}).get("post") or {}).get("id")
145
- if post_id is None:
146
- counts["post_not_found_skipped"] += 1
147
- continue
148
- counts["post_created"] += 1
149
- # Add to in-memory map so subsequent items in same loop reuse it
150
- for cand in (activity_id, ugc_id):
151
- if cand:
152
- posts_by_id[cand] = (post_id, our_url)
153
-
154
- pair_key = f"{author}|||{our_url}"
155
- if pair_key in engaged:
156
- counts["author_already_engaged"] += 1
157
- continue
158
-
159
- # Insert reply (find-or-create; 409 duplicate is fine, the gate may
160
- # also return 200 with reply:null which we treat as discovered-then-gated).
161
- api_post(
162
- "/api/v1/replies",
163
- {
164
- "platform": "linkedin",
165
- "post_id": post_id,
166
- "their_comment_id": comment_urn,
167
- "their_author": author,
168
- "their_content": snippet,
169
- "their_comment_url": href,
170
- "depth": 1,
171
- "status": "pending",
172
- },
173
- ok_on_conflict=True,
174
- )
175
-
176
- existing.add(comment_urn)
177
- engaged.add(pair_key)
178
- counts["discovered"] += 1
179
-
180
- print(json.dumps(counts, indent=2))
181
-
182
- if __name__ == "__main__":
183
- main()