@m13v/s4l 1.6.197-rc.7

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 (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1314 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +497 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,1320 @@
1
+ #!/usr/bin/env python3
2
+ """setup_twitter_auth.py - Twitter/X session bootstrap for the MCP setup flow.
3
+
4
+ Used by the social-autoposter MCP `setup` tool (action=connect_x) to give a
5
+ brand-new user a logged-in X session in the autoposter's managed browser WITHOUT
6
+ making them paste cookies or hand-edit anything.
7
+
8
+ It answers the three questions the setup flow needs:
9
+ 1. Do cookies already exist in the managed browser? (is it logged in?)
10
+ 2. Are they still valid? (auth_token present after
11
+ a real x.com/home load)
12
+ 3. Does the user need to re-log in manually? (import failed / no source)
13
+
14
+ How it works
15
+ ------------
16
+ The autoposter posts through a managed REAL Google Chrome on CDP port 9555 with a
17
+ persistent profile at ~/.claude/browser-profiles/browser-harness (same Chrome the
18
+ twitter-harness pipeline drives). This helper:
19
+
20
+ status - probe that Chrome; if up, report whether the X session is valid.
21
+ connect - ensure that Chrome is running; if the X session is already valid,
22
+ no-op; otherwise IMPORT x.com/twitter.com cookies from the user's
23
+ everyday browser (Chrome/Arc/Brave/Edge, auto-detected) via
24
+ ai_browser_profile.cookies, then re-validate. If still logged out,
25
+ report needs_login so the caller can ask the user to sign in once in
26
+ the (now on-screen) managed Chrome window.
27
+
28
+ Only x.com + twitter.com cookies are copied. No other site's session is touched,
29
+ and cookie VALUES are never printed.
30
+
31
+ Output: a single JSON object on stdout. Human-readable notes go to stderr.
32
+
33
+ CLI:
34
+ python3 setup_twitter_auth.py status
35
+ python3 setup_twitter_auth.py connect [--source chrome:Default] [--no-launch]
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import json
42
+ import os
43
+ import socket
44
+ import subprocess
45
+ import sys
46
+ import time
47
+ import urllib.request
48
+ import urllib.error
49
+ from pathlib import Path
50
+
51
+ # websocket-client is needed for CDP (status/connect). It is NOT needed for
52
+ # `detect-sources` (pure filesystem), so don't hard-exit at import time — defer
53
+ # the error to the commands that actually attach to Chrome.
54
+ try:
55
+ from websocket import create_connection # websocket-client
56
+ _WEBSOCKET_IMPORT_ERROR = None
57
+ except ImportError:
58
+ create_connection = None # type: ignore[assignment]
59
+ _WEBSOCKET_IMPORT_ERROR = (
60
+ "websocket-client not installed (needed for CDP). pip install websocket-client"
61
+ )
62
+
63
+ # Live-handle resolver (best-effort). Lets connect_x record the real logged-in
64
+ # @handle alongside the locally-mirrored cookies. Guarded so a missing dep never
65
+ # breaks setup.
66
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
67
+ try:
68
+ from twitter_account import resolve_handle # noqa: E402
69
+ except Exception:
70
+ resolve_handle = None
71
+
72
+ # Local 0600 cookie mirror — the keychain-independent durability layer (Gap B).
73
+ # Always importable (stdlib only); guarded so a path quirk never breaks setup.
74
+ try:
75
+ import twitter_cookie_mirror # noqa: E402
76
+ except Exception:
77
+ twitter_cookie_mirror = None
78
+
79
+ # Vendored cookie copier — also gives us stdlib-only browser/profile detection
80
+ # (detect_browsers, copy_db) used to (a) pick the RIGHT browser to import from so
81
+ # we trigger exactly ONE keychain prompt, and (b) populate the panel's
82
+ # "import from" dropdown. These helpers touch the filesystem only (no keychain
83
+ # read, no decryption), so importing/using them never shows a Safe Storage prompt.
84
+ try:
85
+ import copy_browser_cookies as _cbc # noqa: E402
86
+ except Exception:
87
+ _cbc = None
88
+
89
+ # --- Config -----------------------------------------------------------------
90
+
91
+ # Same managed Chrome the twitter-harness pipeline uses (skill/lib/twitter-backend.sh).
92
+ CDP = os.environ.get("S4L_TWITTER_CDP_URL", os.environ.get("TWITTER_CDP_URL", "http://127.0.0.1:9555")).rstrip("/")
93
+ PORT = int(CDP.rsplit(":", 1)[-1]) if CDP.rsplit(":", 1)[-1].isdigit() else 9555
94
+ PROFILE_DIR = Path.home() / ".claude" / "browser-profiles" / "browser-harness"
95
+ # Same PID file server.py (the twitter-harness MCP) writes, so a Chrome launched
96
+ # here is tracked and reapable by bh_stop instead of becoming an orphan that
97
+ # strands the debug port.
98
+ PID_FILE = Path.home() / ".claude" / "browser-profiles" / "browser-harness.chrome.pid"
99
+
100
+ # Browsers ai_browser_profile.cookies can read from, in auto-detect priority.
101
+ AUTO_SOURCES = ["chrome:Default", "arc:Default", "brave:Default", "edge:Default"]
102
+ DOMAINS = "x.com,twitter.com"
103
+
104
+ # Primary cookie copier: a self-contained, dependency-light script that ships
105
+ # WITH this repo (deps already in requirements.txt: cryptography +
106
+ # websocket-client). This is what makes the auto-import work on a fresh install.
107
+ VENDORED_COOKIE_SCRIPT = Path(__file__).resolve().parent / "copy_browser_cookies.py"
108
+
109
+ # Legacy fallback: the separate ~/ai-browser-profile project. Only present on
110
+ # the maintainer's dev box; never installed on a customer machine. Kept solely
111
+ # so nothing regresses there if the vendored script is somehow missing.
112
+ ABP_PYTHON = Path.home() / "ai-browser-profile" / ".venv" / "bin" / "python"
113
+
114
+
115
+ # --- Chrome lifecycle -------------------------------------------------------
116
+
117
+ def _port_open(port: int) -> bool:
118
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
119
+ s.settimeout(0.5)
120
+ try:
121
+ s.connect(("127.0.0.1", port))
122
+ return True
123
+ except OSError:
124
+ return False
125
+ finally:
126
+ s.close()
127
+
128
+
129
+ def _cdp_alive() -> bool:
130
+ if not _port_open(PORT):
131
+ return False
132
+ try:
133
+ with urllib.request.urlopen(f"{CDP}/json/version", timeout=1.5) as r:
134
+ return r.status == 200
135
+ except (urllib.error.URLError, TimeoutError, OSError):
136
+ return False
137
+
138
+
139
+ def _resolve_chrome_bin() -> str | None:
140
+ env = os.environ.get("BH_CHROME_BIN")
141
+ if env and Path(env).exists():
142
+ return env
143
+ candidates = [
144
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
145
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
146
+ "/usr/bin/google-chrome",
147
+ "/usr/bin/google-chrome-stable",
148
+ "/usr/bin/chromium",
149
+ "/usr/bin/chromium-browser",
150
+ "/snap/bin/chromium",
151
+ ]
152
+ for p in candidates:
153
+ if Path(p).exists():
154
+ return p
155
+ import shutil
156
+ for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
157
+ found = shutil.which(name)
158
+ if found:
159
+ return found
160
+ return None
161
+
162
+
163
+ def _launch_chrome() -> bool:
164
+ """Launch the managed Chrome on PORT, ON-SCREEN (so manual login is possible).
165
+
166
+ Deliberately does NOT use the off-screen window-position the cron pipeline
167
+ uses (BH_WINDOW_POS 3042,-1032 is a multi-monitor placement); during setup
168
+ the user may need to see this window to log in. Cookies persist on disk, so
169
+ later headless/off-screen relaunches by the pipeline inherit the session.
170
+ """
171
+ chrome = _resolve_chrome_bin()
172
+ if not chrome:
173
+ return False
174
+ cmd = [
175
+ chrome,
176
+ f"--remote-debugging-port={PORT}",
177
+ f"--user-data-dir={PROFILE_DIR}",
178
+ "--no-first-run",
179
+ "--no-default-browser-check",
180
+ # Encrypt the cookie store with Chrome's fixed obfuscation key instead of
181
+ # the macOS Keychain ("Chrome Safe Storage"). Without this, a keychain
182
+ # lock/re-lock leaves Chrome unable to decrypt its Cookies SQLite on the
183
+ # next launch and the imported session is discarded. Must match the cycle
184
+ # launcher (skill/lib/twitter-backend.sh) so the session connected here
185
+ # actually survives the pipeline's later relaunches. (Persistence fix,
186
+ # 2026-06-02.)
187
+ "--password-store=basic",
188
+ "--use-mock-keychain",
189
+ "--disable-features=ChromeWhatsNewUI",
190
+ ]
191
+ is_linux = sys.platform.startswith("linux")
192
+ has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
193
+ if is_linux:
194
+ cmd += ["--no-sandbox", "--disable-dev-shm-usage"]
195
+ if not has_display:
196
+ cmd += ["--headless=new", "--disable-gpu"]
197
+ else:
198
+ # macOS: place the window on-screen, top-left, so the user can sign in.
199
+ cmd += ["--window-position=80,80", "--window-size=1100,900"]
200
+ cmd.append("about:blank")
201
+ PROFILE_DIR.mkdir(parents=True, exist_ok=True)
202
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
203
+ try:
204
+ PID_FILE.write_text(str(proc.pid))
205
+ except OSError:
206
+ pass
207
+ for _ in range(15):
208
+ if _cdp_alive():
209
+ return True
210
+ time.sleep(1)
211
+ return _cdp_alive()
212
+
213
+
214
+ def ensure_chrome(launch: bool = True) -> bool:
215
+ if _cdp_alive():
216
+ return True
217
+ if not launch:
218
+ return False
219
+ return _launch_chrome()
220
+
221
+
222
+ # --- CDP attach + login validation (mirrors restore_twitter_session.py) -----
223
+
224
+ def _attach():
225
+ targets = json.load(urllib.request.urlopen(f"{CDP}/json", timeout=10))
226
+ page = next((t for t in targets if t.get("type") == "page"), None)
227
+ if not page:
228
+ page = json.load(urllib.request.urlopen(
229
+ urllib.request.Request(f"{CDP}/json/new?about:blank", method="PUT"), timeout=10))
230
+ ws = create_connection(page["webSocketDebuggerUrl"], timeout=20, suppress_origin=True)
231
+ state = {"id": 0}
232
+
233
+ def send(method, params=None):
234
+ state["id"] += 1
235
+ ws.send(json.dumps({"id": state["id"], "method": method, "params": params or {}}))
236
+ while True:
237
+ msg = json.loads(ws.recv())
238
+ if msg.get("id") == state["id"]:
239
+ return msg
240
+ return ws, send
241
+
242
+
243
+ def _current_url(send) -> str:
244
+ r = send("Runtime.evaluate", {"expression": "location.href", "returnByValue": True})
245
+ return (r.get("result", {}).get("result", {}) or {}).get("value", "") or ""
246
+
247
+
248
+ def _has_auth_cookie(send) -> bool:
249
+ r = send("Network.getAllCookies")
250
+ cks = r.get("result", {}).get("cookies", []) or []
251
+ return any(
252
+ c.get("name") == "auth_token" and "x.com" in (c.get("domain") or "")
253
+ for c in cks
254
+ )
255
+
256
+
257
+ def _logged_in(send) -> bool:
258
+ send("Network.enable")
259
+ if _has_auth_cookie(send):
260
+ return True
261
+ send("Page.enable")
262
+ send("Page.navigate", {"url": "https://x.com/home"})
263
+ for _ in range(15):
264
+ time.sleep(1)
265
+ if _has_auth_cookie(send):
266
+ return True
267
+ u = _current_url(send)
268
+ if "/login" in u or "/i/flow/login" in u or u.rstrip("/") == "https://x.com":
269
+ return False
270
+ return _has_auth_cookie(send)
271
+
272
+
273
+ def _is_session_valid() -> bool:
274
+ """Rigorous check: navigates x.com/home if needed. Used by `connect`."""
275
+ ws, send = _attach()
276
+ try:
277
+ return _logged_in(send)
278
+ finally:
279
+ try:
280
+ ws.close()
281
+ except Exception:
282
+ pass
283
+
284
+
285
+ def _has_session_quick() -> bool:
286
+ """Read-only check: auth_token cookie present? Never navigates the live
287
+ browser, so it's safe to poll while a posting cycle is running. Used by
288
+ `status`. A present-but-server-revoked cookie can false-positive here; the
289
+ `connect` path's navigate-validate is the authoritative check."""
290
+ ws, send = _attach()
291
+ try:
292
+ send("Network.enable")
293
+ return _has_auth_cookie(send)
294
+ finally:
295
+ try:
296
+ ws.close()
297
+ except Exception:
298
+ pass
299
+
300
+
301
+ def _collect_x_cookies(send) -> list:
302
+ """Read the live x.com/twitter.com cookies (CDP shape) from the managed
303
+ Chrome. Returns [] if none. Shared by the mirror + server-store writers."""
304
+ send("Network.enable")
305
+ r = send("Network.getAllCookies")
306
+ cks = r.get("result", {}).get("cookies", []) or []
307
+ wanted = tuple(d.strip() for d in DOMAINS.split(",") if d.strip())
308
+ return [c for c in cks if any(w in (c.get("domain") or "") for w in wanted)]
309
+
310
+
311
+ _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
312
+ _CONFIG_JSON = os.path.join(_REPO_ROOT, "config.json")
313
+ _HANDLE_PLACEHOLDERS = {"", "your-twitter-handle", "@your-twitter-handle"}
314
+
315
+
316
+ def _resolve_live_handle(send) -> "str | None":
317
+ """Read the logged-in @handle from the LIVE x.com session.
318
+
319
+ resolve_handle() only reads config.json (which on a fresh install is the
320
+ template placeholder), so it can't discover the real account. This reads the
321
+ actual logged-in handle from the SAME authenticated session connect_x just
322
+ validated, so connect_x / cmd_resolve_handle can persist ground truth instead
323
+ of falling back to a hardcoded handle (which would silently mis-attribute
324
+ every post). Two methods, most reliable first:
325
+
326
+ 1. X's own account/settings.json (canonical `screen_name`). The web client
327
+ calls this on every load; it is stable across DOM redesigns, unlike the
328
+ selector-only scrape that kept failing ("handle missing again" during
329
+ onboarding). One GET on the already-open session: csrf via the
330
+ non-httpOnly ct0 cookie, auth_token rides along with credentials.
331
+ 2. DOM fallback: the left-nav Profile link href / account-switcher chip.
332
+
333
+ Best effort: returns None on any failure and never raises into the connect
334
+ flow.
335
+ """
336
+ js = r"""(async function(){
337
+ function ck(n){var m=document.cookie.match(new RegExp('(?:^|; )'+n+'=([^;]*)'));return m?decodeURIComponent(m[1]):'';}
338
+ try{
339
+ var ct0=ck('ct0');
340
+ if(ct0){
341
+ var BEARER='Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
342
+ var urls=['https://api.x.com/1.1/account/settings.json','https://api.twitter.com/1.1/account/settings.json'];
343
+ for(var i=0;i<urls.length;i++){
344
+ try{
345
+ var resp=await fetch(urls[i],{method:'GET',credentials:'include',headers:{'authorization':BEARER,'x-csrf-token':ct0}});
346
+ if(resp&&resp.ok){var j=await resp.json();if(j&&j.screen_name)return String(j.screen_name);}
347
+ }catch(e){}
348
+ }
349
+ }
350
+ }catch(e){}
351
+ try{
352
+ function fromHref(sel){var a=document.querySelector(sel);if(a){var h=a.getAttribute('href')||'';var m=h.match(/^\/([A-Za-z0-9_]{1,15})$/);if(m)return m[1];}return '';}
353
+ var h=fromHref('a[data-testid="AppTabBar_Profile_Link"]');
354
+ if(h)return h;
355
+ var b=document.querySelector('[data-testid="SideNav_AccountSwitcher_Button"]');
356
+ if(b){var m=(b.textContent||'').match(/@([A-Za-z0-9_]{1,15})/);if(m)return m[1];}
357
+ }catch(e){}
358
+ return '';
359
+ })()"""
360
+ try:
361
+ send("Page.enable")
362
+ u = _current_url(send)
363
+ if "x.com" not in u and "twitter.com" not in u:
364
+ send("Page.navigate", {"url": "https://x.com/home"})
365
+ time.sleep(3)
366
+ for _ in range(8):
367
+ r = send("Runtime.evaluate",
368
+ {"expression": js, "returnByValue": True, "awaitPromise": True})
369
+ v = (r.get("result", {}).get("result", {}) or {}).get("value", "") or ""
370
+ v = v.strip().lstrip("@")
371
+ if v:
372
+ return v
373
+ time.sleep(1)
374
+ except Exception:
375
+ return None
376
+ return None
377
+
378
+
379
+ def _write_handle_to_config(handle: "str | None") -> bool:
380
+ """Persist the discovered handle to config.json accounts.twitter.handle, but
381
+ ONLY when the configured value is empty or the template placeholder, so we
382
+ never clobber a handle the user set on purpose. Returns True if written.
383
+
384
+ This is what makes account_resolver.resolve('twitter') return the REAL
385
+ account, so our_account (attribution, own-reply skip, account-keyed ops) is
386
+ correct instead of the poisonous 'your-twitter-handle' default. (2026-06-02)
387
+ """
388
+ if not handle:
389
+ return False
390
+ try:
391
+ with open(_CONFIG_JSON, encoding="utf-8") as f:
392
+ cfg = json.load(f)
393
+ except Exception:
394
+ return False
395
+ accounts = cfg.setdefault("accounts", {})
396
+ if not isinstance(accounts, dict):
397
+ return False
398
+ tw = accounts.setdefault("twitter", {})
399
+ if not isinstance(tw, dict):
400
+ return False
401
+ cur = (tw.get("handle") or "").strip()
402
+ if cur.lower() not in _HANDLE_PLACEHOLDERS:
403
+ return False # a real handle is already set; do not overwrite
404
+ tw["handle"] = "@" + handle.lstrip("@")
405
+ try:
406
+ with open(_CONFIG_JSON, "w", encoding="utf-8") as f:
407
+ json.dump(cfg, f, indent=2)
408
+ f.write("\n")
409
+ return True
410
+ except Exception:
411
+ return False
412
+
413
+
414
+ def _persist_session() -> None:
415
+ """Persist the validated live X session for auto-restore after ANY logout
416
+ (hard kill, crash, or a keychain re-lock wiping Chrome's Cookies DB).
417
+
418
+ Writes the validated x.com/twitter.com cookies to the LOCAL 0600 mirror
419
+ (twitter_cookie_mirror) — the keychain-independent durability layer that
420
+ fixes Gap B on a persistent machine: restore_twitter_session.py re-injects
421
+ from it on the next cycle preflight even after Chrome wiped its own
422
+ encrypted store.
423
+
424
+ Non-fatal end-to-end: the local session is already valid; this only enables
425
+ future auto-recovery, so nothing here may abort connect_x."""
426
+ try:
427
+ ws, send = _attach()
428
+ except Exception:
429
+ return
430
+ # Collect cookies AND resolve the live @handle on the SAME open connection,
431
+ # THEN close it. Resolving after ws.close() (the previous structure) ran every
432
+ # _resolve_live_handle CDP call against a dead socket, so it silently returned
433
+ # None on every connect — which is why config.json + the cookie mirror were
434
+ # perpetually handle:null. Both reads must happen before the finally closes ws.
435
+ handle = None
436
+ try:
437
+ cookies = _collect_x_cookies(send)
438
+ if cookies:
439
+ # Prefer the LIVE logged-in handle so a fresh install records the real
440
+ # account instead of the config.json placeholder; persist it so the
441
+ # cycle's account_resolver (our_account) is correct. Best-effort.
442
+ handle = _resolve_live_handle(send)
443
+ except Exception:
444
+ cookies = []
445
+ finally:
446
+ try:
447
+ ws.close()
448
+ except Exception:
449
+ pass
450
+ if not cookies:
451
+ return
452
+
453
+ # Fall back to the configured handle if live resolution came up empty.
454
+ if handle and _write_handle_to_config(handle):
455
+ print(f"setup_twitter_auth: recorded live X handle @{handle} in config.json "
456
+ "(accounts.twitter.handle); attribution + own-reply dedup now scoped "
457
+ "to the real account", file=sys.stderr)
458
+ if not handle and resolve_handle is not None:
459
+ try:
460
+ handle = resolve_handle()
461
+ except Exception:
462
+ handle = None
463
+
464
+ # Local mirror — keychain-independent durability. This is the only cookie
465
+ # store; the VM-era server store (/api/v1/twitter/session-cookies) was
466
+ # removed 2026-06-17 when we stopped running AppMaker VMs.
467
+ if twitter_cookie_mirror is not None:
468
+ try:
469
+ n = twitter_cookie_mirror.save_cookies(cookies, handle=handle)
470
+ print(f"setup_twitter_auth: mirrored {n} x.com cookies to "
471
+ f"{twitter_cookie_mirror.MIRROR_PATH} (survives keychain re-lock "
472
+ "/ Cookies-DB wipe on relaunch)", file=sys.stderr)
473
+ except Exception as e:
474
+ print(f"setup_twitter_auth: local mirror save skipped ({e})", file=sys.stderr)
475
+
476
+
477
+ def _show_window_and_open_login() -> bool:
478
+ """Make the managed Chrome window VISIBLE + focused and land it on the X login
479
+ page, so the user can sign in by hand (the manual-login fallback).
480
+
481
+ Why this is needed: the cron pipeline parks this same Chrome OFF-SCREEN
482
+ (BH_WINDOW_POS 3042,-1032, a multi-monitor placement). If that window is
483
+ already up when the user runs connect_x, ensure_chrome() short-circuits and
484
+ the user would have an invisible window with nothing to log into. This mirrors
485
+ s4l-plugin's bringToFront() discipline: put a real, focused login screen in
486
+ front of the user. Returns True if we got the page onto x.com/login.
487
+ """
488
+ try:
489
+ ws, send = _attach()
490
+ except Exception:
491
+ return False
492
+ try:
493
+ # Pull the window on-screen, normal state (undo any off-screen parking).
494
+ try:
495
+ win = send("Browser.getWindowForTarget")
496
+ win_id = (win.get("result", {}) or {}).get("windowId")
497
+ if win_id is not None:
498
+ # Two steps: a minimized/parked window must be set normal before
499
+ # its bounds will stick (macOS clamps otherwise).
500
+ send("Browser.setWindowBounds",
501
+ {"windowId": win_id, "bounds": {"windowState": "normal"}})
502
+ send("Browser.setWindowBounds",
503
+ {"windowId": win_id,
504
+ "bounds": {"left": 80, "top": 80, "width": 1100, "height": 900}})
505
+ except Exception:
506
+ pass
507
+ # Land on the real login flow and focus the tab.
508
+ try:
509
+ send("Page.enable")
510
+ send("Page.navigate", {"url": "https://x.com/i/flow/login"})
511
+ send("Page.bringToFront")
512
+ return True
513
+ except Exception:
514
+ return False
515
+ finally:
516
+ try:
517
+ ws.close()
518
+ except Exception:
519
+ pass
520
+
521
+
522
+ def _poll_for_login(timeout: float = 90.0, interval: float = 2.0) -> bool:
523
+ """Wait for the user to finish a MANUAL login, up to `timeout` seconds.
524
+
525
+ Why this exists: connect_x used to return `needs_login` the instant it found
526
+ no session, then relied on the agent driving the setup wizard to re-check
527
+ only after the human had logged in. The agent loops faster than a person can
528
+ type a password + 2FA, so it would re-run, still see `connected: false`, and
529
+ misreport the handle as missing (a detection race, not a bad write).
530
+
531
+ By owning the wait HERE, the tool blocks until the auth cookie actually
532
+ appears (or the bounded window elapses), so no caller can race ahead of the
533
+ human. Read-only: polls the auth_token cookie without navigating, so it never
534
+ disrupts the login flow the user is in the middle of. Stays well under the
535
+ MCP call timeout. Returns True once logged in, False if the window elapsed.
536
+ """
537
+ try:
538
+ ws, send = _attach()
539
+ except Exception:
540
+ return False
541
+ try:
542
+ send("Network.enable")
543
+ deadline = time.time() + max(0.0, timeout)
544
+ while True:
545
+ try:
546
+ if _has_auth_cookie(send):
547
+ return True
548
+ except Exception:
549
+ pass
550
+ if time.time() >= deadline:
551
+ return False
552
+ time.sleep(max(0.5, interval))
553
+ finally:
554
+ try:
555
+ ws.close()
556
+ except Exception:
557
+ pass
558
+
559
+
560
+ # --- Source detection (no keychain, no decryption) --------------------------
561
+ # These let us prompt the OS keychain for exactly ONE browser (the one that
562
+ # actually holds an x.com session) instead of blindly walking all four, and
563
+ # power the panel's "import from" dropdown. They read the Cookies SQLite for the
564
+ # PRESENCE of an auth_token ROW; the value stays encrypted, so no Safe Storage
565
+ # prompt is shown.
566
+
567
+ def _profile_has_x_session(profile) -> bool:
568
+ """True if `profile`'s Cookies DB has an x.com/twitter.com auth_token row.
569
+
570
+ Filesystem + SQLite only — never reads the keychain or decrypts a value, so
571
+ it triggers NO macOS Safe Storage prompt. Used to pick the right import
572
+ source and to flag browsers in the dropdown."""
573
+ if _cbc is None:
574
+ return False
575
+ cookies_path = profile.path / "Cookies"
576
+ if not cookies_path.exists():
577
+ nested = profile.path / "Network" / "Cookies"
578
+ cookies_path = nested if nested.exists() else cookies_path
579
+ if not cookies_path.exists():
580
+ return False
581
+ tmp = _cbc.copy_db(cookies_path)
582
+ if tmp is None:
583
+ return False
584
+ import shutil
585
+ import sqlite3
586
+ try:
587
+ conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
588
+ try:
589
+ row = conn.execute(
590
+ "SELECT 1 FROM cookies WHERE name='auth_token' "
591
+ "AND (host_key LIKE '%x.com' OR host_key LIKE '%twitter.com') LIMIT 1"
592
+ ).fetchone()
593
+ return row is not None
594
+ finally:
595
+ conn.close()
596
+ except Exception:
597
+ return False
598
+ finally:
599
+ shutil.rmtree(tmp.parent, ignore_errors=True)
600
+
601
+
602
+ def _list_sources() -> list[dict]:
603
+ """Every installed Chromium-family profile with an `x_session` flag.
604
+
605
+ Sorted Chrome-first, then sessions-found-first. Pure filesystem detection;
606
+ no keychain prompt."""
607
+ if _cbc is None:
608
+ return []
609
+ out: list[dict] = []
610
+ for p in _cbc.detect_browsers():
611
+ out.append({
612
+ "spec": f"{p.browser}:{p.name}",
613
+ "browser": p.browser,
614
+ "profile": p.name,
615
+ "label": f"{p.browser.capitalize()} \u2014 {p.name}",
616
+ "x_session": _profile_has_x_session(p),
617
+ })
618
+ out.sort(key=lambda s: (s["browser"] != "chrome", not s["x_session"]))
619
+ return out
620
+
621
+
622
+ def _auto_pick_sources() -> list[str]:
623
+ """Default import order when the user didn't pick a browser. Prefer the
624
+ browser(s) that actually have an x.com session so the keychain prompts for
625
+ exactly the right one(s); fall back to Chrome. This is what replaces the old
626
+ blind walk over all four browsers (which fired a keychain prompt per
627
+ installed browser)."""
628
+ srcs = _list_sources()
629
+ with_session = [s["spec"] for s in srcs if s["x_session"]]
630
+ if with_session:
631
+ return with_session
632
+ return ["chrome:Default"]
633
+
634
+
635
+ def cmd_detect_sources(args) -> dict:
636
+ """List browsers/profiles the X session can be imported from (for the panel
637
+ dropdown). Read-only, no keychain prompt."""
638
+ sources = _list_sources()
639
+ recommended = next((s["spec"] for s in sources if s["x_session"]), None)
640
+ if not recommended:
641
+ recommended = next((s["spec"] for s in sources if s["spec"] == "chrome:Default"),
642
+ sources[0]["spec"] if sources else "chrome:Default")
643
+ return {"ok": True, "sources": sources, "recommended": recommended}
644
+
645
+
646
+ # --- Cookie import from the user's everyday browser -------------------------
647
+
648
+ def _import_from(source: str) -> dict:
649
+ """Copy x.com/twitter.com cookies from `source` into the managed Chrome.
650
+
651
+ Prefers the vendored copy_browser_cookies.py (ships with this repo, runs
652
+ under the same interpreter that is already executing this script, so its
653
+ deps are guaranteed present). Falls back to the legacy ai-browser-profile
654
+ venv only on a dev box where the vendored script is absent.
655
+
656
+ Returns {ok, returncode, stdout, stderr}. Cookie values are never surfaced;
657
+ the copier prints counts only.
658
+ """
659
+ if VENDORED_COOKIE_SCRIPT.exists():
660
+ cmd = [
661
+ sys.executable, str(VENDORED_COOKIE_SCRIPT), "copy",
662
+ "--from", source, "--to", CDP, "--domains", DOMAINS,
663
+ ]
664
+ cwd = str(VENDORED_COOKIE_SCRIPT.parent)
665
+ elif ABP_PYTHON.exists():
666
+ cmd = [
667
+ str(ABP_PYTHON), "-m", "ai_browser_profile.cookies", "copy",
668
+ "--from", source, "--to", CDP, "--domains", DOMAINS,
669
+ ]
670
+ cwd = str(Path.home() / "ai-browser-profile")
671
+ else:
672
+ return {
673
+ "ok": False,
674
+ "error": "no cookie copier available "
675
+ f"(vendored script missing at {VENDORED_COOKIE_SCRIPT} and "
676
+ f"ai-browser-profile venv not found at {ABP_PYTHON})",
677
+ }
678
+ # The copier's first step is `security find-generic-password` on the
679
+ # browser's Safe Storage entry, which can pop a macOS Keychain auth dialog
680
+ # the user has to click ("Always Allow"). That dialog often opens unfocused
681
+ # or behind the autoposter's own Chrome window, so a human needs real time
682
+ # to find and click it. A 60s cap killed it mid-prompt and dumped the user
683
+ # into the manual-login fallback. Give the dialog room; override with
684
+ # S4L_COOKIE_COPY_TIMEOUT (seconds), 0/empty = no timeout.
685
+ _raw_to = os.environ.get("S4L_COOKIE_COPY_TIMEOUT", "600").strip()
686
+ try:
687
+ copy_timeout = float(_raw_to) if _raw_to else None
688
+ except ValueError:
689
+ copy_timeout = 600.0
690
+ if copy_timeout is not None and copy_timeout <= 0:
691
+ copy_timeout = None
692
+ try:
693
+ proc = subprocess.run(
694
+ cmd, capture_output=True, text=True, timeout=copy_timeout, cwd=cwd,
695
+ )
696
+ except subprocess.TimeoutExpired:
697
+ _to_label = f"{copy_timeout:g}s" if copy_timeout is not None else "no limit"
698
+ return {"ok": False, "error": f"cookie copy from {source} timed out ({_to_label})"}
699
+ return {
700
+ "ok": proc.returncode == 0,
701
+ "returncode": proc.returncode,
702
+ "stdout": proc.stdout.strip(),
703
+ "stderr": proc.stderr.strip(),
704
+ }
705
+
706
+
707
+ # --- Headless / Keychain pre-flight (#3 + #4, added 2026-06-02) -------------
708
+ # macOS Keychain access for Chrome's Safe Storage is GUI-session-gated. Calls
709
+ # from SSH-invoked processes (cron, ansible, the macstadium test runner, etc.)
710
+ # silently get errSecAuthFailed because there's no GUI to render an auth
711
+ # prompt to. Without these helpers, copy_browser_cookies.py fails with a
712
+ # generic "access denied", setup_twitter_auth re-classifies as needs_login,
713
+ # and the user sees "log in manually" when the actual cause is "your process
714
+ # can't read the OS keychain." This block detects the headless case up front
715
+ # AND classifies the import error so the user-facing message is accurate.
716
+
717
+ def _is_headless() -> bool:
718
+ """True when running without a GUI/interactive session — the case where
719
+ Keychain Safe Storage reads will silently deny without a prompt."""
720
+ if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT"):
721
+ return True
722
+ try:
723
+ if not sys.stdin.isatty():
724
+ return True
725
+ except Exception:
726
+ pass
727
+ return False
728
+
729
+
730
+ def _keychain_safe_storage_ok(browser_label: str = "Chrome") -> tuple[bool, str]:
731
+ """Probe whether the OS keychain entry for `<browser_label> Safe Storage`
732
+ is readable by THIS process. Returns (ok, detail_for_log)."""
733
+ svc = f"{browser_label} Safe Storage"
734
+ try:
735
+ r = subprocess.run(
736
+ ["security", "find-generic-password", "-s", svc, "-a", browser_label, "-w"],
737
+ capture_output=True, text=True, timeout=10,
738
+ )
739
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
740
+ return False, f"security probe failed: {e}"
741
+ if r.returncode == 0:
742
+ return True, "accessible"
743
+ err_tail = (r.stderr or "").strip().splitlines()
744
+ return False, (err_tail[-1] if err_tail else f"exit {r.returncode}")
745
+
746
+
747
+ def _classify_import_error(detail: str | None) -> str:
748
+ """Map a copy_browser_cookies.py error string to a structured type so the
749
+ upper layers (connect_x, the user) can show a precise remediation instead
750
+ of a generic 'needs_login'."""
751
+ if not detail:
752
+ return "unknown"
753
+ d = detail.lower()
754
+ # Keychain access issues — most common on headless runs.
755
+ if ("user interaction is not allowed" in d) or ("interaction is not allowed" in d):
756
+ return "keychain_locked"
757
+ # A keychain DENY can surface two different ways depending on which dialog
758
+ # the user dismissed:
759
+ # - ACL "allow access?" prompt, click Deny -> errSecAuthFailed (-25293)
760
+ # - unlock/confirm prompt, click Cancel/Deny -> errSecUserCanceled (-128)
761
+ # Both mean "the user actively refused", and both have the same fix (re-run
762
+ # and click Allow), so collapse them into one type.
763
+ if (("access denied" in d) or ("errsecauth" in d) or ("-25293" in d)
764
+ or ("user canceled" in d) or ("user cancelled" in d)
765
+ or ("errsecusercanceled" in d) or ("-128" in d)):
766
+ return "keychain_acl_denied"
767
+ if ("not be found in the keychain" in d) or ("errsecitemnotfound" in d):
768
+ return "keychain_entry_missing"
769
+ # Source profile / browser mapping
770
+ if ("no profile" in d) or ("available" in d and "profiles" in d):
771
+ return "source_profile_not_found"
772
+ # CDP injection
773
+ if ("websocket" in d) or ("connection refused" in d) or ("port" in d and "9555" in d):
774
+ return "cdp_inject_failed"
775
+ return "unknown"
776
+
777
+
778
+ def _cookies_db_path() -> Path | None:
779
+ """Resolve the harness profile's on-disk Cookies SQLite. Newer Chrome nests
780
+ it under Default/Network/; older builds keep it at Default/. Returns whichever
781
+ exists (most-recently-modified wins if both linger), or None."""
782
+ candidates = [
783
+ PROFILE_DIR / "Default" / "Network" / "Cookies",
784
+ PROFILE_DIR / "Default" / "Cookies",
785
+ ]
786
+ existing = [p for p in candidates if p.exists()]
787
+ if not existing:
788
+ return None
789
+ return max(existing, key=lambda p: p.stat().st_mtime)
790
+
791
+
792
+ def _count_x_cookies_on_disk() -> int:
793
+ """Count x.com/twitter.com rows committed to the on-disk Cookies SQLite.
794
+
795
+ Reads a temp COPY of the DB (+ -wal/-shm) so an in-flight write by the live
796
+ Chrome can't lock us out, and opens it read-write on the copy so WAL-resident
797
+ rows are visible (a read-only open would miss not-yet-checkpointed writes —
798
+ exactly the rows we are polling for). Returns the count, or -1 if the DB is
799
+ missing/unreadable."""
800
+ db = _cookies_db_path()
801
+ if not db:
802
+ return -1
803
+ import shutil
804
+ import sqlite3
805
+ import tempfile
806
+ tmpdir = None
807
+ try:
808
+ tmpdir = Path(tempfile.mkdtemp(prefix="saps_flushchk_"))
809
+ dst = tmpdir / "Cookies"
810
+ shutil.copy2(db, dst)
811
+ for suffix in ("-wal", "-shm"):
812
+ w = db.parent / (db.name + suffix)
813
+ if w.exists():
814
+ shutil.copy2(w, tmpdir / ("Cookies" + suffix))
815
+ conn = sqlite3.connect(str(dst))
816
+ try:
817
+ n = conn.execute(
818
+ "SELECT COUNT(*) FROM cookies "
819
+ "WHERE host_key LIKE '%x.com' OR host_key LIKE '%twitter.com'"
820
+ ).fetchone()[0]
821
+ finally:
822
+ conn.close()
823
+ return int(n)
824
+ except Exception:
825
+ return -1
826
+ finally:
827
+ if tmpdir is not None:
828
+ shutil.rmtree(tmpdir, ignore_errors=True)
829
+
830
+
831
+ def _force_cookie_flush() -> tuple[bool, str]:
832
+ """Flush Chrome's in-memory cookie store to disk via CDP Browser.close, then
833
+ VERIFY the x.com cookies actually landed in the on-disk SQLite before
834
+ returning (Gap A, 2026-06-02).
835
+
836
+ The bug this fixes: Browser.close acks immediately, but Chrome commits the
837
+ CookieMonster -> SQLite write ASYNCHRONOUSLY (~0.5-5s under load). The old
838
+ code treated the RPC ack as proof of persistence and reported
839
+ flushed_to_disk=true while the disk was still empty, so a doctor run or a
840
+ SIGKILL in that window saw zero cookies. We now poll the on-disk row count
841
+ until the flush is observably durable (or a timeout proves it isn't).
842
+
843
+ Returns (ok, detail). ok=True only when x.com rows are confirmed on disk."""
844
+ bh = Path.home() / ".local" / "bin" / "browser-harness"
845
+ if not bh.exists():
846
+ return False, f"browser-harness CLI missing at {bh}"
847
+ before = _count_x_cookies_on_disk()
848
+ env = os.environ.copy()
849
+ env["BU_CDP_URL"] = CDP
850
+ env.setdefault("BU_NAME", "twitter-harness")
851
+ env["PATH"] = f"{Path.home()}/.local/bin:" + env.get("PATH", "")
852
+ try:
853
+ r = subprocess.run(
854
+ [str(bh)],
855
+ input="cdp('Browser.close')\n",
856
+ env=env, capture_output=True, text=True, timeout=15,
857
+ )
858
+ except (subprocess.TimeoutExpired, OSError) as e:
859
+ return False, f"browser-harness invocation failed: {e}"
860
+ if r.returncode != 0:
861
+ return False, (r.stderr or r.stdout).strip()[:300]
862
+
863
+ # Poll the disk for the async commit to land. Accept as soon as we observe
864
+ # x.com rows on disk (and, if we had a baseline, that it didn't regress).
865
+ deadline = time.time() + 8.0
866
+ last = before
867
+ while time.time() < deadline:
868
+ n = _count_x_cookies_on_disk()
869
+ if n > 0 and (before <= 0 or n >= before):
870
+ return True, f"verified {n} x.com cookies committed to on-disk SQLite"
871
+ last = n
872
+ time.sleep(0.5)
873
+ if last > 0:
874
+ return True, f"verified {last} x.com cookies on disk (slow flush)"
875
+ return False, (
876
+ f"Browser.close issued but on-disk x.com cookie count is {last} after 8s "
877
+ "(flush not confirmed; relying on the local cookie mirror for durability)"
878
+ )
879
+
880
+
881
+ # --- Commands ---------------------------------------------------------------
882
+
883
+ def _configured_handle() -> "str | None":
884
+ """The handle persisted in config.json (accounts.twitter.handle), or None if
885
+ it's empty / still the template placeholder. Used to surface a `handle` on
886
+ status WITHOUT navigating the live browser. None means UNKNOWN, never that a
887
+ real handle is missing."""
888
+ try:
889
+ with open(_CONFIG_JSON, encoding="utf-8") as f:
890
+ cfg = json.load(f)
891
+ h = ((cfg.get("accounts") or {}).get("twitter") or {}).get("handle") or ""
892
+ except Exception:
893
+ return None
894
+ h = h.strip()
895
+ if h.lower() in _HANDLE_PLACEHOLDERS:
896
+ return None
897
+ return "@" + h.lstrip("@")
898
+
899
+
900
+ def _mirror_handle() -> "str | None":
901
+ """The @handle stamped on the keychain-independent cookie mirror by connect_x,
902
+ or None. This is what lets status surface the real account BEFORE a project
903
+ (config.json) exists — the mirror is written the moment X is connected,
904
+ whereas accounts.twitter.handle only exists once setup writes config.json."""
905
+ if twitter_cookie_mirror is None:
906
+ return None
907
+ try:
908
+ h = (twitter_cookie_mirror.load_meta() or {}).get("handle") or ""
909
+ except Exception:
910
+ return None
911
+ h = str(h).strip()
912
+ if not h or h.lower() in _HANDLE_PLACEHOLDERS:
913
+ return None
914
+ return "@" + h.lstrip("@")
915
+
916
+
917
+ def _durable_handle() -> "str | None":
918
+ """The known @handle from the most durable source available: config.json first
919
+ (an intentional, user-confirmed value), then the cookie mirror (stamped at
920
+ connect time, survives a fresh install with no config.json yet)."""
921
+ return _configured_handle() or _mirror_handle()
922
+
923
+
924
+ def _mirror_has_session() -> bool:
925
+ """True when the durable 0600 mirror holds an x.com auth_token cookie — i.e. a
926
+ real X session exists ON DISK even if the managed Chrome isn't live right now.
927
+
928
+ This is the fix for the dashboard flipping back to "disconnected" the instant
929
+ the managed Chrome exits after a successful import: the session is durably
930
+ saved (and the cycle preflight restores it via restore_twitter_session.py), so
931
+ status must trust the mirror instead of demanding a live browser."""
932
+ if twitter_cookie_mirror is None:
933
+ return False
934
+ try:
935
+ cks = twitter_cookie_mirror.load_cookies()
936
+ except Exception:
937
+ return False
938
+ return any(
939
+ isinstance(c, dict)
940
+ and c.get("name") == "auth_token"
941
+ and "x.com" in (c.get("domain") or "")
942
+ for c in (cks or [])
943
+ )
944
+
945
+
946
+ def cmd_status(args) -> dict:
947
+ if not ensure_chrome(launch=False):
948
+ # The managed Chrome isn't live, but a durable keychain-independent session
949
+ # may already exist on disk (the mirror connect_x writes). Trust it so a
950
+ # successful import doesn't read back as "disconnected" once Chrome exits;
951
+ # the cycle preflight restores this same mirror before it scans.
952
+ if _mirror_has_session():
953
+ return {
954
+ "ok": True,
955
+ "connected": True,
956
+ "state": "connected_idle",
957
+ "handle": _durable_handle(),
958
+ "note": "X is connected (session saved). The autoposter's browser "
959
+ "isn't running this moment; the next cycle restores the session "
960
+ "from the local mirror automatically.",
961
+ "cdp": CDP,
962
+ }
963
+ return {
964
+ "ok": True,
965
+ "connected": False,
966
+ "state": "browser_not_running",
967
+ # null = unknown (browser down), NOT a missing/wrong handle.
968
+ "handle": None,
969
+ "note": "The autoposter's X browser isn't running yet. Run connect_x to "
970
+ "start it and check/import your session.",
971
+ "cdp": CDP,
972
+ }
973
+ try:
974
+ valid = _has_session_quick()
975
+ except Exception as e:
976
+ return {"ok": False, "connected": False, "state": "error",
977
+ "handle": None, "error": str(e), "cdp": CDP}
978
+ # Live browser says logged out, but a durable mirror session can still exist
979
+ # (e.g. Chrome relaunched with a keychain-wiped Cookies DB before the preflight
980
+ # restore ran). Report it as connected_idle rather than a hard logged_out so
981
+ # the dashboard doesn't churn between connected and disconnected.
982
+ if not valid and _mirror_has_session():
983
+ return {
984
+ "ok": True,
985
+ "connected": True,
986
+ "state": "connected_idle",
987
+ "handle": _durable_handle(),
988
+ "note": "X is connected (session saved). The live browser is logged out "
989
+ "right now; the next cycle restores the session from the local mirror.",
990
+ "cdp": CDP,
991
+ }
992
+ return {
993
+ "ok": True,
994
+ "connected": valid,
995
+ "state": "connected" if valid else "logged_out",
996
+ # Surface the durable handle (config.json OR mirror) on a valid session;
997
+ # logged_out -> null (unknown, not missing). Callers must not treat a
998
+ # logged_out result as a reason to ask for / overwrite the handle.
999
+ "handle": _durable_handle() if valid else None,
1000
+ "cdp": CDP,
1001
+ }
1002
+
1003
+
1004
+ def cmd_connect(args) -> dict:
1005
+ if not ensure_chrome(launch=not args.no_launch):
1006
+ return {
1007
+ "ok": False,
1008
+ "connected": False,
1009
+ "state": "browser_launch_failed",
1010
+ "error": "Could not start the managed Chrome (no Chrome/Chromium found, "
1011
+ "or it failed to bind the debug port). Set BH_CHROME_BIN to your Chrome path.",
1012
+ "cdp": CDP,
1013
+ }
1014
+
1015
+ # 1. Already logged in? Nothing to import.
1016
+ try:
1017
+ if _is_session_valid():
1018
+ _persist_session()
1019
+ return {
1020
+ "ok": True,
1021
+ "connected": True,
1022
+ "state": "connected",
1023
+ "source": "existing_session",
1024
+ "note": "X is already connected in the autoposter browser; nothing imported.",
1025
+ "cdp": CDP,
1026
+ }
1027
+ except Exception as e:
1028
+ return {"ok": False, "connected": False, "state": "error", "error": str(e), "cdp": CDP}
1029
+
1030
+ # 1b. Headless + Keychain pre-flight (#3 + #4, added 2026-06-02).
1031
+ # On macOS, copy_browser_cookies.py needs to read the per-browser Safe
1032
+ # Storage entry from the OS keychain. SSH-invoked processes get
1033
+ # errSecAuthFailed silently — no prompt, no warning. We probe up front so
1034
+ # the user sees "your keychain is locked / run unlock-keychain" instead of
1035
+ # the misleading "log in manually" cascade.
1036
+ headless = _is_headless()
1037
+ if headless:
1038
+ # Probe with the first source's likely browser label. We don't know
1039
+ # which source will succeed yet, so probe Chrome (the autoposter
1040
+ # default); if that's denied, all the AUTO_SOURCES will be too.
1041
+ kc_ok, kc_detail = _keychain_safe_storage_ok("Chrome")
1042
+ if not kc_ok:
1043
+ return {
1044
+ "ok": True,
1045
+ "connected": False,
1046
+ "state": "keychain_locked",
1047
+ "error_type": "keychain_locked",
1048
+ "headless": True,
1049
+ "keychain_detail": kc_detail,
1050
+ "note": (
1051
+ "Cookie import requires reading Chrome's Safe Storage from the macOS "
1052
+ "Keychain, but this process can't access it (probably running over SSH "
1053
+ "or another headless context). No GUI prompt is shown for this — macOS "
1054
+ "denies access silently. To fix, run this once in the same session:\n"
1055
+ " security unlock-keychain ~/Library/Keychains/login.keychain-db\n"
1056
+ "Then re-run connect_x. If you're on the autoposter machine via SSH, you "
1057
+ "may also need to run it before every fresh shell, or persist with "
1058
+ "`security set-keychain-settings -lut 0`."
1059
+ ),
1060
+ "remediation_cmd": "security unlock-keychain ~/Library/Keychains/login.keychain-db",
1061
+ "cdp": CDP,
1062
+ }
1063
+
1064
+ # 2. Import from the user's everyday browser.
1065
+ # - explicit --source X -> just that one (one keychain prompt)
1066
+ # - --source all -> the full chrome/arc/brave/edge sweep (legacy)
1067
+ # - no --source (the default) -> auto-pick the browser(s) that ACTUALLY
1068
+ # hold an x.com session, so we prompt the keychain for exactly the right
1069
+ # one instead of blindly walking all four and prompting per browser.
1070
+ if args.source == "all":
1071
+ sources = AUTO_SOURCES
1072
+ elif args.source:
1073
+ sources = [args.source]
1074
+ else:
1075
+ sources = _auto_pick_sources()
1076
+ attempts = []
1077
+ for src in sources:
1078
+ res = _import_from(src)
1079
+ copied = res.get("stdout", "")
1080
+ detail = copied or res.get("error") or res.get("stderr")
1081
+ # #3: classify the error so the caller doesn't see string soup.
1082
+ error_type = None if res.get("ok") else _classify_import_error(detail)
1083
+ attempts.append({
1084
+ "source": src,
1085
+ "ok": res.get("ok"),
1086
+ "detail": detail,
1087
+ "error_type": error_type,
1088
+ })
1089
+ if not res.get("ok"):
1090
+ continue
1091
+ # 3. Re-validate after this source.
1092
+ try:
1093
+ if _is_session_valid():
1094
+ _persist_session()
1095
+ # #2: force a cookie-store flush via CDP Browser.close so the
1096
+ # imported session survives any subsequent SIGKILL (e.g. the
1097
+ # autoposter cron stopping Chrome with no grace window). Empty
1098
+ # result on this build is success — Browser.close triggers the
1099
+ # flush synchronously but doesn't actually terminate Chrome.
1100
+ flush_ok, flush_detail = _force_cookie_flush()
1101
+ mirror_count = (
1102
+ twitter_cookie_mirror.load_meta().get("count")
1103
+ if twitter_cookie_mirror is not None else None
1104
+ )
1105
+ return {
1106
+ "ok": True,
1107
+ "connected": True,
1108
+ "state": "imported",
1109
+ "source": src,
1110
+ "attempts": attempts,
1111
+ "flushed_to_disk": flush_ok,
1112
+ "flush_detail": flush_detail,
1113
+ "mirrored_cookies": mirror_count,
1114
+ "note": f"Imported your X session from {src} into the autoposter browser. "
1115
+ + ("Cookies verified on disk AND mirrored locally; "
1116
+ if flush_ok else
1117
+ "Chrome's encrypted store didn't confirm the flush, but ")
1118
+ + (f"{mirror_count} cookies are saved to a keychain-independent "
1119
+ "mirror, so the cycle preflight auto-restores the session even if "
1120
+ "Chrome re-launches logged out."
1121
+ if mirror_count else
1122
+ "the session is live in the running browser."),
1123
+ "cdp": CDP,
1124
+ }
1125
+ except Exception:
1126
+ pass
1127
+
1128
+ # 4. Could not establish a valid session automatically.
1129
+ # Roll up the import failure cause FIRST, because whether we shove a Chrome
1130
+ # login window in front of the user depends on it. We open a focused X login
1131
+ # screen ONLY when either:
1132
+ # (a) the user actually DENIED/Cancelled the keychain prompt — auto-import
1133
+ # genuinely can't proceed, so manual login is the real fallback; or
1134
+ # (b) the caller explicitly asked for it (--manual-login).
1135
+ # For every other failure (no X session in the source browser, locked
1136
+ # keychain, CDP error, unknown) we do NOT pop an unexpected browser window;
1137
+ # we return needs_login and let the user opt into manual login.
1138
+ distinct_error_types = {a.get("error_type") for a in attempts if a.get("error_type")}
1139
+ rolled_up_error_type = (
1140
+ next(iter(distinct_error_types)) if len(distinct_error_types) == 1 else None
1141
+ )
1142
+ manual_login = bool(getattr(args, "manual_login", False))
1143
+ open_login = manual_login or rolled_up_error_type == "keychain_acl_denied"
1144
+
1145
+ shown = False
1146
+ if open_login:
1147
+ # Put a real, focused X login screen in front of the user (the cron
1148
+ # pipeline may have parked this window off-screen) and tell them to sign
1149
+ # in by hand, then re-run connect_x. We never ask for their password and
1150
+ # never hand-decrypt cookies; they log into their own browser themselves.
1151
+ shown = _show_window_and_open_login()
1152
+
1153
+ # Own the wait: block here until the user finishes the manual login (or the
1154
+ # bounded window elapses) instead of returning `needs_login` instantly and
1155
+ # letting the caller re-check faster than a human can type a password + 2FA.
1156
+ # That race is what made setup misreport the handle as "missing." If the
1157
+ # cookie appears, fall through to the same connected/persist/handle path the
1158
+ # auto-import success branch uses.
1159
+ login_wait = getattr(args, "login_wait", 90.0)
1160
+ if login_wait and login_wait > 0 and _poll_for_login(timeout=login_wait):
1161
+ try:
1162
+ if _is_session_valid():
1163
+ _persist_session()
1164
+ flush_ok, flush_detail = _force_cookie_flush()
1165
+ return {
1166
+ "ok": True,
1167
+ "connected": True,
1168
+ "state": "connected",
1169
+ "source": "manual_login",
1170
+ "attempts": attempts,
1171
+ "flushed_to_disk": flush_ok,
1172
+ "flush_detail": flush_detail,
1173
+ "note": "You logged in manually; the autoposter detected the live X "
1174
+ "session and saved it to its own profile.",
1175
+ "cdp": CDP,
1176
+ }
1177
+ except Exception:
1178
+ pass
1179
+
1180
+ # Build the needs_login note from the rolled-up cause + whether a window opened.
1181
+ extra = {}
1182
+ if rolled_up_error_type == "keychain_acl_denied":
1183
+ # The user clicked Deny/Cancel on the keychain prompt. Auto-import would
1184
+ # have worked; they just refused keychain access. Tell them the real fix
1185
+ # (re-run and click Allow), and since we DID open a login window for this
1186
+ # case, point at it as the keychain-free fallback.
1187
+ note = (
1188
+ "It looks like you clicked Deny (or Cancel) on the macOS Keychain prompt. "
1189
+ "To import your X session automatically, the autoposter needs to read Chrome's "
1190
+ "\"Safe Storage\" key from your Keychain. Re-run connect_x and click Allow (or "
1191
+ "Always Allow) on that prompt and the import will finish on its own. "
1192
+ "If you'd rather not grant keychain access, there's already a Chrome window open "
1193
+ "at the X login page"
1194
+ + ("" if shown else " (look for a 'Google Chrome' window)")
1195
+ + " — just log in there by hand and ask me to re-check. "
1196
+ "(Auto-import tried: " + ", ".join(sources) + ".)"
1197
+ )
1198
+ extra["remediation"] = "rerun_connect_x_and_click_allow"
1199
+ elif open_login:
1200
+ # Explicit --manual-login: a window is open and we waited; they just
1201
+ # haven't finished signing in yet.
1202
+ note = (
1203
+ "A Chrome window for the autoposter is open at the X login page"
1204
+ + ("" if shown else " (if you don't see it, look for a 'Google Chrome' window)")
1205
+ + " and you are NOT logged in yet. Log in there yourself — username, password, "
1206
+ "and 2FA if prompted — in that window. When your X home timeline shows, ask me "
1207
+ "to confirm and I'll re-check (run connect_x again). The session is saved to the "
1208
+ "autoposter's own profile, so this is a one-time step. "
1209
+ "(Auto-import tried: " + ", ".join(sources) + ".)"
1210
+ )
1211
+ else:
1212
+ # Auto-import failed for a non-deny reason and the user did NOT ask for
1213
+ # manual login. Do NOT pop a browser window. Explain what happened and
1214
+ # offer manual login as an explicit opt-in.
1215
+ note = (
1216
+ "Couldn't import an X session automatically (auto-import tried: "
1217
+ + ", ".join(sources) + "). This usually means you're not logged into X in "
1218
+ "your everyday browser, so there was no session to copy. I did NOT open a "
1219
+ "login window. If you want to sign in by hand, ask me to connect X with "
1220
+ "manual login and I'll open a focused X login page for you to use."
1221
+ )
1222
+ extra["manual_login_hint"] = "rerun_connect_x_with_manual_login"
1223
+ return {
1224
+ "ok": True,
1225
+ "connected": False,
1226
+ "state": "needs_login",
1227
+ # null = the handle is UNKNOWN because no session exists yet, NOT that a
1228
+ # configured handle is missing/wrong. Callers must never treat a
1229
+ # logged-out result as a handle-remediation trigger.
1230
+ "handle": None,
1231
+ "error_type": rolled_up_error_type,
1232
+ "attempts": attempts,
1233
+ "login_window_opened": shown,
1234
+ "note": note,
1235
+ "profile_dir": str(PROFILE_DIR),
1236
+ "cdp": CDP,
1237
+ **extra,
1238
+ }
1239
+
1240
+
1241
+ def cmd_resolve_handle(args) -> dict:
1242
+ """Read the live logged-in @handle from the managed Chrome and persist it to
1243
+ config.json accounts.twitter.handle.
1244
+
1245
+ The MCP post preflight calls this to self-heal a missing handle — the onboarding
1246
+ gap where connect_x's best-effort live-DOM read silently no-op'd, leaving the
1247
+ install logged in but with accounts:null, so twitter_browser.py refused EVERY
1248
+ reply with no_account_configured. Reading the handle from the SAME session the
1249
+ poster posts through is ground truth, not a guess, so it's safe where a hardcoded
1250
+ fallback would not be. Best-effort: returns state=browser_not_running / no_handle
1251
+ on failure and never raises."""
1252
+ try:
1253
+ ws, send = _attach()
1254
+ except Exception as e:
1255
+ return {"ok": False, "state": "browser_not_running", "error": str(e)}
1256
+ handle = None
1257
+ try:
1258
+ handle = _resolve_live_handle(send)
1259
+ except Exception:
1260
+ handle = None
1261
+ finally:
1262
+ try:
1263
+ ws.close()
1264
+ except Exception:
1265
+ pass
1266
+ if not handle:
1267
+ return {"ok": False, "state": "no_handle"}
1268
+ persisted = _write_handle_to_config(handle)
1269
+ return {"ok": True, "state": "resolved", "handle": handle, "persisted": persisted}
1270
+
1271
+
1272
+ def main() -> int:
1273
+ ap = argparse.ArgumentParser(description="Twitter/X session bootstrap for MCP setup.")
1274
+ sub = ap.add_subparsers(dest="cmd", required=True)
1275
+ sub.add_parser("status", help="Report whether the managed X session is valid.")
1276
+ sub.add_parser("detect-sources",
1277
+ help="List browsers/profiles to import the X session from "
1278
+ "(JSON, for the panel dropdown). No keychain prompt.")
1279
+ sub.add_parser("resolve-handle",
1280
+ help="Read the live logged-in @handle from the managed Chrome and "
1281
+ "persist it to config.json accounts.twitter.handle. Idempotent "
1282
+ "self-heal for the post preflight; never overwrites a real handle.")
1283
+ c = sub.add_parser("connect", help="Ensure browser + import/validate the X session.")
1284
+ c.add_argument("--source", default=None,
1285
+ help="Browser profile to import from (e.g. chrome:Default, arc:Default), "
1286
+ "or 'all' for the full chrome/arc/brave/edge sweep. Default: "
1287
+ "auto-pick the browser that actually holds an x.com session "
1288
+ "(one keychain prompt for the right browser).")
1289
+ c.add_argument("--no-launch", action="store_true",
1290
+ help="Do not launch Chrome if it's down (probe only).")
1291
+ c.add_argument("--manual-login", action="store_true",
1292
+ help="Explicitly opt into manual login: open a focused X login "
1293
+ "window and wait for the user to sign in by hand. Without this, "
1294
+ "the login window only opens when the user DENIED the keychain "
1295
+ "prompt; every other auto-import failure returns needs_login "
1296
+ "without popping an unexpected browser window.")
1297
+ c.add_argument("--login-wait", type=float, default=90.0,
1298
+ help="Seconds to wait for a MANUAL login to complete before "
1299
+ "returning needs_login (default 90; 0 disables the wait). "
1300
+ "Prevents the detection race that misreports the handle as missing.")
1301
+ args = ap.parse_args()
1302
+
1303
+ if args.cmd == "detect-sources":
1304
+ # Pure filesystem; never needs CDP/websocket.
1305
+ out = cmd_detect_sources(args)
1306
+ elif _WEBSOCKET_IMPORT_ERROR is not None:
1307
+ # status/connect attach to Chrome over CDP — websocket-client is required.
1308
+ out = {"ok": False, "state": "error", "error": _WEBSOCKET_IMPORT_ERROR}
1309
+ elif args.cmd == "status":
1310
+ out = cmd_status(args)
1311
+ elif args.cmd == "resolve-handle":
1312
+ out = cmd_resolve_handle(args)
1313
+ else:
1314
+ out = cmd_connect(args)
1315
+ print(json.dumps(out, indent=2))
1316
+ return 0
1317
+
1318
+
1319
+ if __name__ == "__main__":
1320
+ sys.exit(main())