@m13v/s4l 1.6.197-rc.10

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 +1336 -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 +513 -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,56 @@
1
+ #!/usr/bin/env bash
2
+ # clean_stale_singleton.sh — Remove stale Chrome singleton symlinks from a
3
+ # browser profile if (and only if) the PID they reference is dead.
4
+ #
5
+ # Background: when Chrome exits ungracefully (SIGKILL, system sleep, force
6
+ # quit, jetsam), it leaves Singleton{Lock,Cookie,Socket} + RunningChromeVersion
7
+ # symlinks behind. On the next launch Chrome sees them, fails to talk to the
8
+ # (now-dead) PID listed in SingletonLock, and pops "Something went wrong when
9
+ # opening your profile. Some features may be unavailable" once per service
10
+ # (cookies, prefs, history, sync, ...) — typically 7 dialogs. Until the user
11
+ # clicks all of them, no pages load and the pipeline hangs.
12
+ #
13
+ # Safe to call before any Chrome launch on the same profile. Idempotent.
14
+ # Refuses to clean if the SingletonLock PID is still alive (so we never
15
+ # yank locks out from under a running Chrome — including a real user
16
+ # session attached to the same profile).
17
+ #
18
+ # Usage: clean_stale_singleton.sh <profile_dir>
19
+ # e.g. clean_stale_singleton.sh ~/.claude/browser-profiles/twitter
20
+
21
+ set -uo pipefail
22
+
23
+ profile_dir="${1:-}"
24
+ if [ -z "$profile_dir" ] || [ ! -d "$profile_dir" ]; then
25
+ echo "[clean_stale_singleton] usage: $0 <profile_dir>" >&2
26
+ exit 0 # never block the pipeline on a misuse; just no-op
27
+ fi
28
+
29
+ lock_link="$profile_dir/SingletonLock"
30
+
31
+ # No lock = nothing to clean.
32
+ if [ ! -L "$lock_link" ] && [ ! -e "$lock_link" ]; then
33
+ exit 0
34
+ fi
35
+
36
+ # SingletonLock target format: <hostname>-<pid>
37
+ target=$(readlink "$lock_link" 2>/dev/null || echo "")
38
+ pid="${target##*-}"
39
+
40
+ if [ -n "$pid" ] && [[ "$pid" =~ ^[0-9]+$ ]]; then
41
+ if kill -0 "$pid" 2>/dev/null; then
42
+ # Live Chrome owns this profile. Do NOT touch.
43
+ echo "[clean_stale_singleton] ${profile_dir##*/}: SingletonLock PID $pid alive; leaving locks intact." >&2
44
+ exit 0
45
+ fi
46
+ fi
47
+
48
+ # Stale: PID dead, malformed, or unreadable. Nuke the singletons so Chrome
49
+ # can launch cleanly. Also drop RunningChromeVersion which Chrome cross-checks.
50
+ rm -f "$profile_dir/SingletonLock" \
51
+ "$profile_dir/SingletonCookie" \
52
+ "$profile_dir/SingletonSocket" \
53
+ "$profile_dir/RunningChromeVersion"
54
+
55
+ echo "[clean_stale_singleton] ${profile_dir##*/}: cleared stale singleton locks (was PID ${pid:-unknown})." >&2
56
+ exit 0
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env python3
2
+ """Close every CDP "page" tab in harness Chrome except one.
3
+
4
+ Called from skill/lib/twitter-backend.sh::ensure_twitter_browser_for_backend
5
+ and skill/engage-twitter.sh's inline harness branch as part of pre-flight.
6
+ Safe to call any time: exits 0 silently when harness Chrome is down. Workers
7
+ and iframe targets are left alone; they auto-clean when their parent page
8
+ closes.
9
+
10
+ The standalone-script form (vs an inline heredoc) is required because bash
11
+ 3.2 on macOS cannot parse a nested heredoc inside a function body inside a
12
+ sourced file. See git history around 2026-05-14 for the prior inline form
13
+ that broke every launchd-fired twitter script.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ import urllib.request
21
+
22
+ # Port can be overridden via BH_CLEANUP_PORT so the LinkedIn backend
23
+ # (skill/lib/linkedin-backend.sh) can reuse this same cleanup script against
24
+ # its own harness Chrome on 9556. Default 9555 keeps Twitter callers unchanged.
25
+ CDP_PORT = int(os.environ.get("BH_CLEANUP_PORT", "9555"))
26
+ CDP_URL = f"http://127.0.0.1:{CDP_PORT}"
27
+
28
+
29
+ def main() -> int:
30
+ try:
31
+ with urllib.request.urlopen(f"{CDP_URL}/json", timeout=2) as r:
32
+ tabs = json.loads(r.read())
33
+ except Exception:
34
+ return 0
35
+ pages = [t for t in tabs if t.get("type") == "page"]
36
+ if len(pages) <= 1:
37
+ print(f"[cleanup_harness_tabs] {len(pages)} page tab(s), no cleanup needed")
38
+ return 0
39
+ # Keep a REAL (http/https) tab when one exists, not blindly pages[0]. The
40
+ # /json order is roughly most-recently-active first, so a freshly-spawned
41
+ # about:blank can sit at index 0 and the old code would keep the blank and
42
+ # close the live x.com tab the harness daemon is attached to. Closing the
43
+ # daemon's tab forces it to re-attach and re-spawn another about:blank, which
44
+ # is exactly the orphan-tab churn this script is meant to clean up. Falling
45
+ # back to pages[0] preserves the prior behavior when every tab is blank.
46
+ def _is_real(t):
47
+ return (t.get("url") or "").startswith(("http://", "https://"))
48
+
49
+ keep = next((t for t in pages if _is_real(t)), pages[0])
50
+ closed = 0
51
+ for t in pages:
52
+ if t is keep:
53
+ continue
54
+ tid = t.get("id")
55
+ if not tid:
56
+ continue
57
+ try:
58
+ urllib.request.urlopen(f"{CDP_URL}/json/close/{tid}", timeout=2).read()
59
+ closed += 1
60
+ except Exception:
61
+ pass
62
+ kept_kind = "1 real" if _is_real(keep) else "1"
63
+ print(f"[cleanup_harness_tabs] closed {closed}/{len(pages) - 1} extra page tabs (kept {kept_kind})")
64
+ return 0
65
+
66
+
67
+ if __name__ == "__main__":
68
+ sys.exit(main())
@@ -0,0 +1,454 @@
1
+ #!/usr/bin/env python3
2
+ """copy_browser_cookies.py - self-contained Chromium cookie copier.
3
+
4
+ Reads cookies from a local Chromium-family browser profile (Chrome / Arc /
5
+ Brave / Edge), decrypts them with the OS keychain, and injects them into a
6
+ running Chrome via CDP. Used by setup_twitter_auth.py (the MCP `connect_x`
7
+ flow) to import a user's x.com/twitter.com session into the autoposter's
8
+ managed browser WITHOUT a manual login.
9
+
10
+ This is a VENDORED, dependency-light copy of the logic that previously lived in
11
+ the separate ~/ai-browser-profile repo (ai_browser_profile.cookies +
12
+ ai_browser_profile.ingestors.browser_detect). That repo is a private
13
+ personal-memory project that is never installed on a customer machine, so the
14
+ old code path silently failed on every fresh install and fell back to manual
15
+ login. Vendoring it here means the auto-import works out of the box with only
16
+ the deps social-autoposter already ships (cryptography + websocket-client; see
17
+ requirements.txt). Keep the CLI surface (`copy` / `list`) stable: it is the
18
+ contract setup_twitter_auth.py shells out to.
19
+
20
+ macOS only for now (uses the `security` keychain CLI and ~/Library paths). On
21
+ Linux the caller's manual-login fallback still covers the gap.
22
+
23
+ CLI:
24
+ python3 copy_browser_cookies.py copy \\
25
+ --from chrome:Default --to http://127.0.0.1:9555 \\
26
+ --domains x.com,twitter.com
27
+ python3 copy_browser_cookies.py list \\
28
+ --from chrome:Default --domains x.com,twitter.com
29
+
30
+ Cookie VALUES are never printed; `list` reports counts per host only.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import hashlib
37
+ import json
38
+ import logging
39
+ import shutil
40
+ import sqlite3
41
+ import subprocess
42
+ import sys
43
+ import tempfile
44
+ import urllib.request
45
+ from dataclasses import dataclass
46
+ from pathlib import Path
47
+ from typing import Iterable, Optional, Set
48
+
49
+ log = logging.getLogger("copy_browser_cookies")
50
+
51
+ APP_SUPPORT = Path.home() / "Library" / "Application Support"
52
+
53
+ KEYCHAIN_SERVICE = {
54
+ "chrome": "Chrome Safe Storage",
55
+ "arc": "Arc Safe Storage",
56
+ "brave": "Brave Safe Storage",
57
+ "edge": "Microsoft Edge Safe Storage",
58
+ "chromium": "Chromium Safe Storage",
59
+ }
60
+
61
+ # Chromium cookie-encryption constants (v10/v11 AES-CBC on macOS).
62
+ PBKDF2_SALT = b"saltysalt"
63
+ PBKDF2_ITERATIONS = 1003
64
+ AES_KEY_LENGTH = 16
65
+ AES_IV = b" " * 16
66
+
67
+ SAMESITE_MAP = {-1: "Unspecified", 0: "None", 1: "Lax", 2: "Strict"}
68
+
69
+
70
+ # --- Browser / profile detection (stdlib only) ------------------------------
71
+
72
+ @dataclass
73
+ class BrowserProfile:
74
+ browser: str # "arc", "chrome", "brave", "edge", "chromium"
75
+ name: str # "Default", "Profile 1", etc.
76
+ path: Path # Full path to the profile directory
77
+
78
+
79
+ def _chromium_profiles(browser: str, base: Path) -> list[BrowserProfile]:
80
+ """Find Chromium-based browser profiles (Default, Profile 1, etc.)."""
81
+ profiles: list[BrowserProfile] = []
82
+ if not base.exists():
83
+ return profiles
84
+
85
+ for d in sorted(base.iterdir()):
86
+ if d.is_dir() and (d.name == "Default" or d.name.startswith("Profile ")):
87
+ if (d / "History").exists() or (d / "IndexedDB").exists():
88
+ profiles.append(BrowserProfile(browser=browser, name=d.name, path=d))
89
+
90
+ if not profiles:
91
+ default = base / "Default"
92
+ if default.exists():
93
+ profiles.append(BrowserProfile(browser=browser, name="Default", path=default))
94
+
95
+ return profiles
96
+
97
+
98
+ def detect_browsers(allowed: Optional[Set[str]] = None) -> list[BrowserProfile]:
99
+ """Return all detected Chromium-family browser profiles, optionally filtered."""
100
+ profiles: list[BrowserProfile] = []
101
+ browsers = {
102
+ "arc": APP_SUPPORT / "Arc" / "User Data",
103
+ "chrome": APP_SUPPORT / "Google" / "Chrome",
104
+ "brave": APP_SUPPORT / "BraveSoftware" / "Brave-Browser",
105
+ "edge": APP_SUPPORT / "Microsoft Edge",
106
+ "chromium": APP_SUPPORT / "Chromium",
107
+ }
108
+ for name, base in browsers.items():
109
+ if allowed and name not in allowed:
110
+ continue
111
+ profiles.extend(_chromium_profiles(name, base))
112
+ log.info("Detected %d browser profiles: %s", len(profiles),
113
+ [(p.browser, p.name) for p in profiles])
114
+ return profiles
115
+
116
+
117
+ def copy_db(src: Path) -> Optional[Path]:
118
+ """Copy a SQLite DB (plus -wal/-shm) to a temp dir to avoid browser locks."""
119
+ if not src.exists():
120
+ return None
121
+ try:
122
+ tmp = Path(tempfile.mkdtemp(prefix="saps_cookies_"))
123
+ dst = tmp / src.name
124
+ shutil.copy2(src, dst)
125
+ for suffix in ("-wal", "-shm"):
126
+ wal = src.parent / (src.name + suffix)
127
+ if wal.exists():
128
+ shutil.copy2(wal, tmp / (src.name + suffix))
129
+ return dst
130
+ except PermissionError:
131
+ log.warning("Permission denied reading %s. Grant Full Disk Access or skip.", src)
132
+ return None
133
+
134
+
135
+ # --- Cookie read + decrypt --------------------------------------------------
136
+
137
+ @dataclass
138
+ class Cookie:
139
+ name: str
140
+ value: str
141
+ domain: str
142
+ path: str
143
+ expires: float
144
+ secure: bool
145
+ http_only: bool
146
+ same_site: str
147
+
148
+
149
+ def _keychain_password(browser: str) -> bytes:
150
+ service = KEYCHAIN_SERVICE.get(browser)
151
+ if not service:
152
+ raise ValueError(f"No keychain service mapped for browser {browser!r}")
153
+ res = subprocess.run(
154
+ ["security", "find-generic-password", "-w", "-s", service],
155
+ capture_output=True, text=True, check=False,
156
+ )
157
+ if res.returncode != 0:
158
+ raise RuntimeError(
159
+ f"Could not read {service!r} from Keychain: "
160
+ f"{res.stderr.strip() or 'access denied'}"
161
+ )
162
+ return res.stdout.strip().encode()
163
+
164
+
165
+ def _derive_key(password: bytes) -> bytes:
166
+ return hashlib.pbkdf2_hmac(
167
+ "sha1", password, PBKDF2_SALT, PBKDF2_ITERATIONS, AES_KEY_LENGTH
168
+ )
169
+
170
+
171
+ def _decrypt(encrypted: bytes, key: bytes, host_key: str) -> Optional[str]:
172
+ """Decrypt a Chromium cookie value. Returns None on failure."""
173
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
174
+
175
+ if not encrypted:
176
+ return None
177
+ prefix = encrypted[:3]
178
+ payload = encrypted[3:] if prefix in (b"v10", b"v11") else encrypted
179
+ if len(payload) % 16 != 0:
180
+ return None
181
+ cipher = Cipher(algorithms.AES(key), modes.CBC(AES_IV))
182
+ dec = cipher.decryptor()
183
+ plain = dec.update(payload) + dec.finalize()
184
+ if not plain:
185
+ return None
186
+ pad = plain[-1]
187
+ if 1 <= pad <= 16 and plain.endswith(bytes([pad]) * pad):
188
+ plain = plain[:-pad]
189
+ # Chrome 80+ prepends SHA256(host_key) (32 bytes) to bind cookie to its host.
190
+ expected = hashlib.sha256(host_key.encode()).digest()
191
+ if plain.startswith(expected):
192
+ plain = plain[32:]
193
+ try:
194
+ return plain.decode("utf-8")
195
+ except UnicodeDecodeError:
196
+ return plain.decode("utf-8", errors="replace")
197
+
198
+
199
+ def read_cookies(
200
+ profile: BrowserProfile,
201
+ domains: Optional[Iterable[str]] = None,
202
+ ) -> list[Cookie]:
203
+ """Read and decrypt cookies from a Chromium browser profile.
204
+
205
+ `domains` is an iterable of host suffixes; a cookie is kept if its host_key
206
+ equals or is a subdomain of any of them ('x.com' keeps 'x.com'/'api.x.com'
207
+ but not 'fedex.com'). None keeps all cookies.
208
+ """
209
+ cookies_path = profile.path / "Cookies"
210
+ if not cookies_path.exists():
211
+ # Newer Chrome nests the cookie DB under Network/.
212
+ nested = profile.path / "Network" / "Cookies"
213
+ if nested.exists():
214
+ cookies_path = nested
215
+ else:
216
+ raise FileNotFoundError(f"No Cookies file at {cookies_path}")
217
+
218
+ tmp = copy_db(cookies_path)
219
+ if tmp is None:
220
+ raise RuntimeError(
221
+ f"Could not copy {cookies_path}. Grant Full Disk Access to your terminal and retry."
222
+ )
223
+
224
+ domain_filters = list(domains) if domains else None
225
+
226
+ def _host_matches(host: str) -> bool:
227
+ h = host or ""
228
+ if "://" in h:
229
+ h = h.split("://", 1)[1]
230
+ h = h.split("/", 1)[0].split(":", 1)[0].lstrip(".").lower()
231
+ for f in (domain_filters or []):
232
+ ff = (f or "").strip().lstrip(".").lower()
233
+ if not ff:
234
+ continue
235
+ if h == ff or h.endswith("." + ff):
236
+ return True
237
+ return False
238
+
239
+ def _txt(b) -> str:
240
+ if b is None:
241
+ return ""
242
+ if isinstance(b, bytes):
243
+ return b.decode("utf-8", errors="replace")
244
+ return str(b)
245
+
246
+ key = _derive_key(_keychain_password(profile.browser))
247
+ cookies: list[Cookie] = []
248
+ skipped = 0
249
+ try:
250
+ conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
251
+ # Arc and some Chrome forks declare encrypted_value as TEXT, not BLOB,
252
+ # which makes sqlite3 try to UTF-8-decode the AES ciphertext and crash
253
+ # mid-iteration. Force everything to bytes and decode TEXT ourselves.
254
+ conn.text_factory = bytes
255
+ conn.row_factory = sqlite3.Row
256
+ rows = conn.execute(
257
+ "SELECT host_key, name, value, encrypted_value, path, expires_utc, "
258
+ "is_secure, is_httponly, samesite FROM cookies"
259
+ )
260
+ for row in rows:
261
+ host = _txt(row["host_key"])
262
+ if domain_filters and not _host_matches(host):
263
+ continue
264
+ value = _txt(row["value"])
265
+ if not value and row["encrypted_value"]:
266
+ value = _decrypt(row["encrypted_value"], key, host) or ""
267
+ if not value:
268
+ skipped += 1
269
+ continue
270
+ expires = 0.0
271
+ if row["expires_utc"]:
272
+ # Chromium epoch is 1601-01-01 in microseconds.
273
+ expires = (row["expires_utc"] / 1_000_000) - 11644473600
274
+ cookies.append(Cookie(
275
+ name=_txt(row["name"]),
276
+ value=value,
277
+ domain=host,
278
+ path=_txt(row["path"]) or "/",
279
+ expires=expires,
280
+ secure=bool(row["is_secure"]),
281
+ http_only=bool(row["is_httponly"]),
282
+ same_site=SAMESITE_MAP.get(row["samesite"], "Unspecified"),
283
+ ))
284
+ conn.close()
285
+ finally:
286
+ shutil.rmtree(tmp.parent, ignore_errors=True)
287
+
288
+ log.info("Read %d cookies from %s/%s (skipped %d undecryptable)",
289
+ len(cookies), profile.browser, profile.name, skipped)
290
+ return cookies
291
+
292
+
293
+ # --- CDP injection ----------------------------------------------------------
294
+
295
+ def _ws_from_cdp_url(cdp_url: str) -> str:
296
+ if cdp_url.startswith("ws://") or cdp_url.startswith("wss://"):
297
+ return cdp_url
298
+ if cdp_url.startswith("cdp://"):
299
+ cdp_url = "http://" + cdp_url[len("cdp://"):]
300
+ base = cdp_url.rstrip("/")
301
+ with urllib.request.urlopen(f"{base}/json/version", timeout=5) as r:
302
+ return json.loads(r.read())["webSocketDebuggerUrl"]
303
+
304
+
305
+ def inject_via_cdp(cookies: Iterable[Cookie], cdp_url: str = "http://127.0.0.1:9222") -> int:
306
+ """Inject cookies into a running Chrome via CDP. Returns the count accepted.
307
+
308
+ Tries Storage.setCookies at the browser root first; if the browser has no
309
+ Page targets that command fails, so we open a stub about:blank tab and use
310
+ Network.setCookies on its session instead.
311
+ """
312
+ from websocket import create_connection
313
+
314
+ ws_url = _ws_from_cdp_url(cdp_url)
315
+ # Chrome 111+ enforces CDP origin checking; suppressing the Origin header
316
+ # bypasses it (localhost CDP is already privileged).
317
+ ws = create_connection(ws_url, timeout=10, suppress_origin=True)
318
+ msg_id = 0
319
+
320
+ def _send(method, params=None, session_id=None):
321
+ nonlocal msg_id
322
+ msg_id += 1
323
+ msg = {"id": msg_id, "method": method}
324
+ if params:
325
+ msg["params"] = params
326
+ if session_id:
327
+ msg["sessionId"] = session_id
328
+ ws.send(json.dumps(msg))
329
+ while True:
330
+ resp = json.loads(ws.recv())
331
+ if resp.get("id") == msg_id:
332
+ return resp
333
+
334
+ try:
335
+ batch = []
336
+ for c in cookies:
337
+ param = {
338
+ "name": c.name,
339
+ "value": c.value,
340
+ "domain": c.domain,
341
+ "path": c.path or "/",
342
+ "secure": c.secure,
343
+ "httpOnly": c.http_only,
344
+ }
345
+ if c.same_site in ("Strict", "Lax", "None"):
346
+ param["sameSite"] = c.same_site
347
+ if c.expires > 0:
348
+ param["expires"] = c.expires
349
+ batch.append(param)
350
+ if not batch:
351
+ return 0
352
+
353
+ resp = _send("Storage.setCookies", {"cookies": batch})
354
+ err = resp.get("error", {})
355
+ if not err:
356
+ log.info("Injected %d cookies via Storage.setCookies", len(batch))
357
+ return len(batch)
358
+
359
+ msg = err.get("message", "")
360
+ if "Browser context management is not supported" not in msg:
361
+ log.warning("Storage.setCookies failed: %s", err)
362
+ return 0
363
+
364
+ log.info("Storage.setCookies unavailable (no tabs); opening stub tab and retrying")
365
+ target_id = None
366
+ try:
367
+ r = _send("Target.createTarget", {"url": "about:blank"})
368
+ target_id = r.get("result", {}).get("targetId")
369
+ if not target_id:
370
+ log.warning("Couldn't create stub tab: %s", r)
371
+ return 0
372
+ r = _send("Target.attachToTarget", {"targetId": target_id, "flatten": True})
373
+ session_id = r.get("result", {}).get("sessionId")
374
+ if not session_id:
375
+ log.warning("Couldn't attach to stub tab: %s", r)
376
+ return 0
377
+ r = _send("Network.setCookies", {"cookies": batch}, session_id=session_id)
378
+ if r.get("error"):
379
+ log.warning("Network.setCookies failed: %s", r["error"])
380
+ return 0
381
+ log.info("Injected %d cookies via Network.setCookies (per-tab fallback)", len(batch))
382
+ return len(batch)
383
+ finally:
384
+ if target_id:
385
+ try:
386
+ _send("Target.closeTarget", {"targetId": target_id})
387
+ except Exception:
388
+ pass
389
+ finally:
390
+ ws.close()
391
+
392
+
393
+ # --- CLI --------------------------------------------------------------------
394
+
395
+ def find_profile(spec: str) -> BrowserProfile:
396
+ """Resolve a 'browser:profile' spec (e.g. 'chrome:Default') to a BrowserProfile."""
397
+ if ":" in spec:
398
+ browser, name = spec.split(":", 1)
399
+ else:
400
+ browser, name = spec, "Default"
401
+ matches = [p for p in detect_browsers({browser}) if p.name == name]
402
+ if not matches:
403
+ available = [(p.browser, p.name) for p in detect_browsers({browser})]
404
+ raise SystemExit(f"No profile {spec!r}. Available {browser} profiles: {available}")
405
+ return matches[0]
406
+
407
+
408
+ def _cli(argv: Optional[list[str]] = None) -> int:
409
+ parser = argparse.ArgumentParser(prog="copy_browser_cookies.py")
410
+ sub = parser.add_subparsers(dest="cmd", required=True)
411
+
412
+ cp = sub.add_parser("copy", help="copy cookies from a local profile into a running browser via CDP")
413
+ cp.add_argument("--from", dest="src", required=True,
414
+ help="source profile, e.g. chrome:Default or arc:'Profile 1'")
415
+ cp.add_argument("--to", dest="dst", required=True,
416
+ help="target CDP endpoint, e.g. http://127.0.0.1:9555 or cdp://127.0.0.1:9555")
417
+ cp.add_argument("--domains", default=None,
418
+ help="comma-separated host suffixes to include (e.g. x.com,twitter.com)")
419
+ cp.add_argument("-v", "--verbose", action="store_true")
420
+
421
+ ls = sub.add_parser("list", help="list cookies in a local profile (counts only, no values)")
422
+ ls.add_argument("--from", dest="src", required=True)
423
+ ls.add_argument("--domains", default=None)
424
+ ls.add_argument("-v", "--verbose", action="store_true")
425
+
426
+ args = parser.parse_args(argv)
427
+ logging.basicConfig(
428
+ level=logging.DEBUG if getattr(args, "verbose", False) else logging.INFO,
429
+ format="%(levelname)s %(message)s",
430
+ )
431
+
432
+ profile = find_profile(args.src)
433
+ domain_filters = [d.strip() for d in args.domains.split(",")] if args.domains else None
434
+ cookies = read_cookies(profile, domains=domain_filters)
435
+
436
+ if args.cmd == "list":
437
+ by_host: dict[str, int] = {}
438
+ for c in cookies:
439
+ by_host[c.domain] = by_host.get(c.domain, 0) + 1
440
+ for host, n in sorted(by_host.items(), key=lambda kv: -kv[1]):
441
+ print(f" {n:4} {host}")
442
+ print(f"Total: {len(cookies)} cookies across {len(by_host)} hosts")
443
+ return 0
444
+
445
+ if args.cmd == "copy":
446
+ n = inject_via_cdp(cookies, args.dst)
447
+ print(f"Injected {n}/{len(cookies)} cookies into {args.dst}")
448
+ return 0 if n > 0 else 2
449
+
450
+ return 1
451
+
452
+
453
+ if __name__ == "__main__":
454
+ sys.exit(_cli())