@m13v/s4l 1.6.197-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1336 -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 +513 -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,1333 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LinkedIn pipeline killswitch.
|
|
3
|
+
|
|
4
|
+
Single source of truth for "LinkedIn is throttling / has revoked our
|
|
5
|
+
session; do not run anything that talks to LinkedIn until a human
|
|
6
|
+
re-auths and clears the flag".
|
|
7
|
+
|
|
8
|
+
State lives at ~/.claude/social-autoposter/linkedin.killswitch as JSON:
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
"signal": "http_999" | "authwall_redirect" | "throttle_no_pagination"
|
|
12
|
+
| "li_at_cleared" | "session_invalid_marker" | "manual",
|
|
13
|
+
"detail": "...",
|
|
14
|
+
"ts": "2026-05-27T21:20:58Z",
|
|
15
|
+
"run_log_path": "/Users/matthewdi/social-autoposter/skill/logs/...log",
|
|
16
|
+
"pid": 12345,
|
|
17
|
+
"user_to_resolve": "Re-auth LinkedIn in harness Chrome, then clear."
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Every LinkedIn entrypoint (skill/*linkedin*.sh, engage-dm-replies.sh)
|
|
21
|
+
calls `check` at the top and exits 0 if active. `engage()` is
|
|
22
|
+
idempotent: the FIRST signal wins, later signals append to a
|
|
23
|
+
trail file so we can see what cascaded.
|
|
24
|
+
|
|
25
|
+
CLI:
|
|
26
|
+
python3 scripts/linkedin_killswitch.py check # exit 0 if clear, 1 if active
|
|
27
|
+
python3 scripts/linkedin_killswitch.py status # print payload (json), exit 0
|
|
28
|
+
python3 scripts/linkedin_killswitch.py engage \\
|
|
29
|
+
--signal http_999 \\
|
|
30
|
+
--detail "GET /in/me/recent-activity/comments/ -> 999" \\
|
|
31
|
+
--run-log /path/to/log
|
|
32
|
+
python3 scripts/linkedin_killswitch.py clear # remove the flag (human ack)
|
|
33
|
+
|
|
34
|
+
The shell pattern (after `set -euo pipefail`, before any work):
|
|
35
|
+
|
|
36
|
+
if ! /opt/homebrew/bin/python3 "$REPO_DIR/scripts/linkedin_killswitch.py" check >/dev/null 2>&1; then
|
|
37
|
+
log "LINKEDIN_KILLSWITCH active. To resume: re-auth LinkedIn in harness Chrome, then:"
|
|
38
|
+
log " python3 $REPO_DIR/scripts/linkedin_killswitch.py clear"
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
Email alert on engage: re-uses the same Gmail token strike_alert.py
|
|
43
|
+
uses. ONE email per engage call (idempotency in the file prevents
|
|
44
|
+
re-emailing on every cron tick). Subject prefix "[LI KILL]".
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import base64
|
|
49
|
+
import json
|
|
50
|
+
import os
|
|
51
|
+
import re
|
|
52
|
+
import sys
|
|
53
|
+
import time
|
|
54
|
+
from datetime import datetime, timedelta, timezone
|
|
55
|
+
from email.mime.text import MIMEText
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# State paths are env-overridable so the auto-recovery job can be tested
|
|
59
|
+
# against a throwaway killswitch file without touching the live one.
|
|
60
|
+
STATE_DIR = os.path.expanduser(
|
|
61
|
+
os.environ.get("LINKEDIN_KILLSWITCH_DIR", "~/.claude/social-autoposter")
|
|
62
|
+
)
|
|
63
|
+
STATE_FILE = os.path.expanduser(
|
|
64
|
+
os.environ.get("LINKEDIN_KILLSWITCH_FILE", os.path.join(STATE_DIR, "linkedin.killswitch"))
|
|
65
|
+
)
|
|
66
|
+
TRAIL_FILE = os.path.expanduser(
|
|
67
|
+
os.environ.get(
|
|
68
|
+
"LINKEDIN_KILLSWITCH_TRAIL", os.path.join(STATE_DIR, "linkedin.killswitch.trail.jsonl")
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
GMAIL_TOKEN_PATH = os.path.expanduser("~/gmail-api/token_i_at_m13v.com.json")
|
|
73
|
+
GMAIL_SCOPES = ["https://mail.google.com/"]
|
|
74
|
+
NOTIFICATION_EMAIL = os.environ.get("NOTIFICATION_EMAIL", "i@m13v.com")
|
|
75
|
+
|
|
76
|
+
# Auto-recovery (2026-06-03): after the killswitch has been active this long,
|
|
77
|
+
# an hourly launchd job (skill/linkedin-recovery.sh) runs a gentle read-only
|
|
78
|
+
# probe of LinkedIn. If the session is healthy again, it clears the flag, which
|
|
79
|
+
# resumes every LinkedIn pipeline on its next fire (they all gate on this file).
|
|
80
|
+
# The wait protects the account: per the anti-bot rule we let the session sit
|
|
81
|
+
# idle ~24h after a 999/authwall before re-touching it, rather than hammering
|
|
82
|
+
# the login wall on every cron tick. Override for testing.
|
|
83
|
+
RECOVERY_MIN_AGE_HOURS = float(os.environ.get("LINKEDIN_RECOVERY_MIN_AGE_HOURS", "24"))
|
|
84
|
+
LINKEDIN_CDP_URL = os.environ.get("LINKEDIN_CDP_URL", "http://127.0.0.1:9556")
|
|
85
|
+
|
|
86
|
+
# Stop-completely policy (2026-06-03): after the 24h wait, the recovery job runs
|
|
87
|
+
# a read-only probe to see if the session healed on its own. We NEVER attempt a
|
|
88
|
+
# programmatic login (anti-bot rule). If the probe still shows logged-out after
|
|
89
|
+
# this many failed attempts, the session is genuinely dead: we mark the
|
|
90
|
+
# killswitch terminal so the hourly job stops probing entirely and a human must
|
|
91
|
+
# re-auth + clear. Default 1 == "wait 24h, try once, then stop completely".
|
|
92
|
+
RECOVERY_MAX_ATTEMPTS = int(os.environ.get("LINKEDIN_RECOVERY_MAX_ATTEMPTS", "1"))
|
|
93
|
+
|
|
94
|
+
# Claude-driven re-login (2026-06-03): the read-only probe above only detects a
|
|
95
|
+
# self-healed session. The active recovery path instead has the hourly job spin
|
|
96
|
+
# up a Claude session that drives the real harness Chrome (the allowed pattern;
|
|
97
|
+
# scripted Python login is the banned one) to actually log back in. That session
|
|
98
|
+
# returns one of four verdicts, recorded via `recover-record`:
|
|
99
|
+
# - held -> login succeeded; enter a "pending hold" window and
|
|
100
|
+
# re-verify (read-only) after RECOVERY_HOLD_CHECK_MINUTES
|
|
101
|
+
# that it STUCK. Only after a clean hold-check do we clear
|
|
102
|
+
# the flag + resume.
|
|
103
|
+
# - hard_block -> checkpoint / captcha / wrong creds / 2FA wall, or a
|
|
104
|
+
# restriction with NO stated lift time. Terminal
|
|
105
|
+
# immediately: do NOT poke a blocked account again.
|
|
106
|
+
# - restricted_temp -> a TEMPORARY restriction that states an explicit lift
|
|
107
|
+
# time (e.g. "restricted until June 03 2026 4:05 PM PDT").
|
|
108
|
+
# We don't go terminal: we dip until that time + a buffer,
|
|
109
|
+
# then make ONE more attempt, up to RECOVERY_RESTRICTED_MAX.
|
|
110
|
+
# The model passes the lift time as `lift=<ISO8601>` in the
|
|
111
|
+
# detail; if it is unparseable we dip a fixed fallback.
|
|
112
|
+
# - transient -> page didn't load / ambiguous. Re-anchor the 24h clock and
|
|
113
|
+
# let the next eligible cycle try again, up to
|
|
114
|
+
# RECOVERY_TRANSIENT_MAX.
|
|
115
|
+
# If a login held but the session drops during the hold window ("logged out
|
|
116
|
+
# shortly after"), the hold-check goes terminal too: try once, don't keep trying.
|
|
117
|
+
RECOVERY_HOLD_CHECK_MINUTES = float(
|
|
118
|
+
os.environ.get("LINKEDIN_RECOVERY_HOLD_CHECK_MINUTES", "45")
|
|
119
|
+
)
|
|
120
|
+
RECOVERY_TRANSIENT_MAX_ATTEMPTS = int(
|
|
121
|
+
os.environ.get("LINKEDIN_RECOVERY_TRANSIENT_MAX_ATTEMPTS", "3")
|
|
122
|
+
)
|
|
123
|
+
# Timed-dip retries for temporary restrictions before we give up and go terminal.
|
|
124
|
+
RECOVERY_RESTRICTED_MAX_ATTEMPTS = int(
|
|
125
|
+
os.environ.get("LINKEDIN_RECOVERY_RESTRICTED_MAX_ATTEMPTS", "3")
|
|
126
|
+
)
|
|
127
|
+
# Buffer added past the stated lift time before the retry fires (clock skew +
|
|
128
|
+
# LinkedIn not lifting exactly on the dot).
|
|
129
|
+
RECOVERY_RESTRICTED_BUFFER_MINUTES = float(
|
|
130
|
+
os.environ.get("LINKEDIN_RECOVERY_RESTRICTED_BUFFER_MINUTES", "30")
|
|
131
|
+
)
|
|
132
|
+
# Fallback dip when the model reports a temporary restriction but we cannot parse
|
|
133
|
+
# a lift time from the detail. We still avoid terminal, just wait a fixed window.
|
|
134
|
+
RECOVERY_RESTRICTED_FALLBACK_HOURS = float(
|
|
135
|
+
os.environ.get("LINKEDIN_RECOVERY_RESTRICTED_FALLBACK_HOURS", "24")
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
VALID_SIGNALS = {
|
|
139
|
+
"http_999",
|
|
140
|
+
"authwall_redirect",
|
|
141
|
+
"checkpoint_redirect",
|
|
142
|
+
"login_redirect",
|
|
143
|
+
"throttle_no_pagination",
|
|
144
|
+
"li_at_cleared",
|
|
145
|
+
"session_invalid_marker",
|
|
146
|
+
"captcha_detected",
|
|
147
|
+
"manual",
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _now_iso():
|
|
152
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _fmt_local(iso_or_dt):
|
|
156
|
+
"""Render a UTC ISO string (or datetime) in the machine's local timezone for
|
|
157
|
+
human-readable emails, e.g. '2026-06-03 16:35 PDT'. Falls back to the raw
|
|
158
|
+
input on any parse failure so the email still goes out."""
|
|
159
|
+
try:
|
|
160
|
+
dt = iso_or_dt
|
|
161
|
+
if isinstance(dt, str):
|
|
162
|
+
dt = _parse_ts(dt)
|
|
163
|
+
if dt is None:
|
|
164
|
+
return str(iso_or_dt)
|
|
165
|
+
if dt.tzinfo is None:
|
|
166
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
167
|
+
local = dt.astimezone()
|
|
168
|
+
return local.strftime("%Y-%m-%d %I:%M %p %Z").replace(" 0", " ")
|
|
169
|
+
except Exception:
|
|
170
|
+
return str(iso_or_dt)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _ensure_dir():
|
|
174
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def is_active():
|
|
178
|
+
return os.path.isfile(STATE_FILE)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def read():
|
|
182
|
+
if not is_active():
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
with open(STATE_FILE, "r") as f:
|
|
186
|
+
return json.load(f)
|
|
187
|
+
except Exception:
|
|
188
|
+
return {"signal": "unknown", "detail": "state file unreadable"}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _parse_ts(ts):
|
|
192
|
+
"""Parse an ISO Z timestamp like 2026-06-03T07:23:10Z. None on failure."""
|
|
193
|
+
try:
|
|
194
|
+
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
195
|
+
except Exception:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def age_seconds():
|
|
200
|
+
"""Seconds since the killswitch engaged, or None if inactive/unparseable."""
|
|
201
|
+
p = read()
|
|
202
|
+
if not p:
|
|
203
|
+
return None
|
|
204
|
+
dt = _parse_ts(p.get("ts", ""))
|
|
205
|
+
if dt is None:
|
|
206
|
+
return None
|
|
207
|
+
return (datetime.now(timezone.utc) - dt).total_seconds()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _append_trail(payload):
|
|
211
|
+
_ensure_dir()
|
|
212
|
+
try:
|
|
213
|
+
with open(TRAIL_FILE, "a") as f:
|
|
214
|
+
f.write(json.dumps(payload) + "\n")
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _scrub_dashes(s):
|
|
220
|
+
if not s:
|
|
221
|
+
return s
|
|
222
|
+
return s.replace("\u2014", ",").replace("\u2013", ",")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _send_alert_email(payload, first_time):
|
|
226
|
+
"""Send an email alert. first_time=True only on the engaging call.
|
|
227
|
+
|
|
228
|
+
Best-effort: failure to send must not block engagement (the file
|
|
229
|
+
is the source of truth, not the email)."""
|
|
230
|
+
try:
|
|
231
|
+
from google.auth.transport.requests import Request
|
|
232
|
+
from google.oauth2.credentials import Credentials
|
|
233
|
+
from googleapiclient.discovery import build
|
|
234
|
+
|
|
235
|
+
if not os.path.isfile(GMAIL_TOKEN_PATH):
|
|
236
|
+
return False, "gmail token missing"
|
|
237
|
+
|
|
238
|
+
creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
|
|
239
|
+
if creds.expired and creds.refresh_token:
|
|
240
|
+
creds.refresh(Request())
|
|
241
|
+
with open(GMAIL_TOKEN_PATH, "w") as f:
|
|
242
|
+
f.write(creds.to_json())
|
|
243
|
+
|
|
244
|
+
service = build("gmail", "v1", credentials=creds, cache_discovery=False)
|
|
245
|
+
|
|
246
|
+
tag = "ENGAGED" if first_time else "REPEAT"
|
|
247
|
+
subject = "[LI KILL] {tag} signal={sig}".format(
|
|
248
|
+
tag=tag, sig=payload.get("signal", "?"),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
body_lines = [
|
|
252
|
+
"LinkedIn killswitch " + tag.lower() + ".",
|
|
253
|
+
"",
|
|
254
|
+
"All LinkedIn pipelines on this machine will refuse to run until",
|
|
255
|
+
"a human re-authenticates LinkedIn in the harness Chrome and",
|
|
256
|
+
"clears the flag.",
|
|
257
|
+
"",
|
|
258
|
+
"Signal: " + str(payload.get("signal", "?")),
|
|
259
|
+
"Detail: " + str(payload.get("detail", "")),
|
|
260
|
+
"Timestamp: " + str(payload.get("ts", "")),
|
|
261
|
+
"PID: " + str(payload.get("pid", "")),
|
|
262
|
+
"Run log: " + str(payload.get("run_log_path", "")),
|
|
263
|
+
"",
|
|
264
|
+
"To resume:",
|
|
265
|
+
" 1. Open harness Chrome (linkedin profile) and sign back in.",
|
|
266
|
+
" 2. Confirm a normal /feed/ page renders without authwall.",
|
|
267
|
+
" 3. Clear the killswitch:",
|
|
268
|
+
" python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear",
|
|
269
|
+
"",
|
|
270
|
+
"State file: " + STATE_FILE,
|
|
271
|
+
"Trail file: " + TRAIL_FILE,
|
|
272
|
+
]
|
|
273
|
+
body = _scrub_dashes("\n".join(body_lines))
|
|
274
|
+
subject = _scrub_dashes(subject)
|
|
275
|
+
|
|
276
|
+
msg = MIMEText(body, "plain", "utf-8")
|
|
277
|
+
msg["to"] = NOTIFICATION_EMAIL
|
|
278
|
+
msg["subject"] = subject
|
|
279
|
+
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
|
|
280
|
+
service.users().messages().send(userId="me", body={"raw": raw}).execute()
|
|
281
|
+
return True, "sent"
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
return False, "send failed: " + str(exc)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def engage(signal, detail="", run_log_path="", extra=None, send_email=True):
|
|
287
|
+
"""Engage the killswitch. Idempotent: first signal wins.
|
|
288
|
+
|
|
289
|
+
Returns the on-disk payload (either the existing one or the
|
|
290
|
+
newly-written one). Always appends to the trail so we can see
|
|
291
|
+
cascades."""
|
|
292
|
+
if signal not in VALID_SIGNALS:
|
|
293
|
+
signal = "manual"
|
|
294
|
+
|
|
295
|
+
payload_new = {
|
|
296
|
+
"signal": signal,
|
|
297
|
+
"detail": str(detail)[:2000],
|
|
298
|
+
"ts": _now_iso(),
|
|
299
|
+
"run_log_path": run_log_path,
|
|
300
|
+
"pid": os.getpid(),
|
|
301
|
+
"user_to_resolve": (
|
|
302
|
+
"Re-auth LinkedIn in harness Chrome, confirm /feed/ renders, "
|
|
303
|
+
"then run: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear"
|
|
304
|
+
),
|
|
305
|
+
}
|
|
306
|
+
if isinstance(extra, dict):
|
|
307
|
+
for k, v in extra.items():
|
|
308
|
+
if k not in payload_new:
|
|
309
|
+
payload_new[k] = v
|
|
310
|
+
|
|
311
|
+
_ensure_dir()
|
|
312
|
+
_append_trail({"event": "engage_call", **payload_new})
|
|
313
|
+
|
|
314
|
+
first_time = not is_active()
|
|
315
|
+
if first_time:
|
|
316
|
+
tmp = STATE_FILE + ".tmp"
|
|
317
|
+
with open(tmp, "w") as f:
|
|
318
|
+
json.dump(payload_new, f, indent=2)
|
|
319
|
+
f.write("\n")
|
|
320
|
+
os.replace(tmp, STATE_FILE)
|
|
321
|
+
on_disk = payload_new
|
|
322
|
+
else:
|
|
323
|
+
on_disk = read() or payload_new
|
|
324
|
+
|
|
325
|
+
if send_email:
|
|
326
|
+
ok, msg = _send_alert_email(payload_new, first_time)
|
|
327
|
+
_append_trail({"event": "email_attempt", "ok": ok, "msg": msg, "first_time": first_time})
|
|
328
|
+
|
|
329
|
+
return on_disk
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
_LOGIN_MARKERS = ("/login", "/checkpoint", "/uas/login", "linkedin.com/authwall")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _probe_linkedin_health(cdp_url, feed_only=False):
|
|
336
|
+
"""Gentle, read-only health probe of the LinkedIn session.
|
|
337
|
+
|
|
338
|
+
Attaches (CDP) to the already-running linkedin-harness Chrome and does the
|
|
339
|
+
minimal nav set the anti-bot carve-out allows: ONE nav to /feed/ (confirms
|
|
340
|
+
we are logged in) and, unless feed_only, ONE nav to the exact
|
|
341
|
+
/in/me/recent-activity/comments/ endpoint that trips the killswitch (confirms
|
|
342
|
+
it no longer bounces to the authwall). No Voyager calls, no scroll loops, no
|
|
343
|
+
permalink fan-out, no clicks/typing, no programmatic login. Reuses an
|
|
344
|
+
existing tab and never closes the shared context.
|
|
345
|
+
|
|
346
|
+
feed_only=True is the per-run detection gate: a single /feed/ nav is enough
|
|
347
|
+
to tell "are we still logged in?" without touching the activity endpoint on
|
|
348
|
+
every healthy pipeline fire.
|
|
349
|
+
|
|
350
|
+
Returns (healthy: bool, detail: str, conclusive: bool). Never raises.
|
|
351
|
+
conclusive=True means we definitively observed login state (healthy feed, or
|
|
352
|
+
a redirect to the authwall/login/checkpoint). conclusive=False means we
|
|
353
|
+
could not determine it (CDP attach failed, nav timeout, Chrome down): an
|
|
354
|
+
infra hiccup, NOT evidence the session is dead, so callers must not engage
|
|
355
|
+
the killswitch or count it as a failed re-login attempt on this.
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
from playwright.sync_api import sync_playwright
|
|
359
|
+
except Exception as e:
|
|
360
|
+
return False, "playwright import failed: {}".format(e), False
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
with sync_playwright() as p:
|
|
364
|
+
try:
|
|
365
|
+
browser = p.chromium.connect_over_cdp(cdp_url, timeout=8000)
|
|
366
|
+
except Exception as e:
|
|
367
|
+
return False, "cdp attach failed ({}): {}".format(cdp_url, e), False
|
|
368
|
+
contexts = browser.contexts
|
|
369
|
+
if not contexts:
|
|
370
|
+
return False, "cdp attach: zero contexts", False
|
|
371
|
+
ctx = contexts[0]
|
|
372
|
+
|
|
373
|
+
page = None
|
|
374
|
+
reused = False
|
|
375
|
+
for pg in ctx.pages:
|
|
376
|
+
u = pg.url or ""
|
|
377
|
+
if "linkedin.com" in u and "login" not in u and "checkpoint" not in u:
|
|
378
|
+
page, reused = pg, True
|
|
379
|
+
break
|
|
380
|
+
if page is None and ctx.pages:
|
|
381
|
+
page, reused = ctx.pages[0], True
|
|
382
|
+
if page is None:
|
|
383
|
+
page = ctx.new_page()
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
# Nav 1: /feed/ — are we still logged in?
|
|
387
|
+
page.goto(
|
|
388
|
+
"https://www.linkedin.com/feed/",
|
|
389
|
+
wait_until="domcontentloaded",
|
|
390
|
+
timeout=30000,
|
|
391
|
+
)
|
|
392
|
+
page.wait_for_timeout(2000)
|
|
393
|
+
u1 = page.url or ""
|
|
394
|
+
if any(m in u1 for m in _LOGIN_MARKERS):
|
|
395
|
+
return False, "feed redirected to auth: {}".format(u1), True
|
|
396
|
+
|
|
397
|
+
if feed_only:
|
|
398
|
+
title = ""
|
|
399
|
+
try:
|
|
400
|
+
title = page.title() or ""
|
|
401
|
+
except Exception:
|
|
402
|
+
pass
|
|
403
|
+
return True, "feed renders (title={!r}, url={})".format(title, u1), True
|
|
404
|
+
|
|
405
|
+
# Nav 2: the exact endpoint that engaged the killswitch.
|
|
406
|
+
page.goto(
|
|
407
|
+
"https://www.linkedin.com/in/me/recent-activity/comments/",
|
|
408
|
+
wait_until="domcontentloaded",
|
|
409
|
+
timeout=30000,
|
|
410
|
+
)
|
|
411
|
+
page.wait_for_timeout(2000)
|
|
412
|
+
u2 = page.url or ""
|
|
413
|
+
if any(m in u2 for m in _LOGIN_MARKERS):
|
|
414
|
+
return False, "activity endpoint redirected to auth: {}".format(u2), True
|
|
415
|
+
|
|
416
|
+
title = ""
|
|
417
|
+
try:
|
|
418
|
+
title = page.title() or ""
|
|
419
|
+
except Exception:
|
|
420
|
+
pass
|
|
421
|
+
return True, "feed+activity render (title={!r}, url={})".format(title, u2), True
|
|
422
|
+
finally:
|
|
423
|
+
if page is not None and not reused:
|
|
424
|
+
try:
|
|
425
|
+
page.close()
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
except Exception as e:
|
|
429
|
+
return False, "probe exception: {}: {}".format(type(e).__name__, e), False
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _send_recovery_email(detail, age_sec):
|
|
433
|
+
"""Notify that the killswitch auto-cleared after a healthy probe."""
|
|
434
|
+
try:
|
|
435
|
+
from google.auth.transport.requests import Request
|
|
436
|
+
from google.oauth2.credentials import Credentials
|
|
437
|
+
from googleapiclient.discovery import build
|
|
438
|
+
|
|
439
|
+
if not os.path.isfile(GMAIL_TOKEN_PATH):
|
|
440
|
+
return False, "gmail token missing"
|
|
441
|
+
|
|
442
|
+
creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
|
|
443
|
+
if creds.expired and creds.refresh_token:
|
|
444
|
+
creds.refresh(Request())
|
|
445
|
+
with open(GMAIL_TOKEN_PATH, "w") as f:
|
|
446
|
+
f.write(creds.to_json())
|
|
447
|
+
|
|
448
|
+
service = build("gmail", "v1", credentials=creds, cache_discovery=False)
|
|
449
|
+
age_h = round(age_sec / 3600.0, 1) if age_sec else "?"
|
|
450
|
+
subject = "[LI KILL] RECOVERED auto-probe healthy"
|
|
451
|
+
body_lines = [
|
|
452
|
+
"LinkedIn killswitch auto-cleared.",
|
|
453
|
+
"",
|
|
454
|
+
"The hourly recovery probe found the session healthy after the",
|
|
455
|
+
"killswitch had been active for " + str(age_h) + "h, so it cleared",
|
|
456
|
+
"the flag. Every LinkedIn pipeline resumes on its next launchd fire.",
|
|
457
|
+
"",
|
|
458
|
+
"Probe detail: " + str(detail),
|
|
459
|
+
"",
|
|
460
|
+
"If LinkedIn was NOT actually healthy, re-engage manually:",
|
|
461
|
+
" python3 ~/social-autoposter/scripts/linkedin_killswitch.py \\",
|
|
462
|
+
" engage --signal manual --detail 'auto-recovery false positive'",
|
|
463
|
+
"",
|
|
464
|
+
"State file: " + STATE_FILE,
|
|
465
|
+
"Trail file: " + TRAIL_FILE,
|
|
466
|
+
]
|
|
467
|
+
body = _scrub_dashes("\n".join(body_lines))
|
|
468
|
+
msg = MIMEText(body, "plain", "utf-8")
|
|
469
|
+
msg["to"] = NOTIFICATION_EMAIL
|
|
470
|
+
msg["subject"] = _scrub_dashes(subject)
|
|
471
|
+
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
|
|
472
|
+
service.users().messages().send(userId="me", body={"raw": raw}).execute()
|
|
473
|
+
return True, "sent"
|
|
474
|
+
except Exception as exc:
|
|
475
|
+
return False, "send failed: " + str(exc)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def is_terminal():
|
|
479
|
+
"""True if auto-recovery has given up (failed re-login after the 24h wait)
|
|
480
|
+
and a human must re-auth + clear. Once terminal, the hourly recovery job
|
|
481
|
+
stops probing entirely."""
|
|
482
|
+
p = read()
|
|
483
|
+
return bool(p and p.get("recovery_terminal"))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _record_failed_recovery(detail):
|
|
487
|
+
"""A read-only recovery probe conclusively showed still-logged-out after the
|
|
488
|
+
24h wait. Increment the attempt counter on the live state file (preserving
|
|
489
|
+
the original ts so age keeps accruing) and, once attempts reach
|
|
490
|
+
RECOVERY_MAX_ATTEMPTS, flip recovery_terminal so we stop completely.
|
|
491
|
+
|
|
492
|
+
Returns (attempts: int, terminal: bool)."""
|
|
493
|
+
p = read() or {}
|
|
494
|
+
attempts = int(p.get("recovery_attempts", 0)) + 1
|
|
495
|
+
p["recovery_attempts"] = attempts
|
|
496
|
+
p["last_recovery_ts"] = _now_iso()
|
|
497
|
+
p["last_recovery_detail"] = str(detail)[:2000]
|
|
498
|
+
terminal = attempts >= RECOVERY_MAX_ATTEMPTS
|
|
499
|
+
if terminal:
|
|
500
|
+
p["recovery_terminal"] = True
|
|
501
|
+
p["recovery_terminal_ts"] = _now_iso()
|
|
502
|
+
_ensure_dir()
|
|
503
|
+
tmp = STATE_FILE + ".tmp"
|
|
504
|
+
with open(tmp, "w") as f:
|
|
505
|
+
json.dump(p, f, indent=2)
|
|
506
|
+
f.write("\n")
|
|
507
|
+
os.replace(tmp, STATE_FILE)
|
|
508
|
+
_append_trail({
|
|
509
|
+
"event": "recovery_failed",
|
|
510
|
+
"ts": _now_iso(),
|
|
511
|
+
"attempts": attempts,
|
|
512
|
+
"terminal": terminal,
|
|
513
|
+
"detail": str(detail)[:500],
|
|
514
|
+
})
|
|
515
|
+
return attempts, terminal
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _send_terminal_email(detail, attempts, age_sec):
|
|
519
|
+
"""Notify that auto-recovery gave up; manual re-auth required."""
|
|
520
|
+
try:
|
|
521
|
+
from google.auth.transport.requests import Request
|
|
522
|
+
from google.oauth2.credentials import Credentials
|
|
523
|
+
from googleapiclient.discovery import build
|
|
524
|
+
|
|
525
|
+
if not os.path.isfile(GMAIL_TOKEN_PATH):
|
|
526
|
+
return False, "gmail token missing"
|
|
527
|
+
|
|
528
|
+
creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
|
|
529
|
+
if creds.expired and creds.refresh_token:
|
|
530
|
+
creds.refresh(Request())
|
|
531
|
+
with open(GMAIL_TOKEN_PATH, "w") as f:
|
|
532
|
+
f.write(creds.to_json())
|
|
533
|
+
|
|
534
|
+
service = build("gmail", "v1", credentials=creds, cache_discovery=False)
|
|
535
|
+
age_h = round(age_sec / 3600.0, 1) if age_sec else "?"
|
|
536
|
+
subject = "[LI KILL] AUTO-RECOVERY FAILED, manual re-auth required"
|
|
537
|
+
body_lines = [
|
|
538
|
+
"LinkedIn auto-recovery has STOPPED COMPLETELY.",
|
|
539
|
+
"",
|
|
540
|
+
"After the " + str(RECOVERY_MIN_AGE_HOURS) + "h wait, the read-only probe",
|
|
541
|
+
"ran " + str(attempts) + " attempt(s) and the session was still logged out",
|
|
542
|
+
"(redirected to the authwall/login). Per the anti-bot rule we never",
|
|
543
|
+
"log in programmatically, so the hourly recovery job will now stop",
|
|
544
|
+
"probing and every LinkedIn pipeline stays paused until you act.",
|
|
545
|
+
"",
|
|
546
|
+
"Killswitch age at give-up: " + str(age_h) + "h",
|
|
547
|
+
"Last probe detail: " + str(detail),
|
|
548
|
+
"",
|
|
549
|
+
"To resume:",
|
|
550
|
+
" 1. Open the linkedin-harness Chrome (port 9556) and sign back in.",
|
|
551
|
+
" 2. Confirm /feed/ renders without an authwall.",
|
|
552
|
+
" 3. Clear the killswitch:",
|
|
553
|
+
" python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear",
|
|
554
|
+
"",
|
|
555
|
+
"State file: " + STATE_FILE,
|
|
556
|
+
"Trail file: " + TRAIL_FILE,
|
|
557
|
+
]
|
|
558
|
+
body = _scrub_dashes("\n".join(body_lines))
|
|
559
|
+
msg = MIMEText(body, "plain", "utf-8")
|
|
560
|
+
msg["to"] = NOTIFICATION_EMAIL
|
|
561
|
+
msg["subject"] = _scrub_dashes(subject)
|
|
562
|
+
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
|
|
563
|
+
service.users().messages().send(userId="me", body={"raw": raw}).execute()
|
|
564
|
+
return True, "sent"
|
|
565
|
+
except Exception as exc:
|
|
566
|
+
return False, "send failed: " + str(exc)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _write_state(p):
|
|
570
|
+
"""Atomically persist the killswitch state dict."""
|
|
571
|
+
_ensure_dir()
|
|
572
|
+
tmp = STATE_FILE + ".tmp"
|
|
573
|
+
with open(tmp, "w") as f:
|
|
574
|
+
json.dump(p, f, indent=2)
|
|
575
|
+
f.write("\n")
|
|
576
|
+
os.replace(tmp, STATE_FILE)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _record_login_held():
|
|
580
|
+
"""Claude reported a successful re-login. Don't clear the flag yet: enter a
|
|
581
|
+
pending-hold window and re-verify (read-only) after RECOVERY_HOLD_CHECK_MINUTES
|
|
582
|
+
that the session actually stuck. Returns the hold_check_due ISO ts."""
|
|
583
|
+
p = read() or {}
|
|
584
|
+
due = datetime.now(timezone.utc) + timedelta(minutes=RECOVERY_HOLD_CHECK_MINUTES)
|
|
585
|
+
due_iso = due.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
586
|
+
p["recovery_pending_hold"] = True
|
|
587
|
+
p["hold_check_due"] = due_iso
|
|
588
|
+
p["login_held_ts"] = _now_iso()
|
|
589
|
+
p["recovery_transient_attempts"] = 0
|
|
590
|
+
p["recovery_restricted_attempts"] = 0
|
|
591
|
+
p.pop("recovery_restricted_until", None)
|
|
592
|
+
p.pop("recovery_terminal", None)
|
|
593
|
+
_write_state(p)
|
|
594
|
+
_append_trail({
|
|
595
|
+
"event": "login_held",
|
|
596
|
+
"ts": _now_iso(),
|
|
597
|
+
"hold_check_due": due_iso,
|
|
598
|
+
})
|
|
599
|
+
return due_iso
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _record_hard_block(detail):
|
|
603
|
+
"""Claude hit a wall it cannot pass (checkpoint / captcha / restriction /
|
|
604
|
+
wrong creds / 2FA). Go terminal: never auto-poke a restricted account again."""
|
|
605
|
+
p = read() or {}
|
|
606
|
+
p["recovery_terminal"] = True
|
|
607
|
+
p["recovery_terminal_ts"] = _now_iso()
|
|
608
|
+
p["recovery_terminal_reason"] = "hard_block"
|
|
609
|
+
p["last_recovery_detail"] = str(detail)[:2000]
|
|
610
|
+
p.pop("recovery_pending_hold", None)
|
|
611
|
+
p.pop("hold_check_due", None)
|
|
612
|
+
p.pop("recovery_restricted_until", None)
|
|
613
|
+
_write_state(p)
|
|
614
|
+
_append_trail({
|
|
615
|
+
"event": "recovery_hard_block",
|
|
616
|
+
"ts": _now_iso(),
|
|
617
|
+
"detail": str(detail)[:500],
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _record_transient(detail):
|
|
622
|
+
"""Claude couldn't conclusively log in or fail (page didn't load, ambiguous).
|
|
623
|
+
Re-anchor the 24h clock so the next eligible cycle tries again, up to
|
|
624
|
+
RECOVERY_TRANSIENT_MAX_ATTEMPTS, after which we go terminal.
|
|
625
|
+
Returns (transient_attempts: int, terminal: bool)."""
|
|
626
|
+
p = read() or {}
|
|
627
|
+
attempts = int(p.get("recovery_transient_attempts", 0)) + 1
|
|
628
|
+
p["recovery_transient_attempts"] = attempts
|
|
629
|
+
p["last_recovery_ts"] = _now_iso()
|
|
630
|
+
p["last_recovery_detail"] = str(detail)[:2000]
|
|
631
|
+
terminal = attempts >= RECOVERY_TRANSIENT_MAX_ATTEMPTS
|
|
632
|
+
if terminal:
|
|
633
|
+
p["recovery_terminal"] = True
|
|
634
|
+
p["recovery_terminal_ts"] = _now_iso()
|
|
635
|
+
p["recovery_terminal_reason"] = "transient_exhausted"
|
|
636
|
+
else:
|
|
637
|
+
# Re-anchor age so we wait another full RECOVERY_MIN_AGE_HOURS before the
|
|
638
|
+
# next attempt rather than retrying on the next hourly tick.
|
|
639
|
+
p["ts"] = _now_iso()
|
|
640
|
+
p.pop("recovery_pending_hold", None)
|
|
641
|
+
p.pop("hold_check_due", None)
|
|
642
|
+
p.pop("recovery_restricted_until", None)
|
|
643
|
+
_write_state(p)
|
|
644
|
+
_append_trail({
|
|
645
|
+
"event": "recovery_transient",
|
|
646
|
+
"ts": _now_iso(),
|
|
647
|
+
"transient_attempts": attempts,
|
|
648
|
+
"terminal": terminal,
|
|
649
|
+
"detail": str(detail)[:500],
|
|
650
|
+
})
|
|
651
|
+
return attempts, terminal
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _parse_lift_time(detail):
|
|
655
|
+
"""Extract a restriction lift time from the verdict detail. The model is asked
|
|
656
|
+
to embed it as `lift=<ISO8601>` (with a timezone offset or trailing Z), e.g.
|
|
657
|
+
`lift=2026-06-03T16:05:00-07:00`. Returns a tz-aware UTC datetime, or None if
|
|
658
|
+
absent/unparseable (caller falls back to a fixed dip)."""
|
|
659
|
+
if not detail:
|
|
660
|
+
return None
|
|
661
|
+
m = re.search(r"lift=([0-9T:\-\+\.Zz]+)", str(detail))
|
|
662
|
+
if not m:
|
|
663
|
+
return None
|
|
664
|
+
raw = m.group(1).strip()
|
|
665
|
+
# Try the strict Z form first, then full ISO (handles +/-HH:MM offsets).
|
|
666
|
+
dt = _parse_ts(raw)
|
|
667
|
+
if dt is None:
|
|
668
|
+
try:
|
|
669
|
+
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
670
|
+
except Exception:
|
|
671
|
+
return None
|
|
672
|
+
if dt.tzinfo is None:
|
|
673
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
674
|
+
return dt.astimezone(timezone.utc)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _record_restricted_temp(detail):
|
|
678
|
+
"""Claude hit a TEMPORARY restriction that states an explicit lift time. Don't
|
|
679
|
+
go terminal: dip until that time + a buffer, then allow ONE more attempt, up to
|
|
680
|
+
RECOVERY_RESTRICTED_MAX_ATTEMPTS (after which we do go terminal). Returns
|
|
681
|
+
(restricted_attempts: int, terminal: bool, retry_at_iso: str)."""
|
|
682
|
+
p = read() or {}
|
|
683
|
+
attempts = int(p.get("recovery_restricted_attempts", 0)) + 1
|
|
684
|
+
p["recovery_restricted_attempts"] = attempts
|
|
685
|
+
p["last_recovery_ts"] = _now_iso()
|
|
686
|
+
p["last_recovery_detail"] = str(detail)[:2000]
|
|
687
|
+
|
|
688
|
+
terminal = attempts >= RECOVERY_RESTRICTED_MAX_ATTEMPTS
|
|
689
|
+
retry_at_iso = ""
|
|
690
|
+
if terminal:
|
|
691
|
+
p["recovery_terminal"] = True
|
|
692
|
+
p["recovery_terminal_ts"] = _now_iso()
|
|
693
|
+
p["recovery_terminal_reason"] = "restricted_exhausted"
|
|
694
|
+
p.pop("recovery_restricted_until", None)
|
|
695
|
+
else:
|
|
696
|
+
lift = _parse_lift_time(detail)
|
|
697
|
+
if lift is not None:
|
|
698
|
+
retry_at = lift + timedelta(minutes=RECOVERY_RESTRICTED_BUFFER_MINUTES)
|
|
699
|
+
# Never schedule the retry in the past (stated lift already elapsed):
|
|
700
|
+
# give it at least the buffer from now so we don't immediately re-poke.
|
|
701
|
+
floor = datetime.now(timezone.utc) + timedelta(
|
|
702
|
+
minutes=RECOVERY_RESTRICTED_BUFFER_MINUTES
|
|
703
|
+
)
|
|
704
|
+
if retry_at < floor:
|
|
705
|
+
retry_at = floor
|
|
706
|
+
else:
|
|
707
|
+
retry_at = datetime.now(timezone.utc) + timedelta(
|
|
708
|
+
hours=RECOVERY_RESTRICTED_FALLBACK_HOURS
|
|
709
|
+
)
|
|
710
|
+
retry_at_iso = retry_at.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
711
|
+
p["recovery_restricted_until"] = retry_at_iso
|
|
712
|
+
p.pop("recovery_pending_hold", None)
|
|
713
|
+
p.pop("hold_check_due", None)
|
|
714
|
+
_write_state(p)
|
|
715
|
+
_append_trail({
|
|
716
|
+
"event": "recovery_restricted_temp",
|
|
717
|
+
"ts": _now_iso(),
|
|
718
|
+
"restricted_attempts": attempts,
|
|
719
|
+
"terminal": terminal,
|
|
720
|
+
"retry_at": retry_at_iso,
|
|
721
|
+
"detail": str(detail)[:500],
|
|
722
|
+
})
|
|
723
|
+
return attempts, terminal, retry_at_iso
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _restricted_until_seconds():
|
|
727
|
+
"""Seconds until (negative) / since (positive) the restricted-dip retry is due.
|
|
728
|
+
None if no restricted-dip window is set or its ts is unparseable."""
|
|
729
|
+
p = read()
|
|
730
|
+
if not p:
|
|
731
|
+
return None
|
|
732
|
+
until_iso = p.get("recovery_restricted_until")
|
|
733
|
+
if not until_iso:
|
|
734
|
+
return None
|
|
735
|
+
until = _parse_ts(until_iso)
|
|
736
|
+
if until is None:
|
|
737
|
+
return None
|
|
738
|
+
return (datetime.now(timezone.utc) - until).total_seconds()
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _send_simple_email(subject, body_lines):
|
|
742
|
+
"""Best-effort plain-text alert to NOTIFICATION_EMAIL. Never raises."""
|
|
743
|
+
try:
|
|
744
|
+
from google.auth.transport.requests import Request
|
|
745
|
+
from google.oauth2.credentials import Credentials
|
|
746
|
+
from googleapiclient.discovery import build
|
|
747
|
+
|
|
748
|
+
if not os.path.isfile(GMAIL_TOKEN_PATH):
|
|
749
|
+
return False, "gmail token missing"
|
|
750
|
+
creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
|
|
751
|
+
if creds.expired and creds.refresh_token:
|
|
752
|
+
creds.refresh(Request())
|
|
753
|
+
with open(GMAIL_TOKEN_PATH, "w") as f:
|
|
754
|
+
f.write(creds.to_json())
|
|
755
|
+
service = build("gmail", "v1", credentials=creds, cache_discovery=False)
|
|
756
|
+
body = _scrub_dashes("\n".join(body_lines))
|
|
757
|
+
msg = MIMEText(body, "plain", "utf-8")
|
|
758
|
+
msg["to"] = NOTIFICATION_EMAIL
|
|
759
|
+
msg["subject"] = _scrub_dashes(subject)
|
|
760
|
+
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
|
|
761
|
+
service.users().messages().send(userId="me", body={"raw": raw}).execute()
|
|
762
|
+
return True, "sent"
|
|
763
|
+
except Exception as exc:
|
|
764
|
+
return False, "send failed: " + str(exc)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def clear():
|
|
768
|
+
"""Human ack: remove the flag. Trail row records who cleared it."""
|
|
769
|
+
if not is_active():
|
|
770
|
+
return False
|
|
771
|
+
try:
|
|
772
|
+
os.remove(STATE_FILE)
|
|
773
|
+
except FileNotFoundError:
|
|
774
|
+
pass
|
|
775
|
+
_append_trail({
|
|
776
|
+
"event": "clear",
|
|
777
|
+
"ts": _now_iso(),
|
|
778
|
+
"pid": os.getpid(),
|
|
779
|
+
"cleared_by": os.environ.get("USER", "?"),
|
|
780
|
+
})
|
|
781
|
+
return True
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _cmd_check(args):
|
|
785
|
+
if is_active():
|
|
786
|
+
sys.exit(1)
|
|
787
|
+
sys.exit(0)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _cmd_status(args):
|
|
791
|
+
p = read()
|
|
792
|
+
if p is None:
|
|
793
|
+
print(json.dumps({"active": False}))
|
|
794
|
+
sys.exit(0)
|
|
795
|
+
out = {"active": True, **p}
|
|
796
|
+
print(json.dumps(out, indent=2))
|
|
797
|
+
sys.exit(0)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _cmd_engage(args):
|
|
801
|
+
extra = {}
|
|
802
|
+
if args.extra:
|
|
803
|
+
try:
|
|
804
|
+
extra = json.loads(args.extra)
|
|
805
|
+
except Exception:
|
|
806
|
+
extra = {"raw_extra": args.extra}
|
|
807
|
+
p = engage(
|
|
808
|
+
signal=args.signal,
|
|
809
|
+
detail=args.detail or "",
|
|
810
|
+
run_log_path=args.run_log or "",
|
|
811
|
+
extra=extra,
|
|
812
|
+
send_email=not args.no_email,
|
|
813
|
+
)
|
|
814
|
+
print(json.dumps(p, indent=2))
|
|
815
|
+
sys.exit(0)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _cmd_clear(args):
|
|
819
|
+
ok = clear()
|
|
820
|
+
print(json.dumps({"cleared": ok}))
|
|
821
|
+
sys.exit(0)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _cmd_detect_gate(args):
|
|
825
|
+
"""Per-run logout detector, called by ensure_linkedin_browser_for_backend so
|
|
826
|
+
ANY LinkedIn pipeline trips the killswitch on its natural next fire.
|
|
827
|
+
|
|
828
|
+
- If the killswitch is already active: no-op, exit 0 (the file gate / hourly
|
|
829
|
+
recovery already own the situation; don't double-probe).
|
|
830
|
+
- Otherwise run a single read-only /feed/ probe. If it CONCLUSIVELY shows
|
|
831
|
+
logged-out (redirect to authwall/login/checkpoint), engage the killswitch
|
|
832
|
+
(signal login_redirect) and exit 2 so the caller can abort this fire. The
|
|
833
|
+
flag pauses every other pipeline on its next fire and starts the 24h
|
|
834
|
+
recovery clock. Healthy or inconclusive (infra) -> exit 0, proceed."""
|
|
835
|
+
if is_active():
|
|
836
|
+
# Already flagged: nothing to detect. Stay silent + cheap.
|
|
837
|
+
sys.exit(0)
|
|
838
|
+
cdp_url = args.cdp_url or LINKEDIN_CDP_URL
|
|
839
|
+
healthy, detail, conclusive = _probe_linkedin_health(cdp_url, feed_only=True)
|
|
840
|
+
_append_trail({
|
|
841
|
+
"event": "detect_gate",
|
|
842
|
+
"ts": _now_iso(),
|
|
843
|
+
"healthy": healthy,
|
|
844
|
+
"conclusive": conclusive,
|
|
845
|
+
"detail": detail,
|
|
846
|
+
})
|
|
847
|
+
if healthy:
|
|
848
|
+
print("detect-gate: session healthy ({})".format(detail), file=sys.stderr)
|
|
849
|
+
sys.exit(0)
|
|
850
|
+
if not conclusive:
|
|
851
|
+
# Couldn't determine (CDP down, nav timeout). Don't engage on infra
|
|
852
|
+
# noise; let the pipeline's own SESSION_INVALID handling deal with it.
|
|
853
|
+
print("detect-gate: inconclusive ({}), proceeding".format(detail), file=sys.stderr)
|
|
854
|
+
sys.exit(0)
|
|
855
|
+
# Conclusively logged out. Trip the killswitch for the whole fleet.
|
|
856
|
+
run_log_path = os.environ.get("S4L_RUN_LOG_PATH", "")
|
|
857
|
+
engage(
|
|
858
|
+
signal="login_redirect",
|
|
859
|
+
detail="detect-gate: {}".format(detail),
|
|
860
|
+
run_log_path=run_log_path,
|
|
861
|
+
extra={"detected_by": os.environ.get("S4L_PIPELINE_NAME", "?"), "probe": "feed_only"},
|
|
862
|
+
send_email=not args.no_email,
|
|
863
|
+
)
|
|
864
|
+
print(
|
|
865
|
+
"detect-gate: LOGGED OUT, killswitch ENGAGED ({}); aborting this fire".format(detail),
|
|
866
|
+
file=sys.stderr,
|
|
867
|
+
)
|
|
868
|
+
sys.exit(2)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _hold_check_due_seconds():
|
|
872
|
+
"""Seconds until (negative) / since (positive) the pending-hold re-verify is
|
|
873
|
+
due. None if not in a pending-hold window or the ts is unparseable."""
|
|
874
|
+
p = read()
|
|
875
|
+
if not p or not p.get("recovery_pending_hold"):
|
|
876
|
+
return None
|
|
877
|
+
due = _parse_ts(p.get("hold_check_due", ""))
|
|
878
|
+
if due is None:
|
|
879
|
+
return None
|
|
880
|
+
return (datetime.now(timezone.utc) - due).total_seconds()
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _cmd_recover_check(args):
|
|
884
|
+
"""Gate for the hourly recovery job. Exits 0 when there is work to do and
|
|
885
|
+
prints the MODE on stdout so the shell knows which path to drive:
|
|
886
|
+
|
|
887
|
+
"login" -> killswitch active >= RECOVERY_MIN_AGE_HOURS and not mid-hold:
|
|
888
|
+
spin up the Claude re-login session, then `recover-record`.
|
|
889
|
+
"hold" -> a prior login succeeded and the hold window has elapsed: run the
|
|
890
|
+
read-only `recover-hold` re-verify (no Claude, no login).
|
|
891
|
+
|
|
892
|
+
Exits 1 (no stdout) when there is nothing to do this hour (inactive,
|
|
893
|
+
terminal, too young, or still inside an unelapsed hold window)."""
|
|
894
|
+
if not is_active():
|
|
895
|
+
print("recover-check: killswitch not active, nothing to recover", file=sys.stderr)
|
|
896
|
+
sys.exit(1)
|
|
897
|
+
if is_terminal():
|
|
898
|
+
print(
|
|
899
|
+
"recover-check: TERMINAL (auto-recovery gave up); "
|
|
900
|
+
"manual re-auth + clear required, not probing",
|
|
901
|
+
file=sys.stderr,
|
|
902
|
+
)
|
|
903
|
+
sys.exit(1)
|
|
904
|
+
|
|
905
|
+
# Pending-hold takes priority: a login already succeeded; we are only waiting
|
|
906
|
+
# to confirm it stuck. No new login attempt while in this window.
|
|
907
|
+
hold_age = _hold_check_due_seconds()
|
|
908
|
+
if hold_age is not None:
|
|
909
|
+
if hold_age >= 0:
|
|
910
|
+
print(
|
|
911
|
+
"recover-check: hold-check due ({:.0f}m past due), re-verifying".format(
|
|
912
|
+
hold_age / 60.0
|
|
913
|
+
),
|
|
914
|
+
file=sys.stderr,
|
|
915
|
+
)
|
|
916
|
+
print("hold")
|
|
917
|
+
sys.exit(0)
|
|
918
|
+
print(
|
|
919
|
+
"recover-check: login holding, hold-check in {:.0f}m".format(
|
|
920
|
+
-hold_age / 60.0
|
|
921
|
+
),
|
|
922
|
+
file=sys.stderr,
|
|
923
|
+
)
|
|
924
|
+
sys.exit(1)
|
|
925
|
+
|
|
926
|
+
# Temporary-restriction dip: a prior attempt hit a restriction with a stated
|
|
927
|
+
# lift time, so we are waiting for that time (+ buffer) rather than the flat
|
|
928
|
+
# 24h gate. This window takes precedence over the age gate: once the lift time
|
|
929
|
+
# passes we allow ONE more login attempt regardless of flag age.
|
|
930
|
+
restr_age = _restricted_until_seconds()
|
|
931
|
+
if restr_age is not None:
|
|
932
|
+
if restr_age >= 0:
|
|
933
|
+
print(
|
|
934
|
+
"recover-check: restriction lift passed ({:.0f}m ago), retrying login".format(
|
|
935
|
+
restr_age / 60.0
|
|
936
|
+
),
|
|
937
|
+
file=sys.stderr,
|
|
938
|
+
)
|
|
939
|
+
print("login")
|
|
940
|
+
sys.exit(0)
|
|
941
|
+
print(
|
|
942
|
+
"recover-check: temporarily restricted, retry in {:.0f}m".format(
|
|
943
|
+
-restr_age / 60.0
|
|
944
|
+
),
|
|
945
|
+
file=sys.stderr,
|
|
946
|
+
)
|
|
947
|
+
sys.exit(1)
|
|
948
|
+
|
|
949
|
+
age = age_seconds()
|
|
950
|
+
min_age = RECOVERY_MIN_AGE_HOURS * 3600
|
|
951
|
+
if age is None:
|
|
952
|
+
print(
|
|
953
|
+
"recover-check: active but ts unparseable, manual clear required",
|
|
954
|
+
file=sys.stderr,
|
|
955
|
+
)
|
|
956
|
+
sys.exit(1)
|
|
957
|
+
if age < min_age:
|
|
958
|
+
print(
|
|
959
|
+
"recover-check: active but only {:.1f}h old (< {}h), waiting".format(
|
|
960
|
+
age / 3600.0, RECOVERY_MIN_AGE_HOURS
|
|
961
|
+
),
|
|
962
|
+
file=sys.stderr,
|
|
963
|
+
)
|
|
964
|
+
sys.exit(1)
|
|
965
|
+
print(
|
|
966
|
+
"recover-check: eligible for re-login (active {:.1f}h >= {}h)".format(
|
|
967
|
+
age / 3600.0, RECOVERY_MIN_AGE_HOURS
|
|
968
|
+
),
|
|
969
|
+
file=sys.stderr,
|
|
970
|
+
)
|
|
971
|
+
print("login")
|
|
972
|
+
sys.exit(0)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _cmd_recover(args):
|
|
976
|
+
"""Run the gentle probe (Chrome must already be up); clear + email on health.
|
|
977
|
+
|
|
978
|
+
Re-checks the age gate itself (unless --force) so it is safe to call
|
|
979
|
+
directly, not just behind recover-check."""
|
|
980
|
+
if not is_active():
|
|
981
|
+
print(json.dumps({"recovered": False, "reason": "not_active"}))
|
|
982
|
+
sys.exit(0)
|
|
983
|
+
if is_terminal():
|
|
984
|
+
print(json.dumps({"recovered": False, "reason": "terminal_manual_required"}))
|
|
985
|
+
sys.exit(0)
|
|
986
|
+
age = age_seconds()
|
|
987
|
+
min_age = RECOVERY_MIN_AGE_HOURS * 3600
|
|
988
|
+
if not args.force and (age is None or age < min_age):
|
|
989
|
+
print(json.dumps({
|
|
990
|
+
"recovered": False,
|
|
991
|
+
"reason": "too_young",
|
|
992
|
+
"age_hours": (round(age / 3600.0, 2) if age else None),
|
|
993
|
+
}))
|
|
994
|
+
sys.exit(0)
|
|
995
|
+
|
|
996
|
+
cdp_url = args.cdp_url or LINKEDIN_CDP_URL
|
|
997
|
+
healthy, detail, conclusive = _probe_linkedin_health(cdp_url)
|
|
998
|
+
_append_trail({
|
|
999
|
+
"event": "recover_probe",
|
|
1000
|
+
"ts": _now_iso(),
|
|
1001
|
+
"healthy": healthy,
|
|
1002
|
+
"conclusive": conclusive,
|
|
1003
|
+
"detail": detail,
|
|
1004
|
+
"age_hours": (round(age / 3600.0, 2) if age else None),
|
|
1005
|
+
})
|
|
1006
|
+
if not healthy:
|
|
1007
|
+
# Inconclusive (CDP down, nav timeout): infra hiccup, not a dead
|
|
1008
|
+
# session. Do NOT count it as a failed re-login; just retry next hour.
|
|
1009
|
+
if not conclusive:
|
|
1010
|
+
print(json.dumps({
|
|
1011
|
+
"recovered": False,
|
|
1012
|
+
"reason": "probe_inconclusive",
|
|
1013
|
+
"detail": detail,
|
|
1014
|
+
}))
|
|
1015
|
+
sys.exit(0)
|
|
1016
|
+
# Conclusively still logged out after the 24h wait. Record the failed
|
|
1017
|
+
# attempt; once we hit RECOVERY_MAX_ATTEMPTS we stop completely.
|
|
1018
|
+
attempts, terminal = _record_failed_recovery(detail)
|
|
1019
|
+
if terminal and not args.no_email:
|
|
1020
|
+
ok, msg = _send_terminal_email(detail, attempts, age)
|
|
1021
|
+
_append_trail({"event": "terminal_email", "ok": ok, "msg": msg})
|
|
1022
|
+
print(json.dumps({
|
|
1023
|
+
"recovered": False,
|
|
1024
|
+
"reason": ("recovery_terminal" if terminal else "relogin_failed_retrying"),
|
|
1025
|
+
"attempts": attempts,
|
|
1026
|
+
"terminal": terminal,
|
|
1027
|
+
"detail": detail,
|
|
1028
|
+
}))
|
|
1029
|
+
sys.exit(0)
|
|
1030
|
+
|
|
1031
|
+
clear()
|
|
1032
|
+
_append_trail({"event": "recover_clear", "ts": _now_iso(), "detail": detail})
|
|
1033
|
+
if not args.no_email:
|
|
1034
|
+
ok, msg = _send_recovery_email(detail, age)
|
|
1035
|
+
_append_trail({"event": "recover_email", "ok": ok, "msg": msg})
|
|
1036
|
+
print(json.dumps({"recovered": True, "detail": detail}))
|
|
1037
|
+
sys.exit(0)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _cmd_recover_record(args):
|
|
1041
|
+
"""Record the verdict of a Claude-driven re-login attempt and apply the
|
|
1042
|
+
matching state transition. Called by skill/linkedin-recovery.sh after it runs
|
|
1043
|
+
the login session. Verdicts:
|
|
1044
|
+
|
|
1045
|
+
held -> enter the pending-hold window; do NOT resume yet.
|
|
1046
|
+
hard_block -> terminal (manual re-auth required), no further attempts.
|
|
1047
|
+
restricted_temp -> temporary restriction with a stated lift time: dip until
|
|
1048
|
+
that time + buffer, then retry once, up to the cap.
|
|
1049
|
+
transient -> re-anchor the 24h clock and try again later, up to the cap.
|
|
1050
|
+
"""
|
|
1051
|
+
if not is_active():
|
|
1052
|
+
print(json.dumps({"recorded": False, "reason": "not_active"}))
|
|
1053
|
+
sys.exit(0)
|
|
1054
|
+
if is_terminal():
|
|
1055
|
+
print(json.dumps({"recorded": False, "reason": "already_terminal"}))
|
|
1056
|
+
sys.exit(0)
|
|
1057
|
+
|
|
1058
|
+
verdict = args.verdict
|
|
1059
|
+
detail = args.detail or ""
|
|
1060
|
+
age = age_seconds()
|
|
1061
|
+
|
|
1062
|
+
if verdict == "held":
|
|
1063
|
+
due_iso = _record_login_held()
|
|
1064
|
+
if not args.no_email:
|
|
1065
|
+
ok, msg = _send_simple_email(
|
|
1066
|
+
"[LI KILL] re-login OK, verifying it holds",
|
|
1067
|
+
[
|
|
1068
|
+
"A Claude-driven re-login into LinkedIn SUCCEEDED.",
|
|
1069
|
+
"",
|
|
1070
|
+
"Pipelines stay paused for now: we re-verify (read-only) at",
|
|
1071
|
+
str(due_iso) + " that the session actually held before",
|
|
1072
|
+
"clearing the flag. If it dropped by then, we stop completely.",
|
|
1073
|
+
"",
|
|
1074
|
+
"Login detail: " + str(detail),
|
|
1075
|
+
"State file: " + STATE_FILE,
|
|
1076
|
+
],
|
|
1077
|
+
)
|
|
1078
|
+
_append_trail({"event": "pending_hold_email", "ok": ok, "msg": msg})
|
|
1079
|
+
print(json.dumps({
|
|
1080
|
+
"recorded": True,
|
|
1081
|
+
"verdict": "held",
|
|
1082
|
+
"pending_hold": True,
|
|
1083
|
+
"hold_check_due": due_iso,
|
|
1084
|
+
}))
|
|
1085
|
+
sys.exit(0)
|
|
1086
|
+
|
|
1087
|
+
if verdict == "hard_block":
|
|
1088
|
+
_record_hard_block(detail)
|
|
1089
|
+
if not args.no_email:
|
|
1090
|
+
age_h = round(age / 3600.0, 1) if age else "?"
|
|
1091
|
+
ok, msg = _send_simple_email(
|
|
1092
|
+
"[LI KILL] re-login BLOCKED, manual re-auth required",
|
|
1093
|
+
[
|
|
1094
|
+
"A Claude-driven re-login into LinkedIn hit a hard block and",
|
|
1095
|
+
"auto-recovery has STOPPED COMPLETELY (no further attempts).",
|
|
1096
|
+
"",
|
|
1097
|
+
"This is a checkpoint / captcha / account restriction / wrong",
|
|
1098
|
+
"credentials / 2FA wall: poking it again only deepens the block.",
|
|
1099
|
+
"",
|
|
1100
|
+
"Killswitch age: " + str(age_h) + "h",
|
|
1101
|
+
"Block detail: " + str(detail),
|
|
1102
|
+
"",
|
|
1103
|
+
"To resume:",
|
|
1104
|
+
" 1. Open the linkedin-harness Chrome (port 9556), sign in,",
|
|
1105
|
+
" and clear any checkpoint by hand.",
|
|
1106
|
+
" 2. Confirm /feed/ renders.",
|
|
1107
|
+
" 3. python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear",
|
|
1108
|
+
"",
|
|
1109
|
+
"State file: " + STATE_FILE,
|
|
1110
|
+
],
|
|
1111
|
+
)
|
|
1112
|
+
_append_trail({"event": "terminal_email", "ok": ok, "msg": msg})
|
|
1113
|
+
print(json.dumps({
|
|
1114
|
+
"recorded": True,
|
|
1115
|
+
"verdict": "hard_block",
|
|
1116
|
+
"terminal": True,
|
|
1117
|
+
}))
|
|
1118
|
+
sys.exit(0)
|
|
1119
|
+
|
|
1120
|
+
if verdict == "restricted_temp":
|
|
1121
|
+
attempts, terminal, retry_at = _record_restricted_temp(detail)
|
|
1122
|
+
if not args.no_email:
|
|
1123
|
+
if terminal:
|
|
1124
|
+
ok, msg = _send_simple_email(
|
|
1125
|
+
"[LI KILL] temporary restriction kept recurring, stopping",
|
|
1126
|
+
[
|
|
1127
|
+
"A Claude-driven re-login hit a TEMPORARY restriction again",
|
|
1128
|
+
"after " + str(attempts) + " timed retries; auto-recovery has",
|
|
1129
|
+
"STOPPED COMPLETELY. The restriction is not clearing on its own.",
|
|
1130
|
+
"",
|
|
1131
|
+
"Last detail: " + str(detail),
|
|
1132
|
+
"Manual re-auth + clear required:",
|
|
1133
|
+
" python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear",
|
|
1134
|
+
"State file: " + STATE_FILE,
|
|
1135
|
+
],
|
|
1136
|
+
)
|
|
1137
|
+
else:
|
|
1138
|
+
ok, msg = _send_simple_email(
|
|
1139
|
+
"[LI KILL] temporarily restricted, will retry after lift time",
|
|
1140
|
+
[
|
|
1141
|
+
"A Claude-driven re-login found the account TEMPORARILY",
|
|
1142
|
+
"restricted (automated-activity flag with a stated lift time).",
|
|
1143
|
+
"",
|
|
1144
|
+
"We are NOT giving up: pipelines stay paused and we will make",
|
|
1145
|
+
"one more login attempt after the restriction lifts, at",
|
|
1146
|
+
" " + _fmt_local(retry_at) + " (" + str(retry_at) + " UTC)",
|
|
1147
|
+
"This is attempt " + str(attempts) + " of "
|
|
1148
|
+
+ str(RECOVERY_RESTRICTED_MAX_ATTEMPTS) + ".",
|
|
1149
|
+
"",
|
|
1150
|
+
"Restriction detail: " + str(detail),
|
|
1151
|
+
"State file: " + STATE_FILE,
|
|
1152
|
+
],
|
|
1153
|
+
)
|
|
1154
|
+
_append_trail({"event": "restricted_temp_email", "ok": ok, "msg": msg})
|
|
1155
|
+
print(json.dumps({
|
|
1156
|
+
"recorded": True,
|
|
1157
|
+
"verdict": "restricted_temp",
|
|
1158
|
+
"restricted_attempts": attempts,
|
|
1159
|
+
"terminal": terminal,
|
|
1160
|
+
"retry_at": retry_at,
|
|
1161
|
+
}))
|
|
1162
|
+
sys.exit(0)
|
|
1163
|
+
|
|
1164
|
+
# transient
|
|
1165
|
+
attempts, terminal = _record_transient(detail)
|
|
1166
|
+
if terminal and not args.no_email:
|
|
1167
|
+
ok, msg = _send_simple_email(
|
|
1168
|
+
"[LI KILL] re-login gave up after repeated inconclusive attempts",
|
|
1169
|
+
[
|
|
1170
|
+
"Claude-driven re-login could not conclusively log in after",
|
|
1171
|
+
str(attempts) + " transient attempt(s); auto-recovery STOPPED.",
|
|
1172
|
+
"",
|
|
1173
|
+
"Last detail: " + str(detail),
|
|
1174
|
+
"Manual re-auth + clear required.",
|
|
1175
|
+
"State file: " + STATE_FILE,
|
|
1176
|
+
],
|
|
1177
|
+
)
|
|
1178
|
+
_append_trail({"event": "terminal_email", "ok": ok, "msg": msg})
|
|
1179
|
+
print(json.dumps({
|
|
1180
|
+
"recorded": True,
|
|
1181
|
+
"verdict": "transient",
|
|
1182
|
+
"transient_attempts": attempts,
|
|
1183
|
+
"terminal": terminal,
|
|
1184
|
+
}))
|
|
1185
|
+
sys.exit(0)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _cmd_recover_hold(args):
|
|
1189
|
+
"""Read-only re-verify that a successful re-login actually held. Run after the
|
|
1190
|
+
pending-hold window elapses (recover-check prints mode "hold"). No login, no
|
|
1191
|
+
Claude: just the gentle probe.
|
|
1192
|
+
|
|
1193
|
+
healthy -> clear the flag + resume the fleet (the win).
|
|
1194
|
+
conclusively out -> terminal: the session dropped shortly after login,
|
|
1195
|
+
which is exactly the "doesn't hold -> stop" rule.
|
|
1196
|
+
inconclusive (infra) -> leave pending; the next hourly tick re-checks.
|
|
1197
|
+
"""
|
|
1198
|
+
if not is_active():
|
|
1199
|
+
print(json.dumps({"held": False, "reason": "not_active"}))
|
|
1200
|
+
sys.exit(0)
|
|
1201
|
+
if is_terminal():
|
|
1202
|
+
print(json.dumps({"held": False, "reason": "already_terminal"}))
|
|
1203
|
+
sys.exit(0)
|
|
1204
|
+
p = read() or {}
|
|
1205
|
+
if not p.get("recovery_pending_hold"):
|
|
1206
|
+
print(json.dumps({"held": False, "reason": "not_pending_hold"}))
|
|
1207
|
+
sys.exit(0)
|
|
1208
|
+
|
|
1209
|
+
cdp_url = args.cdp_url or LINKEDIN_CDP_URL
|
|
1210
|
+
healthy, detail, conclusive = _probe_linkedin_health(cdp_url)
|
|
1211
|
+
age = age_seconds()
|
|
1212
|
+
_append_trail({
|
|
1213
|
+
"event": "hold_check_probe",
|
|
1214
|
+
"ts": _now_iso(),
|
|
1215
|
+
"healthy": healthy,
|
|
1216
|
+
"conclusive": conclusive,
|
|
1217
|
+
"detail": detail,
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
if healthy:
|
|
1221
|
+
clear()
|
|
1222
|
+
_append_trail({"event": "recover_clear", "ts": _now_iso(), "detail": "hold_check: " + str(detail)})
|
|
1223
|
+
if not args.no_email:
|
|
1224
|
+
ok, msg = _send_recovery_email("re-login held: " + str(detail), age)
|
|
1225
|
+
_append_trail({"event": "recover_email", "ok": ok, "msg": msg})
|
|
1226
|
+
print(json.dumps({"held": True, "recovered": True, "detail": detail}))
|
|
1227
|
+
sys.exit(0)
|
|
1228
|
+
|
|
1229
|
+
if not conclusive:
|
|
1230
|
+
# Infra hiccup during the hold-check; don't punish the session, retry next hour.
|
|
1231
|
+
print(json.dumps({"held": False, "reason": "hold_check_inconclusive", "detail": detail}))
|
|
1232
|
+
sys.exit(0)
|
|
1233
|
+
|
|
1234
|
+
# Conclusively logged out again -> the login did not hold. Stop completely.
|
|
1235
|
+
_record_hard_block("login did not hold: " + str(detail))
|
|
1236
|
+
p2 = read() or {}
|
|
1237
|
+
p2["recovery_terminal_reason"] = "login_dropped_after_hold"
|
|
1238
|
+
_write_state(p2)
|
|
1239
|
+
if not args.no_email:
|
|
1240
|
+
ok, msg = _send_simple_email(
|
|
1241
|
+
"[LI KILL] re-login did NOT hold, stopping completely",
|
|
1242
|
+
[
|
|
1243
|
+
"A re-login succeeded but the session dropped within the hold",
|
|
1244
|
+
"window. Per the 'if it doesn't hold, don't try again' rule,",
|
|
1245
|
+
"auto-recovery has STOPPED COMPLETELY.",
|
|
1246
|
+
"",
|
|
1247
|
+
"Hold-check detail: " + str(detail),
|
|
1248
|
+
"Manual re-auth + clear required:",
|
|
1249
|
+
" python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear",
|
|
1250
|
+
"State file: " + STATE_FILE,
|
|
1251
|
+
],
|
|
1252
|
+
)
|
|
1253
|
+
_append_trail({"event": "terminal_email", "ok": ok, "msg": msg})
|
|
1254
|
+
print(json.dumps({"held": False, "terminal": True, "reason": "login_dropped_after_hold", "detail": detail}))
|
|
1255
|
+
sys.exit(0)
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def main():
|
|
1259
|
+
parser = argparse.ArgumentParser(description="LinkedIn pipeline killswitch")
|
|
1260
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
1261
|
+
|
|
1262
|
+
sub.add_parser("check", help="exit 0 if clear, 1 if active (no output)")
|
|
1263
|
+
sub.add_parser("status", help="print JSON payload of current state")
|
|
1264
|
+
|
|
1265
|
+
e = sub.add_parser("engage", help="engage the killswitch")
|
|
1266
|
+
e.add_argument("--signal", required=True, choices=sorted(VALID_SIGNALS))
|
|
1267
|
+
e.add_argument("--detail", default="")
|
|
1268
|
+
e.add_argument("--run-log", default="")
|
|
1269
|
+
e.add_argument("--extra", default="", help="JSON object of extra fields")
|
|
1270
|
+
e.add_argument("--no-email", action="store_true", help="skip email alert")
|
|
1271
|
+
|
|
1272
|
+
sub.add_parser("clear", help="clear the killswitch (human ack)")
|
|
1273
|
+
|
|
1274
|
+
dg = sub.add_parser(
|
|
1275
|
+
"detect-gate",
|
|
1276
|
+
help="per-run logout probe; engage + exit 2 if conclusively logged out",
|
|
1277
|
+
)
|
|
1278
|
+
dg.add_argument("--cdp-url", default="", help="harness CDP URL (default $LINKEDIN_CDP_URL)")
|
|
1279
|
+
dg.add_argument("--no-email", action="store_true", help="skip engage alert email")
|
|
1280
|
+
|
|
1281
|
+
sub.add_parser(
|
|
1282
|
+
"recover-check",
|
|
1283
|
+
help="exit 0 if active AND >= RECOVERY_MIN_AGE_HOURS old (else 1)",
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
r = sub.add_parser(
|
|
1287
|
+
"recover",
|
|
1288
|
+
help="gentle read-only self-heal probe; clear + email on health (legacy)",
|
|
1289
|
+
)
|
|
1290
|
+
r.add_argument("--cdp-url", default="", help="harness CDP URL (default $LINKEDIN_CDP_URL)")
|
|
1291
|
+
r.add_argument("--no-email", action="store_true", help="skip recovery email")
|
|
1292
|
+
r.add_argument("--force", action="store_true", help="skip the age gate")
|
|
1293
|
+
|
|
1294
|
+
rr = sub.add_parser(
|
|
1295
|
+
"recover-record",
|
|
1296
|
+
help="record the verdict of a Claude re-login attempt + transition state",
|
|
1297
|
+
)
|
|
1298
|
+
rr.add_argument(
|
|
1299
|
+
"--verdict",
|
|
1300
|
+
required=True,
|
|
1301
|
+
choices=["held", "hard_block", "restricted_temp", "transient"],
|
|
1302
|
+
help=(
|
|
1303
|
+
"held=login ok (verify hold); hard_block=terminal; "
|
|
1304
|
+
"restricted_temp=temporary restriction, dip until lift time then retry; "
|
|
1305
|
+
"transient=retry later"
|
|
1306
|
+
),
|
|
1307
|
+
)
|
|
1308
|
+
rr.add_argument("--detail", default="", help="human-readable detail for the trail/email")
|
|
1309
|
+
rr.add_argument("--no-email", action="store_true", help="skip the alert email")
|
|
1310
|
+
|
|
1311
|
+
rh = sub.add_parser(
|
|
1312
|
+
"recover-hold",
|
|
1313
|
+
help="read-only re-verify a prior re-login held; clear or go terminal",
|
|
1314
|
+
)
|
|
1315
|
+
rh.add_argument("--cdp-url", default="", help="harness CDP URL (default $LINKEDIN_CDP_URL)")
|
|
1316
|
+
rh.add_argument("--no-email", action="store_true", help="skip the alert email")
|
|
1317
|
+
|
|
1318
|
+
args = parser.parse_args()
|
|
1319
|
+
{
|
|
1320
|
+
"check": _cmd_check,
|
|
1321
|
+
"status": _cmd_status,
|
|
1322
|
+
"engage": _cmd_engage,
|
|
1323
|
+
"clear": _cmd_clear,
|
|
1324
|
+
"detect-gate": _cmd_detect_gate,
|
|
1325
|
+
"recover-check": _cmd_recover_check,
|
|
1326
|
+
"recover": _cmd_recover,
|
|
1327
|
+
"recover-record": _cmd_recover_record,
|
|
1328
|
+
"recover-hold": _cmd_recover_hold,
|
|
1329
|
+
}[args.cmd](args)
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
if __name__ == "__main__":
|
|
1333
|
+
main()
|