@m13v/s4l 1.7.5-rc.2 → 1.7.5-rc.21

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.
Files changed (48) hide show
  1. package/mcp/dist/index.js +571 -79
  2. package/mcp/dist/panel.html +2 -3
  3. package/mcp/dist/redditAuth.js +115 -0
  4. package/mcp/dist/runtime.js +83 -2
  5. package/mcp/dist/setup.js +142 -5
  6. package/mcp/dist/telemetry.js +13 -3
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +1 -1
  9. package/mcp/menubar/s4l_card.py +32 -6
  10. package/mcp/menubar/s4l_menubar.py +42 -111
  11. package/mcp/menubar/s4l_state.py +54 -187
  12. package/mcp/package.json +1 -1
  13. package/mcp/shared/onboarding-ledger.cjs +63 -4
  14. package/package.json +1 -1
  15. package/scripts/autopilot_stall_watch.py +3 -6
  16. package/scripts/browser_mutex.py +316 -0
  17. package/scripts/claude_job.py +21 -0
  18. package/scripts/config.py +118 -0
  19. package/scripts/counterparty_history.py +44 -6
  20. package/scripts/engage_github.py +7 -3
  21. package/scripts/engage_reddit.py +9 -3
  22. package/scripts/engagement_signal_feedback.py +256 -0
  23. package/scripts/learned_preferences.py +37 -14
  24. package/scripts/memory_snapshot.py +45 -0
  25. package/scripts/merge_review_queue.py +67 -3
  26. package/scripts/post_reddit.py +289 -24
  27. package/scripts/reddit_browser.py +62 -63
  28. package/scripts/reddit_browser_fetch.py +32 -0
  29. package/scripts/release-mcpb.sh +17 -0
  30. package/scripts/reset-test-machine.sh +13 -7
  31. package/scripts/scan_twitter_thread_followups.py +7 -1
  32. package/scripts/schedule_state.py +1 -1
  33. package/scripts/setup_reddit_auth.py +925 -0
  34. package/scripts/snapshot.py +96 -4
  35. package/scripts/stats.py +37 -13
  36. package/scripts/test_no_silent_fallbacks.py +281 -0
  37. package/scripts/trigger_promo_lane_test.sh +91 -0
  38. package/scripts/twitter_browser.py +183 -342
  39. package/scripts/twitter_scan.py +75 -10
  40. package/scripts/watchdog_hung_runs.py +15 -9
  41. package/skill/lib/harness-common.sh +344 -0
  42. package/skill/lib/linkedin-backend.sh +61 -161
  43. package/skill/lib/reddit-backend.sh +39 -125
  44. package/skill/lib/twitter-backend.sh +51 -273
  45. package/skill/lock.sh +57 -0
  46. package/skill/run-reddit-search-launchd.sh +4 -1
  47. package/skill/run-reddit-search.sh +51 -2
  48. package/skill/run-twitter-cycle.sh +44 -21
@@ -0,0 +1,925 @@
1
+ #!/usr/bin/env python3
2
+ """setup_reddit_auth.py - Reddit session bootstrap for the MCP setup flow.
3
+
4
+ Used by the social-autoposter MCP `project_config` tool (action=connect_reddit)
5
+ to give a user a logged-in Reddit session in the autoposter's managed
6
+ reddit-harness Chrome WITHOUT making them paste cookies or hand-edit anything.
7
+ Mirrors scripts/setup_twitter_auth.py, parameterized for Reddit.
8
+
9
+ It answers the three questions the setup flow needs:
10
+ 1. Do cookies already exist in the managed reddit-harness browser?
11
+ 2. Are they still valid? (api/me.json returns a real, non-suspended account
12
+ via a same-origin fetch from inside a reddit.com page)
13
+ 3. Does the user need to log in manually? (import failed / no source)
14
+
15
+ How it works
16
+ ------------
17
+ The Reddit pipeline rides a dedicated managed Chrome on CDP port 9557 with a
18
+ persistent profile at ~/.claude/browser-profiles/reddit-harness (the same
19
+ Chrome skill/lib/reddit-backend.sh launches for discovery + posting). This
20
+ helper:
21
+
22
+ status - probe that Chrome; if up, do a read-only reddit_session cookie
23
+ check (cheap and poll-safe: never navigates the shared harness
24
+ tab; the authoritative me.json validation lives in `connect`).
25
+ If down, fall back to a read-only on-disk cookie check
26
+ (connected_idle) so a saved session never reads back as
27
+ "disconnected" just because the harness isn't running.
28
+ connect - ensure that Chrome is running; if the reddit session is already
29
+ valid, no-op; otherwise IMPORT reddit.com cookies (reddit_session,
30
+ token_v2, and the rest of the reddit.com jar) from the user's
31
+ everyday browser (Chrome/Arc/Brave/Edge, auto-detected) via the
32
+ vendored copy_browser_cookies.py, then re-validate. If still
33
+ logged out, open a visible login window (manual-login fallback)
34
+ and wait for the user to sign in once.
35
+
36
+ Only reddit.com cookies are copied. No other site's session is touched, and
37
+ cookie VALUES are never printed.
38
+
39
+ On a valid session the result also carries account context so onboarding can
40
+ set expectations: account_age_days + comment_karma from me.json, plus a
41
+ `warning` string when the account is fresh/low-karma (AutoMod silently gates
42
+ those in most subreddits). The warning NEVER blocks the connect.
43
+
44
+ The username is returned to the caller; the MCP server persists it into
45
+ config accounts.reddit.username through its own config-write path (this
46
+ script deliberately does NOT write config.json).
47
+
48
+ Output: a single JSON object on stdout. Human-readable notes go to stderr.
49
+
50
+ CLI:
51
+ python3 setup_reddit_auth.py status
52
+ python3 setup_reddit_auth.py detect-sources
53
+ python3 setup_reddit_auth.py connect [--source chrome:Default] [--no-launch]
54
+ [--manual-login] [--login-wait 300]
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import argparse
60
+ import json
61
+ import os
62
+ import socket
63
+ import subprocess
64
+ import sys
65
+ import time
66
+ import urllib.request
67
+ import urllib.error
68
+ from pathlib import Path
69
+
70
+ # websocket-client is needed for CDP cookie polling (connect). Deferred error,
71
+ # same as setup_twitter_auth.py: `detect-sources` is pure filesystem.
72
+ try:
73
+ from websocket import create_connection # websocket-client
74
+ _WEBSOCKET_IMPORT_ERROR = None
75
+ except ImportError:
76
+ create_connection = None # type: ignore[assignment]
77
+ _WEBSOCKET_IMPORT_ERROR = (
78
+ "websocket-client not installed (needed for CDP). pip install websocket-client"
79
+ )
80
+
81
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
82
+
83
+ # Shared, platform-agnostic helpers from the X bootstrap (headless detection,
84
+ # keychain preflight, import-error classification). Reused, not duplicated, so
85
+ # a fix there reaches both platforms. Guarded: a missing module never breaks
86
+ # reddit setup, we just degrade to coarse error strings.
87
+ try:
88
+ from setup_twitter_auth import ( # noqa: E402
89
+ _classify_import_error,
90
+ _is_headless,
91
+ _keychain_safe_storage_ok,
92
+ )
93
+ except Exception: # pragma: no cover - defensive
94
+ def _classify_import_error(detail): # type: ignore[misc]
95
+ return "unknown"
96
+
97
+ def _is_headless(): # type: ignore[misc]
98
+ return False
99
+
100
+ def _keychain_safe_storage_ok(browser_label="Chrome"): # type: ignore[misc]
101
+ return True, "probe unavailable"
102
+
103
+ # Vendored cookie copier - stdlib-only browser/profile detection plus the
104
+ # actual keychain-decrypt + CDP-inject copy. Same module connect_x uses,
105
+ # parameterized here by --domains reddit.com.
106
+ try:
107
+ import copy_browser_cookies as _cbc # noqa: E402
108
+ except Exception:
109
+ _cbc = None
110
+
111
+ # Same-origin me.json fetch through the logged-in harness page. This is the
112
+ # ONLY fetch path Reddit reliably 200s (bare urllib/curl 403s from residential
113
+ # IPs since 2026-05-28); it also yields to an active posting drain.
114
+ try:
115
+ from reddit_browser_fetch import browser_get_json # noqa: E402
116
+ except Exception:
117
+ browser_get_json = None
118
+
119
+ # --- Config -----------------------------------------------------------------
120
+
121
+ # Same managed Chrome the reddit pipeline uses (skill/lib/reddit-backend.sh).
122
+ CDP = os.environ.get(
123
+ "S4L_REDDIT_CDP_URL", os.environ.get("REDDIT_CDP_URL", "http://127.0.0.1:9557")
124
+ ).rstrip("/")
125
+ PORT = int(CDP.rsplit(":", 1)[-1]) if CDP.rsplit(":", 1)[-1].isdigit() else 9557
126
+ PROFILE_DIR = Path.home() / ".claude" / "browser-profiles" / "reddit-harness"
127
+ PID_FILE = Path.home() / ".claude" / "browser-profiles" / "reddit-harness.chrome.pid"
128
+
129
+ DOMAINS = "reddit.com"
130
+ ME_JSON_URL = "https://www.reddit.com/api/me.json"
131
+
132
+ VENDORED_COOKIE_SCRIPT = Path(__file__).resolve().parent / "copy_browser_cookies.py"
133
+
134
+ # Expectation thresholds for the fresh-account warning (advisory only; most
135
+ # subreddits' AutoMod removes comments from young / low-karma accounts).
136
+ FRESH_ACCOUNT_MIN_AGE_DAYS = 30
137
+ FRESH_ACCOUNT_MIN_COMMENT_KARMA = 100
138
+
139
+ _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
140
+ _CONFIG_JSON = os.path.join(_REPO_ROOT, "config.json")
141
+ _USERNAME_PLACEHOLDERS = {"", "your-reddit-username", "u/your-reddit-username"}
142
+
143
+
144
+ # --- Chrome lifecycle (mirrors setup_twitter_auth.py) ------------------------
145
+
146
+ def _port_open(port: int) -> bool:
147
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
148
+ s.settimeout(0.5)
149
+ try:
150
+ s.connect(("127.0.0.1", port))
151
+ return True
152
+ except OSError:
153
+ return False
154
+ finally:
155
+ s.close()
156
+
157
+
158
+ def _cdp_alive() -> bool:
159
+ if not _port_open(PORT):
160
+ return False
161
+ try:
162
+ with urllib.request.urlopen(f"{CDP}/json/version", timeout=1.5) as r:
163
+ return r.status == 200
164
+ except (urllib.error.URLError, TimeoutError, OSError):
165
+ return False
166
+
167
+
168
+ def _resolve_chrome_bin() -> "str | None":
169
+ env = os.environ.get("BH_CHROME_BIN")
170
+ if env and Path(env).exists():
171
+ return env
172
+ candidates = [
173
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
174
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
175
+ "/usr/bin/google-chrome",
176
+ "/usr/bin/google-chrome-stable",
177
+ "/usr/bin/chromium",
178
+ "/usr/bin/chromium-browser",
179
+ "/snap/bin/chromium",
180
+ ]
181
+ for p in candidates:
182
+ if Path(p).exists():
183
+ return p
184
+ import shutil
185
+ for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
186
+ found = shutil.which(name)
187
+ if found:
188
+ return found
189
+ return None
190
+
191
+
192
+ def _launch_chrome() -> bool:
193
+ """Launch the managed reddit-harness Chrome on PORT, ON-SCREEN.
194
+
195
+ On-screen (unlike the pipeline's off-screen parking) because during setup
196
+ the user may need to see this window to log in. Cookies persist on disk,
197
+ so later off-screen relaunches by the pipeline inherit the session.
198
+
199
+ IMPORTANT flag parity: skill/lib/reddit-backend.sh launches this profile
200
+ WITHOUT --password-store=basic / --use-mock-keychain (unlike the twitter
201
+ harness). Chrome encrypts the cookie SQLite with whichever key the launch
202
+ flags select; mixing keyed and mock-keychain launches on the SAME profile
203
+ makes Chrome unable to decrypt its own cookies and silently drops the
204
+ session. So this launcher must match the pipeline's flags exactly.
205
+ """
206
+ chrome = _resolve_chrome_bin()
207
+ if not chrome:
208
+ return False
209
+ cmd = [
210
+ chrome,
211
+ f"--remote-debugging-port={PORT}",
212
+ f"--user-data-dir={PROFILE_DIR}",
213
+ "--no-first-run",
214
+ "--no-default-browser-check",
215
+ "--disable-hang-monitor",
216
+ "--disable-features=ChromeWhatsNewUI,CalculateNativeWinOcclusion",
217
+ "--disable-backgrounding-occluded-windows",
218
+ ]
219
+ is_linux = sys.platform.startswith("linux")
220
+ has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
221
+ if is_linux:
222
+ cmd += ["--no-sandbox", "--disable-dev-shm-usage"]
223
+ if not has_display:
224
+ cmd += ["--headless=new", "--disable-gpu"]
225
+ else:
226
+ cmd += ["--window-position=80,80", "--window-size=1100,900"]
227
+ cmd.append("about:blank")
228
+ PROFILE_DIR.mkdir(parents=True, exist_ok=True)
229
+ # start_new_session: don't let a transient launchd caller's process-group
230
+ # SIGKILL reap this Chrome on exit (same fix as the backends, 2026-07-12).
231
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
232
+ start_new_session=True)
233
+ try:
234
+ PID_FILE.write_text(str(proc.pid))
235
+ except OSError:
236
+ pass
237
+ # 40s poll window, wider than the twitter helper's 15s: the FIRST reddit
238
+ # connect creates the harness profile from scratch, and on a cold box that
239
+ # first launch takes >15s to bind the debug port (observed on the QA box
240
+ # 2026-07-15: ensure_chrome gave up at 15s, Chrome bound seconds later and
241
+ # the flow misreported browser_launch_failed).
242
+ for _ in range(40):
243
+ if _cdp_alive():
244
+ return True
245
+ time.sleep(1)
246
+ return _cdp_alive()
247
+
248
+
249
+ def ensure_chrome(launch: bool = True) -> bool:
250
+ if _cdp_alive():
251
+ return True
252
+ if not launch:
253
+ return False
254
+ return _launch_chrome()
255
+
256
+
257
+ # --- CDP attach (cookie polling + window control) ----------------------------
258
+
259
+ def _attach():
260
+ targets = json.load(urllib.request.urlopen(f"{CDP}/json", timeout=10))
261
+ page = next((t for t in targets if t.get("type") == "page"), None)
262
+ if not page:
263
+ page = json.load(urllib.request.urlopen(
264
+ urllib.request.Request(f"{CDP}/json/new?about:blank", method="PUT"), timeout=10))
265
+ ws = create_connection(page["webSocketDebuggerUrl"], timeout=20, suppress_origin=True)
266
+ state = {"id": 0}
267
+
268
+ def send(method, params=None):
269
+ state["id"] += 1
270
+ ws.send(json.dumps({"id": state["id"], "method": method, "params": params or {}}))
271
+ while True:
272
+ msg = json.loads(ws.recv())
273
+ if msg.get("id") == state["id"]:
274
+ return msg
275
+ return ws, send
276
+
277
+
278
+ def _has_reddit_session_cookie(send) -> bool:
279
+ # reddit_session ONLY: token_v2 is an anonymous-session JWT Reddit sets for
280
+ # every visitor (logged in or not), so keying on it false-positives the
281
+ # moment the harness merely VISITS reddit.com (observed on the QA box
282
+ # 2026-07-15). reddit_session exists only for a logged-in account.
283
+ r = send("Network.getAllCookies")
284
+ cks = r.get("result", {}).get("cookies", []) or []
285
+ return any(
286
+ c.get("name") == "reddit_session"
287
+ and "reddit.com" in (c.get("domain") or "")
288
+ for c in cks
289
+ )
290
+
291
+
292
+ def _has_session_quick() -> bool:
293
+ """Read-only: reddit_session cookie present in the live browser?
294
+ Never navigates, so it's safe while a pipeline run is active. A revoked
295
+ cookie can false-positive; the me.json validation is authoritative."""
296
+ ws, send = _attach()
297
+ try:
298
+ send("Network.enable")
299
+ return _has_reddit_session_cookie(send)
300
+ finally:
301
+ try:
302
+ ws.close()
303
+ except Exception:
304
+ pass
305
+
306
+
307
+ # --- me.json validation -------------------------------------------------------
308
+
309
+ def _fetch_me() -> "dict | None":
310
+ """Fetch api/me.json through the logged-in harness page (same-origin fetch,
311
+ the only transport Reddit reliably 200s). Returns the parsed `data` dict on
312
+ a logged-in session, {} when logged out, None on transport failure."""
313
+ if browser_get_json is None:
314
+ return None
315
+ body, status = browser_get_json(ME_JSON_URL, cdp_url=CDP)
316
+ if status != 200 or not body:
317
+ return None
318
+ try:
319
+ j = json.loads(body)
320
+ except json.JSONDecodeError:
321
+ return None
322
+ if not isinstance(j, dict):
323
+ return None
324
+ data = j.get("data")
325
+ return data if isinstance(data, dict) else {}
326
+
327
+
328
+ def _account_fields(data: dict) -> dict:
329
+ """Account context for onboarding expectations: username, age, karma, and a
330
+ non-blocking warning for fresh/low-karma accounts (AutoMod gates those)."""
331
+ name = (data.get("name") or "").strip()
332
+ created = data.get("created_utc") or data.get("created")
333
+ age_days = None
334
+ try:
335
+ if created:
336
+ age_days = max(0, int((time.time() - float(created)) / 86400))
337
+ except (TypeError, ValueError):
338
+ age_days = None
339
+ comment_karma = data.get("comment_karma")
340
+ total_karma = data.get("total_karma", data.get("link_karma"))
341
+ warning = None
342
+ reasons = []
343
+ if age_days is not None and age_days < FRESH_ACCOUNT_MIN_AGE_DAYS:
344
+ reasons.append(f"the account is only {age_days} day(s) old")
345
+ if isinstance(comment_karma, (int, float)) and comment_karma < FRESH_ACCOUNT_MIN_COMMENT_KARMA:
346
+ reasons.append(f"it has {int(comment_karma)} comment karma")
347
+ if reasons:
348
+ warning = (
349
+ "Heads up: " + " and ".join(reasons) + ". Most subreddits' AutoMod "
350
+ "silently removes or holds comments from fresh, low-karma accounts, so "
351
+ "early replies may not be visible to others. This does not block setup; "
352
+ "expect slower traction until the account ages and earns karma "
353
+ "(participating manually for a few weeks helps)."
354
+ )
355
+ return {
356
+ "username": name or None,
357
+ "account_age_days": age_days,
358
+ "comment_karma": int(comment_karma) if isinstance(comment_karma, (int, float)) else None,
359
+ "total_karma": int(total_karma) if isinstance(total_karma, (int, float)) else None,
360
+ "suspended": bool(data.get("is_suspended")),
361
+ "warning": warning,
362
+ }
363
+
364
+
365
+ def _validate_session() -> "dict | None":
366
+ """Authoritative check: me.json through the live harness. Returns the
367
+ account-fields dict when logged in with a usable account, or None."""
368
+ data = _fetch_me()
369
+ if not data: # None (transport fail) or {} (logged out)
370
+ return None
371
+ fields = _account_fields(data)
372
+ if not fields["username"]:
373
+ return None
374
+ return fields
375
+
376
+
377
+ # --- On-disk session detection (no keychain, no decryption) -------------------
378
+
379
+ def _cookies_db_path() -> "Path | None":
380
+ candidates = [
381
+ PROFILE_DIR / "Default" / "Network" / "Cookies",
382
+ PROFILE_DIR / "Default" / "Cookies",
383
+ ]
384
+ existing = [p for p in candidates if p.exists()]
385
+ if not existing:
386
+ return None
387
+ return max(existing, key=lambda p: p.stat().st_mtime)
388
+
389
+
390
+ def _profile_dir_has_session() -> bool:
391
+ """True when the HARNESS profile's on-disk Cookies SQLite has a
392
+ reddit_session row (login cookie ONLY; token_v2 is set for anonymous
393
+ visitors too). Presence-only (value stays encrypted), so no
394
+ macOS Safe Storage prompt. Lets `status` report connected_idle while the
395
+ harness Chrome is down (the pipeline relaunches it with the same profile)."""
396
+ if _cbc is None:
397
+ return False
398
+ db = _cookies_db_path()
399
+ if db is None:
400
+ return False
401
+ tmp = _cbc.copy_db(db)
402
+ if tmp is None:
403
+ return False
404
+ import shutil
405
+ import sqlite3
406
+ try:
407
+ conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
408
+ try:
409
+ row = conn.execute(
410
+ "SELECT 1 FROM cookies WHERE name = 'reddit_session' "
411
+ "AND host_key LIKE '%reddit.com' LIMIT 1"
412
+ ).fetchone()
413
+ return row is not None
414
+ finally:
415
+ conn.close()
416
+ except Exception:
417
+ return False
418
+ finally:
419
+ shutil.rmtree(tmp.parent, ignore_errors=True)
420
+
421
+
422
+ def _configured_username() -> "str | None":
423
+ """accounts.reddit.username from config.json (read-only), or None if empty /
424
+ placeholder. Reads through the same file account_resolver reads."""
425
+ try:
426
+ with open(_CONFIG_JSON, encoding="utf-8") as f:
427
+ cfg = json.load(f)
428
+ u = ((cfg.get("accounts") or {}).get("reddit") or {}).get("username") or ""
429
+ except Exception:
430
+ return None
431
+ u = u.strip()
432
+ if u.lower() in _USERNAME_PLACEHOLDERS:
433
+ return None
434
+ if u.lower().startswith("u/"):
435
+ u = u[2:]
436
+ return u or None
437
+
438
+
439
+ # --- Source detection ---------------------------------------------------------
440
+
441
+ def _profile_has_reddit_session(profile) -> bool:
442
+ """True if `profile`'s Cookies DB has a reddit.com session cookie row.
443
+ Filesystem + SQLite only; no keychain prompt."""
444
+ if _cbc is None:
445
+ return False
446
+ cookies_path = profile.path / "Cookies"
447
+ if not cookies_path.exists():
448
+ nested = profile.path / "Network" / "Cookies"
449
+ cookies_path = nested if nested.exists() else cookies_path
450
+ if not cookies_path.exists():
451
+ return False
452
+ tmp = _cbc.copy_db(cookies_path)
453
+ if tmp is None:
454
+ return False
455
+ import shutil
456
+ import sqlite3
457
+ try:
458
+ conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
459
+ try:
460
+ row = conn.execute(
461
+ "SELECT 1 FROM cookies WHERE name = 'reddit_session' "
462
+ "AND host_key LIKE '%reddit.com' LIMIT 1"
463
+ ).fetchone()
464
+ return row is not None
465
+ finally:
466
+ conn.close()
467
+ except Exception:
468
+ return False
469
+ finally:
470
+ shutil.rmtree(tmp.parent, ignore_errors=True)
471
+
472
+
473
+ def _list_sources() -> "list[dict]":
474
+ if _cbc is None:
475
+ return []
476
+ out: "list[dict]" = []
477
+ for p in _cbc.detect_browsers():
478
+ out.append({
479
+ "spec": f"{p.browser}:{p.name}",
480
+ "browser": p.browser,
481
+ "profile": p.name,
482
+ "label": f"{p.browser.capitalize()} - {p.name}",
483
+ "reddit_session": _profile_has_reddit_session(p),
484
+ })
485
+ out.sort(key=lambda s: (s["browser"] != "chrome", not s["reddit_session"]))
486
+ return out
487
+
488
+
489
+ def cmd_detect_sources(args) -> dict:
490
+ sources = _list_sources()
491
+ recommended = next((s["spec"] for s in sources if s["reddit_session"]), None)
492
+ if not recommended:
493
+ recommended = next((s["spec"] for s in sources if s["spec"] == "chrome:Default"),
494
+ sources[0]["spec"] if sources else "chrome:Default")
495
+ return {"ok": True, "sources": sources, "recommended": recommended}
496
+
497
+
498
+ # --- Cookie import -------------------------------------------------------------
499
+
500
+ def _import_from(source: str) -> dict:
501
+ """Copy reddit.com cookies from `source` into the managed harness Chrome via
502
+ the vendored copier. Values are never surfaced; the copier prints counts."""
503
+ if not VENDORED_COOKIE_SCRIPT.exists():
504
+ return {
505
+ "ok": False,
506
+ "error": f"vendored cookie copier missing at {VENDORED_COOKIE_SCRIPT}",
507
+ }
508
+ cmd = [
509
+ sys.executable, str(VENDORED_COOKIE_SCRIPT), "copy",
510
+ "--from", source, "--to", CDP, "--domains", DOMAINS,
511
+ ]
512
+ # Keychain auth dialogs need human time; same generous window as connect_x.
513
+ _raw_to = os.environ.get("S4L_COOKIE_COPY_TIMEOUT", "600").strip()
514
+ try:
515
+ copy_timeout = float(_raw_to) if _raw_to else None
516
+ except ValueError:
517
+ copy_timeout = 600.0
518
+ if copy_timeout is not None and copy_timeout <= 0:
519
+ copy_timeout = None
520
+ try:
521
+ proc = subprocess.run(
522
+ cmd, capture_output=True, text=True, timeout=copy_timeout,
523
+ cwd=str(VENDORED_COOKIE_SCRIPT.parent),
524
+ )
525
+ except subprocess.TimeoutExpired:
526
+ _to_label = f"{copy_timeout:g}s" if copy_timeout is not None else "no limit"
527
+ return {"ok": False, "error": f"cookie copy from {source} timed out ({_to_label})"}
528
+ return {
529
+ "ok": proc.returncode == 0,
530
+ "returncode": proc.returncode,
531
+ "stdout": proc.stdout.strip(),
532
+ "stderr": proc.stderr.strip(),
533
+ }
534
+
535
+
536
+ # --- Manual login fallback ------------------------------------------------------
537
+
538
+ def _show_window_and_open_login() -> bool:
539
+ """Make the harness Chrome window visible + focused on the Reddit login page
540
+ so the user can sign in by hand. Mirrors the X manual-login discipline."""
541
+ try:
542
+ ws, send = _attach()
543
+ except Exception:
544
+ return False
545
+ try:
546
+ try:
547
+ win = send("Browser.getWindowForTarget")
548
+ win_id = (win.get("result", {}) or {}).get("windowId")
549
+ if win_id is not None:
550
+ send("Browser.setWindowBounds",
551
+ {"windowId": win_id, "bounds": {"windowState": "normal"}})
552
+ send("Browser.setWindowBounds",
553
+ {"windowId": win_id,
554
+ "bounds": {"left": 80, "top": 80, "width": 1100, "height": 900}})
555
+ except Exception:
556
+ pass
557
+ try:
558
+ send("Page.enable")
559
+ send("Page.navigate", {"url": "https://www.reddit.com/login/"})
560
+ send("Page.bringToFront")
561
+ return True
562
+ except Exception:
563
+ return False
564
+ finally:
565
+ try:
566
+ ws.close()
567
+ except Exception:
568
+ pass
569
+
570
+
571
+ def _poll_for_login(timeout: float = 90.0, interval: float = 2.0) -> bool:
572
+ """Block until the user finishes a MANUAL login (session cookie appears) or
573
+ the bounded window elapses. Read-only polling; never navigates, so it can't
574
+ disrupt the login flow. Prevents the caller re-checking faster than a human
575
+ can type a password + 2FA (the detection race connect_x had)."""
576
+ try:
577
+ ws, send = _attach()
578
+ except Exception:
579
+ return False
580
+ try:
581
+ send("Network.enable")
582
+ deadline = time.time() + max(0.0, timeout)
583
+ while True:
584
+ try:
585
+ if _has_reddit_session_cookie(send):
586
+ return True
587
+ except Exception:
588
+ pass
589
+ if time.time() >= deadline:
590
+ return False
591
+ time.sleep(max(0.5, interval))
592
+ finally:
593
+ try:
594
+ ws.close()
595
+ except Exception:
596
+ pass
597
+
598
+
599
+ # --- Commands -------------------------------------------------------------------
600
+
601
+ def _connected_result(state: str, fields: dict, source: "str | None" = None,
602
+ note: "str | None" = None, attempts: "list | None" = None) -> dict:
603
+ out = {
604
+ "ok": True,
605
+ "connected": True,
606
+ "state": state,
607
+ "username": fields.get("username"),
608
+ "account_age_days": fields.get("account_age_days"),
609
+ "comment_karma": fields.get("comment_karma"),
610
+ "total_karma": fields.get("total_karma"),
611
+ "warning": fields.get("warning"),
612
+ "cdp": CDP,
613
+ }
614
+ if source:
615
+ out["source"] = source
616
+ if note:
617
+ out["note"] = note
618
+ if attempts:
619
+ out["attempts"] = attempts
620
+ return out
621
+
622
+
623
+ def _posting_drain_active() -> bool:
624
+ """True while a poster is mid-drain (fresh reddit-posting-active.json).
625
+ status must not grab the shared harness tab out from under it, and a live
626
+ drain is itself proof of a working session."""
627
+ flag = os.path.join(
628
+ os.path.expanduser(os.environ.get("S4L_STATE_DIR") or "~/.social-autoposter-mcp"),
629
+ "reddit-posting-active.json",
630
+ )
631
+ try:
632
+ return (time.time() - os.path.getmtime(flag)) < 120
633
+ except OSError:
634
+ return False
635
+
636
+
637
+ def cmd_status(args) -> dict:
638
+ # An active posting drain owns the one shared harness tab; don't navigate it
639
+ # (and don't sit in reddit_browser_fetch's 5-minute yield inside a status
640
+ # probe). The drain posting right now IS a valid session.
641
+ if _posting_drain_active():
642
+ return {
643
+ "ok": True,
644
+ "connected": True,
645
+ "state": "connected_idle",
646
+ "username": _configured_username(),
647
+ "note": "Reddit posting is actively draining right now (which requires a "
648
+ "valid session); skipped the live me.json probe to leave the browser tab "
649
+ "to the poster.",
650
+ "cdp": CDP,
651
+ }
652
+ if not ensure_chrome(launch=False):
653
+ # Harness down. The persistent profile IS the durability layer for
654
+ # reddit (the pipeline relaunches this exact profile), so trust an
655
+ # on-disk session cookie row instead of demanding a live browser.
656
+ if _profile_dir_has_session():
657
+ return {
658
+ "ok": True,
659
+ "connected": True,
660
+ "state": "connected_idle",
661
+ "username": _configured_username(),
662
+ "note": "Reddit is connected (session saved in the harness profile). "
663
+ "The reddit browser isn't running this moment; the next pipeline "
664
+ "run relaunches it with the same profile.",
665
+ "cdp": CDP,
666
+ }
667
+ return {
668
+ "ok": True,
669
+ "connected": False,
670
+ "state": "browser_not_running",
671
+ "username": None,
672
+ "note": "The autoposter's reddit browser isn't running yet. Run "
673
+ "connect_reddit to start it and check/import your session.",
674
+ "cdp": CDP,
675
+ }
676
+ # Live browser is up: read-only reddit_session cookie check. Deliberately
677
+ # NOT the me.json validation: status is polled by the snapshot/menubar and
678
+ # the kicker gate, and a me.json fetch navigates the ONE shared harness tab
679
+ # (fighting discovery/posting). The authoritative me.json validation runs
680
+ # inside `connect` (already-valid short-circuit, post-import, post-login).
681
+ # A revoked-but-present cookie can false-positive here; the next connect or
682
+ # pipeline run surfaces that loudly.
683
+ try:
684
+ quick = _has_session_quick()
685
+ except Exception as e:
686
+ return {"ok": False, "connected": False, "state": "error",
687
+ "username": None, "error": str(e), "cdp": CDP}
688
+ if quick:
689
+ return {
690
+ "ok": True,
691
+ "connected": True,
692
+ "state": "connected",
693
+ "username": _configured_username(),
694
+ "cdp": CDP,
695
+ }
696
+ if _profile_dir_has_session():
697
+ return {
698
+ "ok": True,
699
+ "connected": True,
700
+ "state": "connected_idle",
701
+ "username": _configured_username(),
702
+ "note": "A reddit session exists in the harness profile on disk; the "
703
+ "live browser has not loaded it into memory yet.",
704
+ "cdp": CDP,
705
+ }
706
+ return {"ok": True, "connected": False, "state": "logged_out",
707
+ "username": None, "cdp": CDP}
708
+
709
+
710
+ def cmd_connect(args) -> dict:
711
+ if not ensure_chrome(launch=not args.no_launch):
712
+ return {
713
+ "ok": False,
714
+ "connected": False,
715
+ "state": "browser_launch_failed",
716
+ "error": "Could not start the managed reddit Chrome (no Chrome/Chromium "
717
+ "found, or it failed to bind the debug port). Set BH_CHROME_BIN to your "
718
+ "Chrome path.",
719
+ "cdp": CDP,
720
+ }
721
+
722
+ # 1. Already logged in? Nothing to import.
723
+ fields = _validate_session()
724
+ if fields is not None and fields.get("suspended"):
725
+ return {
726
+ "ok": True,
727
+ "connected": False,
728
+ "state": "suspended",
729
+ "username": fields.get("username"),
730
+ "note": "This reddit account is suspended; posting is not possible.",
731
+ "cdp": CDP,
732
+ }
733
+ if fields is not None:
734
+ return _connected_result(
735
+ "connected", fields, source="existing_session",
736
+ note="Reddit is already connected in the autoposter's reddit browser; "
737
+ "nothing imported.")
738
+
739
+ # 1b. Choose the path (import vs manual login) before any keychain work.
740
+ if args.source == "all":
741
+ sources = ["chrome:Default", "arc:Default", "brave:Default", "edge:Default"]
742
+ has_importable = True
743
+ elif args.source:
744
+ sources = [args.source]
745
+ has_importable = True
746
+ else:
747
+ _srcs = _list_sources()
748
+ _with_session = [s["spec"] for s in _srcs if s.get("reddit_session")]
749
+ sources = _with_session or ["chrome:Default"]
750
+ has_importable = bool(_with_session)
751
+
752
+ manual_login = bool(getattr(args, "manual_login", False))
753
+ will_import = has_importable and not manual_login
754
+
755
+ # 1c. Headless keychain preflight, import path only (manual login reads no
756
+ # keychain and must never be blocked by this).
757
+ if will_import and _is_headless():
758
+ kc_ok, kc_detail = _keychain_safe_storage_ok("Chrome")
759
+ if not kc_ok:
760
+ return {
761
+ "ok": True,
762
+ "connected": False,
763
+ "state": "keychain_locked",
764
+ "error_type": "keychain_locked",
765
+ "headless": True,
766
+ "keychain_detail": kc_detail,
767
+ "note": (
768
+ "Cookie import requires reading the browser's Safe Storage from "
769
+ "the macOS Keychain, but this process can't access it (probably "
770
+ "running over SSH or another headless context). Run this once in "
771
+ "the same session:\n"
772
+ " security unlock-keychain ~/Library/Keychains/login.keychain-db\n"
773
+ "Then re-run connect_reddit. Or connect Reddit with manual login "
774
+ "instead; that signs in directly in the autoposter's own browser "
775
+ "and needs no keychain."
776
+ ),
777
+ "remediation_cmd": "security unlock-keychain ~/Library/Keychains/login.keychain-db",
778
+ "cdp": CDP,
779
+ }
780
+
781
+ # 2. Import from the user's everyday browser.
782
+ attempts = []
783
+ for src in (sources if will_import else []):
784
+ res = _import_from(src)
785
+ copied = res.get("stdout", "")
786
+ detail = copied or res.get("error") or res.get("stderr")
787
+ error_type = None if res.get("ok") else _classify_import_error(detail)
788
+ attempts.append({
789
+ "source": src,
790
+ "ok": res.get("ok"),
791
+ "detail": detail,
792
+ "error_type": error_type,
793
+ })
794
+ if not res.get("ok"):
795
+ continue
796
+ fields = _validate_session()
797
+ if fields is not None and not fields.get("suspended"):
798
+ return _connected_result(
799
+ "imported", fields, source=src, attempts=attempts,
800
+ note=f"Imported your Reddit session from {src} into the autoposter's "
801
+ "reddit browser. The session persists in the harness profile on "
802
+ "disk, so pipeline relaunches inherit it.")
803
+ if fields is not None and fields.get("suspended"):
804
+ return {
805
+ "ok": True,
806
+ "connected": False,
807
+ "state": "suspended",
808
+ "username": fields.get("username"),
809
+ "attempts": attempts,
810
+ "note": "The imported reddit account is suspended; posting is not possible.",
811
+ "cdp": CDP,
812
+ }
813
+
814
+ # 3. Could not establish a valid session automatically.
815
+ distinct_error_types = {a.get("error_type") for a in attempts if a.get("error_type")}
816
+ rolled_up_error_type = (
817
+ next(iter(distinct_error_types)) if len(distinct_error_types) == 1 else None
818
+ )
819
+ open_login = (
820
+ manual_login
821
+ or not will_import
822
+ or rolled_up_error_type == "keychain_acl_denied"
823
+ )
824
+
825
+ shown = False
826
+ if open_login:
827
+ shown = _show_window_and_open_login()
828
+ login_wait = getattr(args, "login_wait", 300.0)
829
+ if login_wait and login_wait > 0 and _poll_for_login(timeout=login_wait):
830
+ fields = _validate_session()
831
+ if fields is not None and not fields.get("suspended"):
832
+ return _connected_result(
833
+ "connected", fields, source="manual_login", attempts=attempts,
834
+ note="You logged in manually; the autoposter detected the live "
835
+ "reddit session in its own browser profile.")
836
+
837
+ if rolled_up_error_type == "keychain_acl_denied":
838
+ note = (
839
+ "It looks like you clicked Deny (or Cancel) on the macOS Keychain prompt. "
840
+ "To import your Reddit session automatically, the autoposter needs to read "
841
+ "your browser's \"Safe Storage\" key from your Keychain. Re-run "
842
+ "connect_reddit and click Allow (or Always Allow) and the import will "
843
+ "finish on its own. If you'd rather not grant keychain access, there's "
844
+ "already a Chrome window open at the Reddit login page"
845
+ + ("" if shown else " (look for a 'Google Chrome' window)")
846
+ + "; just log in there by hand and ask me to re-check. "
847
+ "(Auto-import tried: " + ", ".join(sources) + ".)"
848
+ )
849
+ extra = {"remediation": "rerun_connect_reddit_and_click_allow"}
850
+ elif open_login:
851
+ _why = (
852
+ "" if will_import
853
+ else "No existing Reddit session was found in your everyday browser to import, so "
854
+ )
855
+ _tried = (" (Auto-import tried: " + ", ".join(sources) + ".)") if will_import else ""
856
+ note = (
857
+ _why
858
+ + "a Chrome window for the autoposter is open at the Reddit login page"
859
+ + ("" if shown else " (if you don't see it, look for a 'Google Chrome' window)")
860
+ + " and you are NOT logged in yet. Log in there yourself (username, password, "
861
+ "and 2FA if prompted) in that window. When your Reddit home feed shows, ask me "
862
+ "to confirm and I'll re-check (run connect_reddit again). The session is saved "
863
+ "to the autoposter's own profile, so this is a one-time step." + _tried
864
+ )
865
+ extra = {}
866
+ else:
867
+ note = (
868
+ "Couldn't import a Reddit session automatically (auto-import tried: "
869
+ + ", ".join(sources) + "). This usually means you're not logged into Reddit "
870
+ "in your everyday browser, so there was no session to copy. I did NOT open "
871
+ "a login window. If you want to sign in by hand, ask me to connect Reddit "
872
+ "with manual login and I'll open a focused Reddit login page for you."
873
+ )
874
+ extra = {"manual_login_hint": "rerun_connect_reddit_with_manual_login"}
875
+ return {
876
+ "ok": True,
877
+ "connected": False,
878
+ "state": "needs_login",
879
+ "username": None,
880
+ "error_type": rolled_up_error_type,
881
+ "attempts": attempts,
882
+ "login_window_opened": shown,
883
+ "note": note,
884
+ "profile_dir": str(PROFILE_DIR),
885
+ "cdp": CDP,
886
+ **extra,
887
+ }
888
+
889
+
890
+ def main() -> int:
891
+ ap = argparse.ArgumentParser(description="Reddit session bootstrap for MCP setup.")
892
+ sub = ap.add_subparsers(dest="cmd", required=True)
893
+ sub.add_parser("status", help="Report whether the managed reddit session is valid.")
894
+ sub.add_parser("detect-sources",
895
+ help="List browsers/profiles to import the Reddit session from "
896
+ "(JSON). No keychain prompt.")
897
+ c = sub.add_parser("connect", help="Ensure browser + import/validate the Reddit session.")
898
+ c.add_argument("--source", default=None,
899
+ help="Browser profile to import from (e.g. chrome:Default, arc:Default), "
900
+ "or 'all' for the full sweep. Default: auto-pick the browser that "
901
+ "actually holds a reddit.com session.")
902
+ c.add_argument("--no-launch", action="store_true",
903
+ help="Do not launch Chrome if it's down (probe only).")
904
+ c.add_argument("--manual-login", action="store_true",
905
+ help="Explicitly opt into manual login: open a focused Reddit login "
906
+ "window and wait for the user to sign in by hand.")
907
+ c.add_argument("--login-wait", type=float, default=300.0,
908
+ help="Seconds to wait for a MANUAL login to complete before "
909
+ "returning needs_login (default 300; 0 disables the wait).")
910
+ args = ap.parse_args()
911
+
912
+ if args.cmd == "detect-sources":
913
+ out = cmd_detect_sources(args)
914
+ elif _WEBSOCKET_IMPORT_ERROR is not None and args.cmd != "status":
915
+ out = {"ok": False, "state": "error", "error": _WEBSOCKET_IMPORT_ERROR}
916
+ elif args.cmd == "status":
917
+ out = cmd_status(args)
918
+ else:
919
+ out = cmd_connect(args)
920
+ print(json.dumps(out, indent=2))
921
+ return 0
922
+
923
+
924
+ if __name__ == "__main__":
925
+ sys.exit(main())