@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.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1314 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +497 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- 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())
|