@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,968 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Run via:
|
|
3
|
+
# uv run --quiet --with mcp <this-file>
|
|
4
|
+
# or:
|
|
5
|
+
# python3 -m pip install --break-system-packages 'mcp>=1.0.0' && python3 <this-file>
|
|
6
|
+
"""
|
|
7
|
+
browser-harness MCP server.
|
|
8
|
+
|
|
9
|
+
Wraps the `browser-harness` CLI (https://github.com/browser-use/browser-harness)
|
|
10
|
+
behind an MCP stdio server so any Claude Code session can drive direct CDP
|
|
11
|
+
browser control without manually managing the daemon.
|
|
12
|
+
|
|
13
|
+
Architecture:
|
|
14
|
+
- Auto-launches a dedicated Chrome instance on port 9555 with a persistent
|
|
15
|
+
profile at ~/.claude/browser-profiles/browser-harness so cookies/sessions
|
|
16
|
+
carry across Claude Code sessions.
|
|
17
|
+
- Exposes tools that shell out to the `browser-harness` CLI with
|
|
18
|
+
BU_CDP_URL pointed at our managed Chrome.
|
|
19
|
+
- Stays out of the user's normal Chrome (which is what playwright-extension
|
|
20
|
+
uses); this is a separate isolated profile.
|
|
21
|
+
|
|
22
|
+
Cross-platform: works on macOS and Linux. Chrome binary is auto-detected
|
|
23
|
+
(env override: BH_CHROME_BIN). On Linux + root we add --no-sandbox; on Linux
|
|
24
|
+
without a display we add --headless=new (override with BH_HEADLESS=0 to force
|
|
25
|
+
headed, e.g. when Xvfb is available).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import shutil
|
|
34
|
+
import socket
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
import urllib.request
|
|
39
|
+
import urllib.error
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from mcp.server.fastmcp import FastMCP
|
|
43
|
+
|
|
44
|
+
# --- Config ---
|
|
45
|
+
|
|
46
|
+
PORT = int(os.environ.get("BH_PORT", "9555"))
|
|
47
|
+
|
|
48
|
+
# Profile name can be overridden via BH_PROFILE_NAME env so multiple harness
|
|
49
|
+
# instances (twitter-harness on 9555, linkedin-harness on 9556, reddit-harness
|
|
50
|
+
# on 9557) can run side by side on SEPARATE persistent profiles + PID files
|
|
51
|
+
# without stomping each other's cookies/sessions. If this is hardcoded to
|
|
52
|
+
# "browser-harness", every non-default-port instance lands on the Twitter
|
|
53
|
+
# profile and shares one PID_FILE, so the per-instance ensure_chrome() calls
|
|
54
|
+
# SIGKILL each other's Chrome (regression 2026-06-02, fixed by restoring this).
|
|
55
|
+
# Default "browser-harness" keeps the existing Twitter setup unchanged.
|
|
56
|
+
PROFILE_NAME = os.environ.get("BH_PROFILE_NAME", "browser-harness")
|
|
57
|
+
PROFILE_DIR = Path.home() / ".claude" / "browser-profiles" / PROFILE_NAME
|
|
58
|
+
PID_FILE = Path.home() / ".claude" / "browser-profiles" / f"{PROFILE_NAME}.chrome.pid"
|
|
59
|
+
LOG_FILE = Path.home() / ".claude" / "browser-profiles" / f"{PROFILE_NAME}.chrome.log"
|
|
60
|
+
MCP_LOG_FILE = Path.home() / ".claude" / "browser-profiles" / f"{PROFILE_NAME}.mcp.log"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _playwright_chromium_bins() -> list[str]:
|
|
64
|
+
"""Chromium binaries the runtime's `playwright install chromium` step drops
|
|
65
|
+
into the shared ms-playwright cache, newest revision first.
|
|
66
|
+
|
|
67
|
+
A .mcpb user with no system Chrome (e.g. only Arc installed) still gets a
|
|
68
|
+
working browser here because the runtime download lands in this cache. The
|
|
69
|
+
rev number (chromium-1208/1217/1223/...) changes per Playwright pin, and the
|
|
70
|
+
bundle is named "Google Chrome for Testing" on recent revs / "Chromium" on
|
|
71
|
+
older ones, so we glob rather than hardcode. macOS uses chrome-mac[-arm64];
|
|
72
|
+
Linux uses chrome-linux.
|
|
73
|
+
"""
|
|
74
|
+
cache = Path.home() / "Library" / "Caches" / "ms-playwright" # macOS
|
|
75
|
+
linux_cache = Path.home() / ".cache" / "ms-playwright" # Linux
|
|
76
|
+
patterns = [
|
|
77
|
+
# macOS, newer revs (arm64 + x64): "Google Chrome for Testing"
|
|
78
|
+
"chromium-*/chrome-mac*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
79
|
+
# macOS, older revs: "Chromium"
|
|
80
|
+
"chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium",
|
|
81
|
+
# Linux
|
|
82
|
+
"chromium-*/chrome-linux/chrome",
|
|
83
|
+
]
|
|
84
|
+
found: list[str] = []
|
|
85
|
+
for root in (cache, linux_cache):
|
|
86
|
+
if not root.exists():
|
|
87
|
+
continue
|
|
88
|
+
for pat in patterns:
|
|
89
|
+
for hit in root.glob(pat):
|
|
90
|
+
if hit.exists():
|
|
91
|
+
found.append(str(hit))
|
|
92
|
+
|
|
93
|
+
def _rev(p: str) -> int:
|
|
94
|
+
# Sort by the chromium-<rev> number so the newest install wins.
|
|
95
|
+
for part in Path(p).parts:
|
|
96
|
+
if part.startswith("chromium-") and part[len("chromium-"):].isdigit():
|
|
97
|
+
return int(part[len("chromium-"):])
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
return sorted(set(found), key=_rev, reverse=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _detect_chrome_bin() -> str:
|
|
104
|
+
"""Find the Chrome binary on disk. Env override wins. Returns "" if nothing
|
|
105
|
+
real is found (callers can then trigger an install fallback)."""
|
|
106
|
+
env = os.environ.get("BH_CHROME_BIN")
|
|
107
|
+
if env and Path(env).exists():
|
|
108
|
+
return env
|
|
109
|
+
|
|
110
|
+
candidates = [
|
|
111
|
+
# macOS
|
|
112
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
113
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
114
|
+
# Linux (Debian/Ubuntu defaults)
|
|
115
|
+
"/usr/bin/google-chrome",
|
|
116
|
+
"/usr/bin/google-chrome-stable",
|
|
117
|
+
"/usr/bin/chromium",
|
|
118
|
+
"/usr/bin/chromium-browser",
|
|
119
|
+
"/snap/bin/chromium",
|
|
120
|
+
]
|
|
121
|
+
for p in candidates:
|
|
122
|
+
if Path(p).exists():
|
|
123
|
+
return p
|
|
124
|
+
|
|
125
|
+
# The runtime's own bundled Chromium (ms-playwright cache). This is what
|
|
126
|
+
# makes setup work on a machine with no system Chrome installed.
|
|
127
|
+
for p in _playwright_chromium_bins():
|
|
128
|
+
return p
|
|
129
|
+
|
|
130
|
+
# Fall back to PATH lookup.
|
|
131
|
+
for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
|
|
132
|
+
found = shutil.which(name)
|
|
133
|
+
if found:
|
|
134
|
+
return found
|
|
135
|
+
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _chrome_exists(p: str) -> bool:
|
|
140
|
+
return bool(p) and (Path(p).exists() or shutil.which(p) is not None)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _venv_python() -> str | None:
|
|
144
|
+
"""The runtime venv interpreter (has playwright). Mirrors runtime.ts:
|
|
145
|
+
<SAPS_STATE_DIR|~/.social-autoposter-mcp>/runtime/.venv/bin/python3."""
|
|
146
|
+
state_dir = Path(os.environ.get("SAPS_STATE_DIR", str(Path.home() / ".social-autoposter-mcp")))
|
|
147
|
+
if sys.platform == "win32":
|
|
148
|
+
cand = state_dir / "runtime" / ".venv" / "Scripts" / "python.exe"
|
|
149
|
+
else:
|
|
150
|
+
cand = state_dir / "runtime" / ".venv" / "bin" / "python3"
|
|
151
|
+
return str(cand) if cand.exists() else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _install_chromium() -> dict:
|
|
155
|
+
"""Last-ditch fallback when no Chromium is found anywhere: run
|
|
156
|
+
`playwright install chromium` so a fresh machine self-heals instead of
|
|
157
|
+
dead-ending at no_chrome_binary. Uses the runtime venv if present, otherwise
|
|
158
|
+
the interpreter running this server. The download lands in the shared
|
|
159
|
+
ms-playwright cache that _detect_chrome_bin() globs."""
|
|
160
|
+
py = _venv_python() or sys.executable
|
|
161
|
+
_log(f"no chromium found; attempting `playwright install chromium` via {py}")
|
|
162
|
+
try:
|
|
163
|
+
r = subprocess.run(
|
|
164
|
+
[py, "-m", "playwright", "install", "chromium"],
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
timeout=int(os.environ.get("BH_CHROME_INSTALL_TIMEOUT_SEC", "600")),
|
|
168
|
+
)
|
|
169
|
+
except Exception as e: # noqa: BLE001
|
|
170
|
+
_log(f"chromium install failed to launch: {e}")
|
|
171
|
+
return {"ok": False, "python": py, "error": str(e)}
|
|
172
|
+
ok = r.returncode == 0
|
|
173
|
+
_log(f"chromium install exit={r.returncode}")
|
|
174
|
+
return {"ok": ok, "python": py, "exit": r.returncode, "out": (r.stdout + r.stderr)[-600:]}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _detect_browser_harness_bin() -> str:
|
|
178
|
+
"""Find the browser-harness CLI. Env override wins."""
|
|
179
|
+
env = os.environ.get("BH_HARNESS_BIN")
|
|
180
|
+
if env and Path(env).exists():
|
|
181
|
+
return env
|
|
182
|
+
|
|
183
|
+
# uv-tool default install location.
|
|
184
|
+
candidate = Path.home() / ".local" / "bin" / "browser-harness"
|
|
185
|
+
if candidate.exists():
|
|
186
|
+
return str(candidate)
|
|
187
|
+
|
|
188
|
+
found = shutil.which("browser-harness")
|
|
189
|
+
if found:
|
|
190
|
+
return found
|
|
191
|
+
|
|
192
|
+
return str(candidate) # report the expected path even if missing
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
CHROME_BIN = _detect_chrome_bin()
|
|
196
|
+
BROWSER_HARNESS_BIN = _detect_browser_harness_bin()
|
|
197
|
+
CDP_URL = f"http://127.0.0.1:{PORT}"
|
|
198
|
+
|
|
199
|
+
# Default exec timeout for a single tool call (seconds). Browser flows can be
|
|
200
|
+
# slow; raise the cap so multi-step scripts don't get killed mid-flight.
|
|
201
|
+
EXEC_TIMEOUT_SEC = int(os.environ.get("BH_EXEC_TIMEOUT_SEC", "300"))
|
|
202
|
+
|
|
203
|
+
# Heuristic: are we on Linux without a graphical display? Then we should
|
|
204
|
+
# launch Chrome headless unless the operator explicitly says otherwise.
|
|
205
|
+
_IS_LINUX = sys.platform.startswith("linux")
|
|
206
|
+
_HAS_DISPLAY = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
207
|
+
_DEFAULT_HEADLESS = "1" if (_IS_LINUX and not _HAS_DISPLAY) else "0"
|
|
208
|
+
HEADLESS = os.environ.get("BH_HEADLESS", _DEFAULT_HEADLESS) == "1"
|
|
209
|
+
RUNNING_AS_ROOT = (hasattr(os, "geteuid") and os.geteuid() == 0)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --- Logging ---
|
|
213
|
+
|
|
214
|
+
def _log(msg: str) -> None:
|
|
215
|
+
try:
|
|
216
|
+
MCP_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
with MCP_LOG_FILE.open("a") as f:
|
|
218
|
+
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
# --- Chrome lifecycle ---
|
|
223
|
+
|
|
224
|
+
# Hardcoded fallbacks used only the very first time a profile launches (before
|
|
225
|
+
# Chrome has written any window_placement to its Preferences).
|
|
226
|
+
DEFAULT_WINDOW_POS = "3042,-1032"
|
|
227
|
+
DEFAULT_WINDOW_SIZE = "1024,1013"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _persisted_window_geometry() -> tuple[str | None, str | None]:
|
|
231
|
+
"""Read the window position+size Chrome last persisted for THIS profile.
|
|
232
|
+
|
|
233
|
+
Chrome writes the live window bounds to <profile>/Default/Preferences ->
|
|
234
|
+
browser.window_placement (left/top/right/bottom, in screen coords) whenever
|
|
235
|
+
the user moves/resizes the window. By reading that back and feeding it into
|
|
236
|
+
the launch flags, a user's manual placement survives SIGKILL+relaunch
|
|
237
|
+
instead of snapping back to the hardcoded default. Returns ("X,Y", "W,H")
|
|
238
|
+
or (None, None) when nothing usable is persisted yet.
|
|
239
|
+
"""
|
|
240
|
+
pref = PROFILE_DIR / "Default" / "Preferences"
|
|
241
|
+
try:
|
|
242
|
+
wp = json.loads(pref.read_text()).get("browser", {}).get("window_placement")
|
|
243
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
244
|
+
return (None, None)
|
|
245
|
+
if not isinstance(wp, dict) or wp.get("maximized"):
|
|
246
|
+
return (None, None)
|
|
247
|
+
try:
|
|
248
|
+
left, top = int(wp["left"]), int(wp["top"])
|
|
249
|
+
width, height = int(wp["right"]) - left, int(wp["bottom"]) - top
|
|
250
|
+
except (KeyError, TypeError, ValueError):
|
|
251
|
+
return (None, None)
|
|
252
|
+
if width <= 0 or height <= 0:
|
|
253
|
+
return (f"{left},{top}", None)
|
|
254
|
+
return (f"{left},{top}", f"{width},{height}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _port_open(port: int) -> bool:
|
|
258
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
259
|
+
s.settimeout(0.5)
|
|
260
|
+
try:
|
|
261
|
+
s.connect(("127.0.0.1", port))
|
|
262
|
+
return True
|
|
263
|
+
except OSError:
|
|
264
|
+
return False
|
|
265
|
+
finally:
|
|
266
|
+
s.close()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _cdp_alive() -> bool:
|
|
270
|
+
"""Return True if the CDP /json/version endpoint responds."""
|
|
271
|
+
if not _port_open(PORT):
|
|
272
|
+
return False
|
|
273
|
+
try:
|
|
274
|
+
with urllib.request.urlopen(f"{CDP_URL}/json/version", timeout=1.5) as r:
|
|
275
|
+
return r.status == 200
|
|
276
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _read_pid() -> int | None:
|
|
281
|
+
try:
|
|
282
|
+
return int(PID_FILE.read_text().strip())
|
|
283
|
+
except (FileNotFoundError, ValueError):
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _pid_alive(pid: int) -> bool:
|
|
288
|
+
try:
|
|
289
|
+
os.kill(pid, 0)
|
|
290
|
+
return True
|
|
291
|
+
except OSError:
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _build_chrome_cmd() -> list[str]:
|
|
296
|
+
"""Compose the Chrome launch argv for this platform / environment."""
|
|
297
|
+
cmd = [
|
|
298
|
+
CHROME_BIN,
|
|
299
|
+
f"--remote-debugging-port={PORT}",
|
|
300
|
+
f"--user-data-dir={PROFILE_DIR}",
|
|
301
|
+
"--no-first-run",
|
|
302
|
+
"--no-default-browser-check",
|
|
303
|
+
"--disable-features=ChromeWhatsNewUI",
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
# Headless + sandboxing for Linux/root.
|
|
307
|
+
if HEADLESS:
|
|
308
|
+
cmd.append("--headless=new")
|
|
309
|
+
cmd.append("--disable-gpu")
|
|
310
|
+
if RUNNING_AS_ROOT or _IS_LINUX:
|
|
311
|
+
# --no-sandbox is required when Chrome runs as root (e.g. inside a
|
|
312
|
+
# rootful container/VM). Harmless on Linux non-root too.
|
|
313
|
+
cmd.append("--no-sandbox")
|
|
314
|
+
cmd.append("--disable-dev-shm-usage")
|
|
315
|
+
|
|
316
|
+
# Persistent window placement on macOS multi-monitor setups.
|
|
317
|
+
# Skip on headless / Linux where positioning is meaningless and the
|
|
318
|
+
# off-screen values would just hide the window on a single-monitor setup.
|
|
319
|
+
# Position priority (2026-06-02):
|
|
320
|
+
# 1. BH_WINDOW_POS / BH_WINDOW_SIZE env (explicit hard override)
|
|
321
|
+
# 2. whatever Chrome last persisted for this profile (the user's own
|
|
322
|
+
# manually-dragged position) -> so user placement survives relaunch
|
|
323
|
+
# 3. hardcoded default (first-ever launch only)
|
|
324
|
+
# We still pass an explicit --window-position flag (rather than letting
|
|
325
|
+
# Chrome restore on its own), so SIGKILL+relaunch can't cascade/drift the
|
|
326
|
+
# window: we control the exact value, but that value now tracks the user's
|
|
327
|
+
# last placement instead of a fixed constant.
|
|
328
|
+
if not HEADLESS and not _IS_LINUX:
|
|
329
|
+
saved_pos, saved_size = _persisted_window_geometry()
|
|
330
|
+
win_pos = os.environ.get("BH_WINDOW_POS") or saved_pos or DEFAULT_WINDOW_POS
|
|
331
|
+
win_size = os.environ.get("BH_WINDOW_SIZE") or saved_size or DEFAULT_WINDOW_SIZE
|
|
332
|
+
cmd.append(f"--window-position={win_pos}")
|
|
333
|
+
cmd.append(f"--window-size={win_size}")
|
|
334
|
+
|
|
335
|
+
# Open a REAL http(s) page (NOT about:blank) so the harness daemon's
|
|
336
|
+
# attach_first_page() finds an existing real tab and reuses it. Upstream
|
|
337
|
+
# browser-harness creates a throwaway about:blank on every re-attach where
|
|
338
|
+
# is_real_page() is false (which about:blank is); launching blank feeds that
|
|
339
|
+
# loop, piling up orphan tabs that cleanup_harness_tabs then has to sweep.
|
|
340
|
+
# Landing URL is derived from the profile's platform; BH_LAUNCH_URL overrides.
|
|
341
|
+
cmd.append(_launch_url())
|
|
342
|
+
return cmd
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _launch_url() -> str:
|
|
346
|
+
"""Initial tab URL for the managed Chrome. MUST be a real http(s) page, not
|
|
347
|
+
about:blank, so the browser-harness daemon reuses it instead of spawning
|
|
348
|
+
throwaway blank tabs (see _build_chrome_cmd). Derived from PROFILE_NAME;
|
|
349
|
+
BH_LAUNCH_URL overrides for non-standard profiles."""
|
|
350
|
+
override = os.environ.get("BH_LAUNCH_URL", "").strip()
|
|
351
|
+
if override:
|
|
352
|
+
return override
|
|
353
|
+
name = PROFILE_NAME.lower()
|
|
354
|
+
if "linkedin" in name:
|
|
355
|
+
return "https://www.linkedin.com/feed/"
|
|
356
|
+
if "reddit" in name:
|
|
357
|
+
return "https://www.reddit.com/"
|
|
358
|
+
return "https://x.com"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def ensure_chrome() -> dict:
|
|
362
|
+
"""Make sure our managed Chrome is running on PORT. Idempotent."""
|
|
363
|
+
global CHROME_BIN
|
|
364
|
+
if _cdp_alive():
|
|
365
|
+
return {"status": "already_running", "pid": _read_pid(), "cdp": CDP_URL}
|
|
366
|
+
|
|
367
|
+
if not _chrome_exists(CHROME_BIN):
|
|
368
|
+
# Re-detect (the runtime may have finished its chromium download since
|
|
369
|
+
# this server booted), then auto-install as a last resort so a fresh
|
|
370
|
+
# machine self-heals instead of dead-ending here.
|
|
371
|
+
CHROME_BIN = _detect_chrome_bin()
|
|
372
|
+
if not _chrome_exists(CHROME_BIN):
|
|
373
|
+
install = _install_chromium()
|
|
374
|
+
CHROME_BIN = _detect_chrome_bin()
|
|
375
|
+
if not _chrome_exists(CHROME_BIN):
|
|
376
|
+
return {
|
|
377
|
+
"status": "no_chrome_binary",
|
|
378
|
+
"looked_for": CHROME_BIN or "(none found)",
|
|
379
|
+
"install_attempt": install,
|
|
380
|
+
"hint": "Auto-install of chromium failed. Set BH_CHROME_BIN to a Chrome/Chromium path, or run `playwright install chromium`.",
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
|
384
|
+
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
385
|
+
|
|
386
|
+
# Clean up stale daemon socket so first browser-harness call doesn't
|
|
387
|
+
# try to talk to a dead daemon from a previous Chrome instance.
|
|
388
|
+
for stale in ("/tmp/bu-default.sock", "/tmp/bu-default.pid"):
|
|
389
|
+
try:
|
|
390
|
+
os.unlink(stale)
|
|
391
|
+
except FileNotFoundError:
|
|
392
|
+
pass
|
|
393
|
+
|
|
394
|
+
# If a Chrome with our profile is still partially up but not on the port,
|
|
395
|
+
# try to surface that in the log rather than silently double-launching.
|
|
396
|
+
pid = _read_pid()
|
|
397
|
+
if pid and _pid_alive(pid):
|
|
398
|
+
_log(f"stale pid {pid} alive but CDP dead; killing")
|
|
399
|
+
try:
|
|
400
|
+
os.kill(pid, 9)
|
|
401
|
+
except OSError:
|
|
402
|
+
pass
|
|
403
|
+
time.sleep(0.5)
|
|
404
|
+
|
|
405
|
+
cmd = _build_chrome_cmd()
|
|
406
|
+
|
|
407
|
+
log_fh = LOG_FILE.open("ab")
|
|
408
|
+
proc = subprocess.Popen(
|
|
409
|
+
cmd,
|
|
410
|
+
stdout=log_fh,
|
|
411
|
+
stderr=log_fh,
|
|
412
|
+
stdin=subprocess.DEVNULL,
|
|
413
|
+
start_new_session=True,
|
|
414
|
+
)
|
|
415
|
+
PID_FILE.write_text(str(proc.pid))
|
|
416
|
+
_log(f"launched Chrome pid={proc.pid} port={PORT} profile={PROFILE_DIR} headless={HEADLESS}")
|
|
417
|
+
|
|
418
|
+
# Wait for CDP to be ready. First launch on a cold/fresh machine has to
|
|
419
|
+
# create the profile and run Chrome's first-run setup, which routinely
|
|
420
|
+
# exceeds 15s on a slow VM; an over-tight deadline returns launch_timeout
|
|
421
|
+
# and the caller runs against a port that was about to come up. 30s is the
|
|
422
|
+
# safe floor (override with BH_LAUNCH_TIMEOUT_SEC).
|
|
423
|
+
launch_timeout = int(os.environ.get("BH_LAUNCH_TIMEOUT_SEC", "30"))
|
|
424
|
+
deadline = time.time() + launch_timeout
|
|
425
|
+
while time.time() < deadline:
|
|
426
|
+
if _cdp_alive():
|
|
427
|
+
return {"status": "started", "pid": proc.pid, "cdp": CDP_URL}
|
|
428
|
+
time.sleep(0.3)
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
"status": "launch_timeout",
|
|
432
|
+
"pid": proc.pid,
|
|
433
|
+
"cdp": CDP_URL,
|
|
434
|
+
"log": str(LOG_FILE),
|
|
435
|
+
"waited_sec": launch_timeout,
|
|
436
|
+
"log_tail": _chrome_log_tail(),
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _port_owner_pids() -> list[int]:
|
|
441
|
+
"""PIDs LISTENing on our debug PORT, via lsof. Lets stop_chrome reap a Chrome
|
|
442
|
+
that another launcher (e.g. setup_twitter_auth.py's connect_x) started without
|
|
443
|
+
writing PID_FILE, instead of stranding an un-reapable orphan that makes
|
|
444
|
+
bh_start keep reporting 'already_running'. Returns [] if lsof is unavailable."""
|
|
445
|
+
try:
|
|
446
|
+
out = subprocess.run(
|
|
447
|
+
["lsof", "-ti", f"tcp:{PORT}", "-sTCP:LISTEN"],
|
|
448
|
+
capture_output=True, text=True, timeout=5,
|
|
449
|
+
)
|
|
450
|
+
except (OSError, subprocess.SubprocessError):
|
|
451
|
+
return []
|
|
452
|
+
pids = []
|
|
453
|
+
for tok in (out.stdout or "").split():
|
|
454
|
+
try:
|
|
455
|
+
pids.append(int(tok))
|
|
456
|
+
except ValueError:
|
|
457
|
+
pass
|
|
458
|
+
return pids
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _terminate(pid: int, grace: float = 5.0) -> None:
|
|
462
|
+
"""SIGTERM, then SIGKILL only if still alive after `grace` seconds. The wait
|
|
463
|
+
gives Chrome time to flush its in-memory cookie store to the on-disk profile,
|
|
464
|
+
so a stop->start restart preserves the X session instead of coming back
|
|
465
|
+
logged out (the failure the setup agent hit after a hard kill)."""
|
|
466
|
+
try:
|
|
467
|
+
os.kill(pid, 15)
|
|
468
|
+
except OSError:
|
|
469
|
+
return
|
|
470
|
+
deadline = time.time() + grace
|
|
471
|
+
while time.time() < deadline:
|
|
472
|
+
if not _pid_alive(pid):
|
|
473
|
+
return
|
|
474
|
+
time.sleep(0.2)
|
|
475
|
+
try:
|
|
476
|
+
os.kill(pid, 9)
|
|
477
|
+
except OSError:
|
|
478
|
+
pass
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _cdp_browser_close() -> bool:
|
|
482
|
+
"""Ask Chrome to quit via CDP Browser.close. This is Chrome's own
|
|
483
|
+
graceful-shutdown RPC: it tears down renderers in order and flushes the
|
|
484
|
+
cookie store before exiting, which signal-based termination does not
|
|
485
|
+
reliably guarantee. Returns True if the RPC was issued; False if the
|
|
486
|
+
browser-harness CLI was missing, CDP was unreachable, or the call errored.
|
|
487
|
+
Issuing the RPC does NOT mean Chrome has exited yet — poll the pid."""
|
|
488
|
+
if not shutil.which(BROWSER_HARNESS_BIN) and not Path(BROWSER_HARNESS_BIN).exists():
|
|
489
|
+
return False
|
|
490
|
+
env = os.environ.copy()
|
|
491
|
+
env["BU_CDP_URL"] = CDP_URL
|
|
492
|
+
env["PATH"] = f"{Path.home()}/.local/bin:" + env.get("PATH", "")
|
|
493
|
+
try:
|
|
494
|
+
proc = subprocess.run(
|
|
495
|
+
[BROWSER_HARNESS_BIN],
|
|
496
|
+
input="cdp('Browser.close')\n",
|
|
497
|
+
env=env,
|
|
498
|
+
capture_output=True,
|
|
499
|
+
text=True,
|
|
500
|
+
timeout=10,
|
|
501
|
+
)
|
|
502
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
503
|
+
return False
|
|
504
|
+
return proc.returncode == 0
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def stop_chrome() -> dict:
|
|
508
|
+
"""Gracefully stop the managed Chrome — the tracked process AND any orphan
|
|
509
|
+
still LISTENing on the debug port — so connect_x-launched Chromes can't
|
|
510
|
+
strand the port.
|
|
511
|
+
|
|
512
|
+
Two-stage shutdown: first ask Chrome to quit itself via CDP `Browser.close`
|
|
513
|
+
(its own graceful-quit RPC, which flushes the cookie SQLite synchronously
|
|
514
|
+
before exit). If the process exits within `CDP_QUIT_DEADLINE_SEC`, we're
|
|
515
|
+
done — cookies are durable. Only if CDP refuses or the process doesn't
|
|
516
|
+
exit in time do we fall back to SIGTERM-with-grace, then SIGKILL. The old
|
|
517
|
+
SIGTERM+5s+SIGKILL path lost cookies because Chrome's shutdown sequence
|
|
518
|
+
sometimes outlasts the 5s window; CDP-first removes that race."""
|
|
519
|
+
CDP_QUIT_DEADLINE_SEC = 20.0
|
|
520
|
+
POLL_INTERVAL_SEC = 0.5
|
|
521
|
+
|
|
522
|
+
tracked_pid = _read_pid()
|
|
523
|
+
initial_owners = _port_owner_pids()
|
|
524
|
+
initial_targets: list[int] = []
|
|
525
|
+
if tracked_pid and _pid_alive(tracked_pid):
|
|
526
|
+
initial_targets.append(tracked_pid)
|
|
527
|
+
for owner in initial_owners:
|
|
528
|
+
if owner not in initial_targets and _pid_alive(owner):
|
|
529
|
+
initial_targets.append(owner)
|
|
530
|
+
|
|
531
|
+
cdp_attempted = False
|
|
532
|
+
cdp_issued = False
|
|
533
|
+
if initial_targets and _cdp_alive():
|
|
534
|
+
cdp_attempted = True
|
|
535
|
+
cdp_issued = _cdp_browser_close()
|
|
536
|
+
if cdp_issued:
|
|
537
|
+
deadline = time.time() + CDP_QUIT_DEADLINE_SEC
|
|
538
|
+
while time.time() < deadline:
|
|
539
|
+
still_alive = [p for p in initial_targets if _pid_alive(p)]
|
|
540
|
+
if not still_alive:
|
|
541
|
+
break
|
|
542
|
+
time.sleep(POLL_INTERVAL_SEC)
|
|
543
|
+
|
|
544
|
+
reaped: list[int] = []
|
|
545
|
+
survivors = [p for p in initial_targets if _pid_alive(p)]
|
|
546
|
+
for p in survivors:
|
|
547
|
+
_terminate(p, grace=15.0)
|
|
548
|
+
reaped.append(p)
|
|
549
|
+
|
|
550
|
+
for stale in (PID_FILE, Path("/tmp/bu-default.sock"), Path("/tmp/bu-default.pid")):
|
|
551
|
+
try:
|
|
552
|
+
stale.unlink()
|
|
553
|
+
except FileNotFoundError:
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
via = "cdp_browser_close" if (cdp_issued and not survivors) else (
|
|
557
|
+
"sigterm_fallback" if cdp_attempted else "sigterm"
|
|
558
|
+
)
|
|
559
|
+
return {
|
|
560
|
+
"status": "stopped",
|
|
561
|
+
"via": via,
|
|
562
|
+
"tracked_pid": tracked_pid,
|
|
563
|
+
"initial_targets": initial_targets,
|
|
564
|
+
"cdp_attempted": cdp_attempted,
|
|
565
|
+
"cdp_issued": cdp_issued,
|
|
566
|
+
"sigterm_reaped": reaped,
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# --- browser-harness exec wrapper ---
|
|
571
|
+
|
|
572
|
+
def _chrome_log_tail(lines: int = 25) -> str:
|
|
573
|
+
"""Last `lines` of the managed-Chrome log, for surfacing in CDP errors."""
|
|
574
|
+
try:
|
|
575
|
+
text = LOG_FILE.read_text(errors="replace")
|
|
576
|
+
except (FileNotFoundError, OSError):
|
|
577
|
+
return ""
|
|
578
|
+
return "\n".join(text.splitlines()[-lines:])
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _ensure_cdp_ready() -> dict | None:
|
|
582
|
+
"""Guarantee CDP is actually answering on PORT before we shell out to the
|
|
583
|
+
harness CLI. Returns None when CDP is live; otherwise returns a structured,
|
|
584
|
+
actionable error dict (and leaves the chrome log tail attached).
|
|
585
|
+
|
|
586
|
+
Without this gate, ensure_chrome() failures (no_chrome_binary,
|
|
587
|
+
launch_timeout) were swallowed and the CLI ran against a dead port, so the
|
|
588
|
+
agent saw a cryptic usage banner / connection error instead of the real
|
|
589
|
+
cause. This is the #1 fresh-install failure mode."""
|
|
590
|
+
res = ensure_chrome()
|
|
591
|
+
if _cdp_alive():
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
# One self-heal attempt: a stale Chrome bound to the port but not speaking
|
|
595
|
+
# CDP (crashed renderer, half-dead profile) won't recover on its own.
|
|
596
|
+
_log(f"CDP not alive after ensure_chrome (status={res.get('status')}); attempting stop+relaunch")
|
|
597
|
+
stop_chrome()
|
|
598
|
+
time.sleep(1.0)
|
|
599
|
+
res = ensure_chrome()
|
|
600
|
+
if _cdp_alive():
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
status = res.get("status", "unknown")
|
|
604
|
+
if status == "no_chrome_binary":
|
|
605
|
+
hint = res.get("hint", "Install Chrome/Chromium or set BH_CHROME_BIN.")
|
|
606
|
+
else:
|
|
607
|
+
hint = (
|
|
608
|
+
f"Chrome did not expose CDP on {CDP_URL} (status={status}). "
|
|
609
|
+
"On a headless Linux box ensure BH_HEADLESS=1 and a Chrome binary "
|
|
610
|
+
"are present; on macOS make sure no other Chrome owns the profile. "
|
|
611
|
+
f"See {LOG_FILE}."
|
|
612
|
+
)
|
|
613
|
+
return {
|
|
614
|
+
"ok": False,
|
|
615
|
+
"error": f"browser-harness CDP not connected: {hint}",
|
|
616
|
+
"cdp": CDP_URL,
|
|
617
|
+
"ensure_chrome": res,
|
|
618
|
+
"chrome_log_tail": _chrome_log_tail(),
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _run_harness(script: str, timeout: int = EXEC_TIMEOUT_SEC) -> dict:
|
|
623
|
+
if not shutil.which(BROWSER_HARNESS_BIN) and not Path(BROWSER_HARNESS_BIN).exists():
|
|
624
|
+
return {
|
|
625
|
+
"ok": False,
|
|
626
|
+
"error": (
|
|
627
|
+
f"browser-harness CLI not found at {BROWSER_HARNESS_BIN}. "
|
|
628
|
+
"Install with: cd ~/Developer/browser-harness && uv tool install -e ."
|
|
629
|
+
),
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
cdp_err = _ensure_cdp_ready()
|
|
633
|
+
if cdp_err is not None:
|
|
634
|
+
return cdp_err
|
|
635
|
+
|
|
636
|
+
env = os.environ.copy()
|
|
637
|
+
env["BU_CDP_URL"] = CDP_URL
|
|
638
|
+
# Make sure ~/.local/bin is on PATH (uv tools live there).
|
|
639
|
+
env["PATH"] = f"{Path.home()}/.local/bin:" + env.get("PATH", "")
|
|
640
|
+
|
|
641
|
+
# Upstream browser-harness dropped the `-c <script>` flag and now reads the
|
|
642
|
+
# script from stdin only (heredoc style). Pass via stdin so we work against
|
|
643
|
+
# current upstream; the old `-c` form returns the usage banner and exits 1,
|
|
644
|
+
# which used to surface as "CDP not connected" on every fresh install.
|
|
645
|
+
try:
|
|
646
|
+
proc = subprocess.run(
|
|
647
|
+
[BROWSER_HARNESS_BIN],
|
|
648
|
+
input=script,
|
|
649
|
+
env=env,
|
|
650
|
+
capture_output=True,
|
|
651
|
+
text=True,
|
|
652
|
+
timeout=timeout,
|
|
653
|
+
)
|
|
654
|
+
except subprocess.TimeoutExpired as e:
|
|
655
|
+
return {
|
|
656
|
+
"ok": False,
|
|
657
|
+
"error": f"browser-harness timed out after {timeout}s",
|
|
658
|
+
"stdout": (e.stdout or "") if isinstance(e.stdout, str) else "",
|
|
659
|
+
"stderr": (e.stderr or "") if isinstance(e.stderr, str) else "",
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
"ok": proc.returncode == 0,
|
|
664
|
+
"returncode": proc.returncode,
|
|
665
|
+
"stdout": proc.stdout,
|
|
666
|
+
"stderr": proc.stderr,
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# --- MCP server ---
|
|
671
|
+
|
|
672
|
+
mcp = FastMCP(
|
|
673
|
+
"browser-harness",
|
|
674
|
+
instructions=(
|
|
675
|
+
"Direct CDP browser control via the browser-use/browser-harness CLI. "
|
|
676
|
+
"Runs in a dedicated Chrome with a persistent profile at "
|
|
677
|
+
"~/.claude/browser-profiles/browser-harness so cookies/sessions persist "
|
|
678
|
+
"across Claude Code sessions. Separate from the user's normal Chrome "
|
|
679
|
+
"(which playwright-extension drives) and from the per-platform agents "
|
|
680
|
+
"(reddit/twitter/linkedin/logged-in-browser/isolated-browser). "
|
|
681
|
+
"Primary tool: bh_run(script) — runs arbitrary Python with the harness "
|
|
682
|
+
"helpers pre-imported (new_tab, goto_url, page_info, capture_screenshot, "
|
|
683
|
+
"click_at_xy, js, type_text, press_key, fill_input, scroll, "
|
|
684
|
+
"wait_for_load, wait_for_element, ensure_real_tab, list_tabs, "
|
|
685
|
+
"switch_tab, http_get, cdp, etc.). "
|
|
686
|
+
"Workflow: capture_screenshot → click_at_xy(x,y) → re-screenshot. "
|
|
687
|
+
"Helpers cheat-sheet lives at ~/Developer/browser-harness/SKILL.md."
|
|
688
|
+
),
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
@mcp.tool()
|
|
693
|
+
def bh_run(script: str, timeout: int = EXEC_TIMEOUT_SEC) -> str:
|
|
694
|
+
"""Execute a Python script inside browser-harness.
|
|
695
|
+
|
|
696
|
+
The script runs with all browser-harness helpers pre-imported (new_tab,
|
|
697
|
+
goto_url, page_info, capture_screenshot, click_at_xy, js, type_text,
|
|
698
|
+
press_key, fill_input, scroll, wait_for_load, wait_for_element,
|
|
699
|
+
ensure_real_tab, list_tabs, switch_tab, http_get, cdp, etc.).
|
|
700
|
+
|
|
701
|
+
The first navigation in a fresh tab should be new_tab(url), not
|
|
702
|
+
goto_url(url) — goto runs in the user's currently-focused tab and clobbers
|
|
703
|
+
whatever is loaded there.
|
|
704
|
+
|
|
705
|
+
To inspect / extract data, use print(...) and read it back from the
|
|
706
|
+
"stdout" field of the result.
|
|
707
|
+
|
|
708
|
+
Returns a JSON string with: ok, returncode, stdout, stderr.
|
|
709
|
+
"""
|
|
710
|
+
result = _run_harness(script, timeout=timeout)
|
|
711
|
+
return json.dumps(result, indent=2)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
@mcp.tool()
|
|
715
|
+
def bh_status() -> str:
|
|
716
|
+
"""Report whether the managed Chrome is alive and where it lives."""
|
|
717
|
+
pid = _read_pid()
|
|
718
|
+
owners = _port_owner_pids()
|
|
719
|
+
return json.dumps(
|
|
720
|
+
{
|
|
721
|
+
"cdp_url": CDP_URL,
|
|
722
|
+
"cdp_alive": _cdp_alive(),
|
|
723
|
+
"chrome_pid": pid,
|
|
724
|
+
"chrome_alive": (pid is not None and _pid_alive(pid)) or _cdp_alive(),
|
|
725
|
+
# Untracked Chromes (e.g. launched by connect_x) show up here even
|
|
726
|
+
# when chrome_pid is null — that's the orphan that bh_stop now reaps.
|
|
727
|
+
"port_owner_pids": owners,
|
|
728
|
+
"profile_dir": str(PROFILE_DIR),
|
|
729
|
+
"log_file": str(LOG_FILE),
|
|
730
|
+
"harness_bin": BROWSER_HARNESS_BIN,
|
|
731
|
+
"chrome_bin": CHROME_BIN,
|
|
732
|
+
"headless": HEADLESS,
|
|
733
|
+
"root": RUNNING_AS_ROOT,
|
|
734
|
+
},
|
|
735
|
+
indent=2,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
@mcp.tool()
|
|
740
|
+
def bh_start() -> str:
|
|
741
|
+
"""Start the managed Chrome (idempotent). Normally bh_run handles this."""
|
|
742
|
+
return json.dumps(ensure_chrome(), indent=2)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@mcp.tool()
|
|
746
|
+
def bh_stop() -> str:
|
|
747
|
+
"""Kill the managed Chrome instance. Cookies/profile data persist on disk.
|
|
748
|
+
|
|
749
|
+
Reaps both the tracked process and any orphan still holding the debug port,
|
|
750
|
+
using a graceful SIGTERM-with-grace so cookies flush to disk first."""
|
|
751
|
+
return json.dumps(stop_chrome(), indent=2)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
@mcp.tool()
|
|
755
|
+
def bh_restart() -> str:
|
|
756
|
+
"""Flush + restart the managed Chrome in one step. Gracefully stops it (so
|
|
757
|
+
Chrome persists the cookie store to disk and any port-orphan is reaped), then
|
|
758
|
+
starts a fresh instance that loads the just-flushed session from disk. Use
|
|
759
|
+
this instead of killing Chrome by hand — a hard kill drops the in-memory X
|
|
760
|
+
session before it is written, which is what forces a re-login."""
|
|
761
|
+
stopped = stop_chrome()
|
|
762
|
+
time.sleep(0.5)
|
|
763
|
+
started = ensure_chrome()
|
|
764
|
+
return json.dumps({"status": "restarted", "stopped": stopped, "started": started}, indent=2)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
@mcp.tool()
|
|
768
|
+
def bh_seed_cookies(source: str = "chrome:Default", domains: str | None = None) -> str:
|
|
769
|
+
"""Import cookies from a local browser profile into the managed Chrome.
|
|
770
|
+
|
|
771
|
+
Shells out to ai_browser_profile.cookies (~/ai-browser-profile/.venv).
|
|
772
|
+
Reads cookies from the source profile via macOS Keychain + AES-CBC decrypt,
|
|
773
|
+
then injects via CDP Storage.setCookies into our managed Chrome on PORT.
|
|
774
|
+
|
|
775
|
+
macOS-only at present (depends on Keychain). On Linux this returns an
|
|
776
|
+
error pointing to a manual cookie-injection path.
|
|
777
|
+
|
|
778
|
+
Args:
|
|
779
|
+
source: 'browser:profile' spec, e.g. 'chrome:Profile 1', 'arc:Default'.
|
|
780
|
+
Browsers: chrome, arc, brave, edge.
|
|
781
|
+
domains: comma-separated host_key substrings to filter
|
|
782
|
+
(e.g. 'github.com,linear.app'). None = ALL cookies. Highly
|
|
783
|
+
recommended to filter — cookies are auth secrets and importing
|
|
784
|
+
everything mirrors your full session into the managed browser.
|
|
785
|
+
|
|
786
|
+
Returns JSON with ok, returncode, stdout (cookie counts only — never values), stderr.
|
|
787
|
+
"""
|
|
788
|
+
if _IS_LINUX:
|
|
789
|
+
return json.dumps(
|
|
790
|
+
{
|
|
791
|
+
"ok": False,
|
|
792
|
+
"error": (
|
|
793
|
+
"bh_seed_cookies is macOS-only (depends on Keychain). "
|
|
794
|
+
"On Linux, log in to the target site once in the managed "
|
|
795
|
+
"Chrome (the profile at ~/.claude/browser-profiles/browser-harness "
|
|
796
|
+
"persists), or inject cookies via your own bootstrap."
|
|
797
|
+
),
|
|
798
|
+
},
|
|
799
|
+
indent=2,
|
|
800
|
+
)
|
|
801
|
+
ensure_chrome()
|
|
802
|
+
abp_python = Path.home() / "ai-browser-profile" / ".venv" / "bin" / "python"
|
|
803
|
+
if not abp_python.exists():
|
|
804
|
+
return json.dumps(
|
|
805
|
+
{"ok": False, "error": f"ai-browser-profile venv not found at {abp_python}"},
|
|
806
|
+
indent=2,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
cmd = [
|
|
810
|
+
str(abp_python),
|
|
811
|
+
"-m",
|
|
812
|
+
"ai_browser_profile.cookies",
|
|
813
|
+
"copy",
|
|
814
|
+
"--from",
|
|
815
|
+
source,
|
|
816
|
+
"--to",
|
|
817
|
+
CDP_URL,
|
|
818
|
+
]
|
|
819
|
+
if domains:
|
|
820
|
+
cmd += ["--domains", domains]
|
|
821
|
+
|
|
822
|
+
try:
|
|
823
|
+
proc = subprocess.run(
|
|
824
|
+
cmd,
|
|
825
|
+
capture_output=True,
|
|
826
|
+
text=True,
|
|
827
|
+
timeout=60,
|
|
828
|
+
cwd=str(Path.home() / "ai-browser-profile"),
|
|
829
|
+
)
|
|
830
|
+
except subprocess.TimeoutExpired:
|
|
831
|
+
return json.dumps({"ok": False, "error": "seed timed out after 60s"}, indent=2)
|
|
832
|
+
|
|
833
|
+
return json.dumps(
|
|
834
|
+
{
|
|
835
|
+
"ok": proc.returncode == 0,
|
|
836
|
+
"returncode": proc.returncode,
|
|
837
|
+
"stdout": proc.stdout,
|
|
838
|
+
"stderr": proc.stderr,
|
|
839
|
+
},
|
|
840
|
+
indent=2,
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
@mcp.tool()
|
|
845
|
+
def bh_seed_localstorage(source: str = "chrome:Default", origins: str | None = None) -> str:
|
|
846
|
+
"""Import localStorage from a local browser profile into the managed Chrome.
|
|
847
|
+
|
|
848
|
+
Sister to bh_seed_cookies. macOS-only (same Keychain dependency).
|
|
849
|
+
|
|
850
|
+
Args:
|
|
851
|
+
source: 'browser:profile' spec, e.g. 'chrome:Profile 1'.
|
|
852
|
+
origins: comma-separated host substrings (e.g. 'chatgpt.com,notion.so').
|
|
853
|
+
|
|
854
|
+
Returns JSON with ok, returncode, stdout (counts only, no values), stderr.
|
|
855
|
+
"""
|
|
856
|
+
if _IS_LINUX:
|
|
857
|
+
return json.dumps(
|
|
858
|
+
{
|
|
859
|
+
"ok": False,
|
|
860
|
+
"error": (
|
|
861
|
+
"bh_seed_localstorage is macOS-only (depends on Keychain). "
|
|
862
|
+
"On Linux, log in once in the managed Chrome."
|
|
863
|
+
),
|
|
864
|
+
},
|
|
865
|
+
indent=2,
|
|
866
|
+
)
|
|
867
|
+
ensure_chrome()
|
|
868
|
+
abp_python = Path.home() / "ai-browser-profile" / ".venv" / "bin" / "python"
|
|
869
|
+
if not abp_python.exists():
|
|
870
|
+
return json.dumps(
|
|
871
|
+
{"ok": False, "error": f"ai-browser-profile venv not found at {abp_python}"},
|
|
872
|
+
indent=2,
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
cmd = [
|
|
876
|
+
str(abp_python),
|
|
877
|
+
"-m",
|
|
878
|
+
"ai_browser_profile.localstorage",
|
|
879
|
+
"copy",
|
|
880
|
+
"--from",
|
|
881
|
+
source,
|
|
882
|
+
"--to",
|
|
883
|
+
CDP_URL,
|
|
884
|
+
]
|
|
885
|
+
if origins:
|
|
886
|
+
cmd += ["--origins", origins]
|
|
887
|
+
|
|
888
|
+
try:
|
|
889
|
+
proc = subprocess.run(
|
|
890
|
+
cmd,
|
|
891
|
+
capture_output=True,
|
|
892
|
+
text=True,
|
|
893
|
+
timeout=180, # tab-per-origin can take a while
|
|
894
|
+
cwd=str(Path.home() / "ai-browser-profile"),
|
|
895
|
+
)
|
|
896
|
+
except subprocess.TimeoutExpired:
|
|
897
|
+
return json.dumps({"ok": False, "error": "seed timed out after 180s"}, indent=2)
|
|
898
|
+
|
|
899
|
+
return json.dumps(
|
|
900
|
+
{
|
|
901
|
+
"ok": proc.returncode == 0,
|
|
902
|
+
"returncode": proc.returncode,
|
|
903
|
+
"stdout": proc.stdout,
|
|
904
|
+
"stderr": proc.stderr,
|
|
905
|
+
},
|
|
906
|
+
indent=2,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
@mcp.tool()
|
|
911
|
+
def bh_screenshot(quality: int = 50) -> str:
|
|
912
|
+
"""Capture a screenshot of the current tab and write it to a temp file.
|
|
913
|
+
|
|
914
|
+
Returns JSON with the file path and basic page info. Use bh_run for any
|
|
915
|
+
workflow that needs to keep state across multiple steps.
|
|
916
|
+
|
|
917
|
+
The `quality` parameter is accepted for back-compat but is ignored — the
|
|
918
|
+
current upstream `capture_screenshot()` signature is (path, full, max_dim)
|
|
919
|
+
and does not expose a JPEG-quality knob. Older callers (and the MCP tool
|
|
920
|
+
schema) still pass it; we just don't forward it.
|
|
921
|
+
"""
|
|
922
|
+
# Avoid the unused-variable lint and keep `quality` part of the MCP
|
|
923
|
+
# contract: a sanity-cap so a bad caller can't pass arbitrary types.
|
|
924
|
+
_ = int(quality)
|
|
925
|
+
script = (
|
|
926
|
+
"import json, time, os\n"
|
|
927
|
+
"ensure_real_tab()\n"
|
|
928
|
+
"info = page_info()\n"
|
|
929
|
+
"path = capture_screenshot()\n"
|
|
930
|
+
"print(json.dumps({\"screenshot\": str(path), \"page\": info}))\n"
|
|
931
|
+
)
|
|
932
|
+
result = _run_harness(script)
|
|
933
|
+
if not result.get("ok"):
|
|
934
|
+
return json.dumps(result, indent=2)
|
|
935
|
+
# Last line of stdout is our JSON.
|
|
936
|
+
out = (result.get("stdout") or "").strip().splitlines()
|
|
937
|
+
payload = out[-1] if out else "{}"
|
|
938
|
+
return payload
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
@mcp.tool()
|
|
942
|
+
def bh_navigate(url: str, new_tab: bool = True) -> str:
|
|
943
|
+
"""Open a URL. By default opens in a fresh tab (recommended)."""
|
|
944
|
+
if new_tab:
|
|
945
|
+
nav = f"new_tab({url!r})"
|
|
946
|
+
else:
|
|
947
|
+
nav = f"ensure_real_tab(); goto_url({url!r})"
|
|
948
|
+
script = (
|
|
949
|
+
"import json\n"
|
|
950
|
+
f"{nav}\n"
|
|
951
|
+
"wait_for_load()\n"
|
|
952
|
+
"info = page_info()\n"
|
|
953
|
+
"print(json.dumps(info))\n"
|
|
954
|
+
)
|
|
955
|
+
result = _run_harness(script)
|
|
956
|
+
if not result.get("ok"):
|
|
957
|
+
return json.dumps(result, indent=2)
|
|
958
|
+
out = (result.get("stdout") or "").strip().splitlines()
|
|
959
|
+
return out[-1] if out else "{}"
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
if __name__ == "__main__":
|
|
963
|
+
_log("server starting")
|
|
964
|
+
try:
|
|
965
|
+
mcp.run()
|
|
966
|
+
except Exception as e:
|
|
967
|
+
_log(f"server crashed: {e!r}")
|
|
968
|
+
raise
|