@m13v/s4l 1.7.5-rc.2 → 1.7.5-rc.21
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/mcp/dist/index.js +571 -79
- package/mcp/dist/panel.html +2 -3
- package/mcp/dist/redditAuth.js +115 -0
- package/mcp/dist/runtime.js +83 -2
- package/mcp/dist/setup.js +142 -5
- package/mcp/dist/telemetry.js +13 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +32 -6
- package/mcp/menubar/s4l_menubar.py +42 -111
- package/mcp/menubar/s4l_state.py +54 -187
- package/mcp/package.json +1 -1
- package/mcp/shared/onboarding-ledger.cjs +63 -4
- package/package.json +1 -1
- package/scripts/autopilot_stall_watch.py +3 -6
- package/scripts/browser_mutex.py +316 -0
- package/scripts/claude_job.py +21 -0
- package/scripts/config.py +118 -0
- package/scripts/counterparty_history.py +44 -6
- package/scripts/engage_github.py +7 -3
- package/scripts/engage_reddit.py +9 -3
- package/scripts/engagement_signal_feedback.py +256 -0
- package/scripts/learned_preferences.py +37 -14
- package/scripts/memory_snapshot.py +45 -0
- package/scripts/merge_review_queue.py +67 -3
- package/scripts/post_reddit.py +289 -24
- package/scripts/reddit_browser.py +62 -63
- package/scripts/reddit_browser_fetch.py +32 -0
- package/scripts/release-mcpb.sh +17 -0
- package/scripts/reset-test-machine.sh +13 -7
- package/scripts/scan_twitter_thread_followups.py +7 -1
- package/scripts/schedule_state.py +1 -1
- package/scripts/setup_reddit_auth.py +925 -0
- package/scripts/snapshot.py +96 -4
- package/scripts/stats.py +37 -13
- package/scripts/test_no_silent_fallbacks.py +281 -0
- package/scripts/trigger_promo_lane_test.sh +91 -0
- package/scripts/twitter_browser.py +183 -342
- package/scripts/twitter_scan.py +75 -10
- package/scripts/watchdog_hung_runs.py +15 -9
- package/skill/lib/harness-common.sh +344 -0
- package/skill/lib/linkedin-backend.sh +61 -161
- package/skill/lib/reddit-backend.sh +39 -125
- package/skill/lib/twitter-backend.sh +51 -273
- package/skill/lock.sh +57 -0
- package/skill/run-reddit-search-launchd.sh +4 -1
- package/skill/run-reddit-search.sh +51 -2
- package/skill/run-twitter-cycle.sh +44 -21
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""setup_reddit_auth.py - Reddit session bootstrap for the MCP setup flow.
|
|
3
|
+
|
|
4
|
+
Used by the social-autoposter MCP `project_config` tool (action=connect_reddit)
|
|
5
|
+
to give a user a logged-in Reddit session in the autoposter's managed
|
|
6
|
+
reddit-harness Chrome WITHOUT making them paste cookies or hand-edit anything.
|
|
7
|
+
Mirrors scripts/setup_twitter_auth.py, parameterized for Reddit.
|
|
8
|
+
|
|
9
|
+
It answers the three questions the setup flow needs:
|
|
10
|
+
1. Do cookies already exist in the managed reddit-harness browser?
|
|
11
|
+
2. Are they still valid? (api/me.json returns a real, non-suspended account
|
|
12
|
+
via a same-origin fetch from inside a reddit.com page)
|
|
13
|
+
3. Does the user need to log in manually? (import failed / no source)
|
|
14
|
+
|
|
15
|
+
How it works
|
|
16
|
+
------------
|
|
17
|
+
The Reddit pipeline rides a dedicated managed Chrome on CDP port 9557 with a
|
|
18
|
+
persistent profile at ~/.claude/browser-profiles/reddit-harness (the same
|
|
19
|
+
Chrome skill/lib/reddit-backend.sh launches for discovery + posting). This
|
|
20
|
+
helper:
|
|
21
|
+
|
|
22
|
+
status - probe that Chrome; if up, do a read-only reddit_session cookie
|
|
23
|
+
check (cheap and poll-safe: never navigates the shared harness
|
|
24
|
+
tab; the authoritative me.json validation lives in `connect`).
|
|
25
|
+
If down, fall back to a read-only on-disk cookie check
|
|
26
|
+
(connected_idle) so a saved session never reads back as
|
|
27
|
+
"disconnected" just because the harness isn't running.
|
|
28
|
+
connect - ensure that Chrome is running; if the reddit session is already
|
|
29
|
+
valid, no-op; otherwise IMPORT reddit.com cookies (reddit_session,
|
|
30
|
+
token_v2, and the rest of the reddit.com jar) from the user's
|
|
31
|
+
everyday browser (Chrome/Arc/Brave/Edge, auto-detected) via the
|
|
32
|
+
vendored copy_browser_cookies.py, then re-validate. If still
|
|
33
|
+
logged out, open a visible login window (manual-login fallback)
|
|
34
|
+
and wait for the user to sign in once.
|
|
35
|
+
|
|
36
|
+
Only reddit.com cookies are copied. No other site's session is touched, and
|
|
37
|
+
cookie VALUES are never printed.
|
|
38
|
+
|
|
39
|
+
On a valid session the result also carries account context so onboarding can
|
|
40
|
+
set expectations: account_age_days + comment_karma from me.json, plus a
|
|
41
|
+
`warning` string when the account is fresh/low-karma (AutoMod silently gates
|
|
42
|
+
those in most subreddits). The warning NEVER blocks the connect.
|
|
43
|
+
|
|
44
|
+
The username is returned to the caller; the MCP server persists it into
|
|
45
|
+
config accounts.reddit.username through its own config-write path (this
|
|
46
|
+
script deliberately does NOT write config.json).
|
|
47
|
+
|
|
48
|
+
Output: a single JSON object on stdout. Human-readable notes go to stderr.
|
|
49
|
+
|
|
50
|
+
CLI:
|
|
51
|
+
python3 setup_reddit_auth.py status
|
|
52
|
+
python3 setup_reddit_auth.py detect-sources
|
|
53
|
+
python3 setup_reddit_auth.py connect [--source chrome:Default] [--no-launch]
|
|
54
|
+
[--manual-login] [--login-wait 300]
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import argparse
|
|
60
|
+
import json
|
|
61
|
+
import os
|
|
62
|
+
import socket
|
|
63
|
+
import subprocess
|
|
64
|
+
import sys
|
|
65
|
+
import time
|
|
66
|
+
import urllib.request
|
|
67
|
+
import urllib.error
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
|
|
70
|
+
# websocket-client is needed for CDP cookie polling (connect). Deferred error,
|
|
71
|
+
# same as setup_twitter_auth.py: `detect-sources` is pure filesystem.
|
|
72
|
+
try:
|
|
73
|
+
from websocket import create_connection # websocket-client
|
|
74
|
+
_WEBSOCKET_IMPORT_ERROR = None
|
|
75
|
+
except ImportError:
|
|
76
|
+
create_connection = None # type: ignore[assignment]
|
|
77
|
+
_WEBSOCKET_IMPORT_ERROR = (
|
|
78
|
+
"websocket-client not installed (needed for CDP). pip install websocket-client"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
82
|
+
|
|
83
|
+
# Shared, platform-agnostic helpers from the X bootstrap (headless detection,
|
|
84
|
+
# keychain preflight, import-error classification). Reused, not duplicated, so
|
|
85
|
+
# a fix there reaches both platforms. Guarded: a missing module never breaks
|
|
86
|
+
# reddit setup, we just degrade to coarse error strings.
|
|
87
|
+
try:
|
|
88
|
+
from setup_twitter_auth import ( # noqa: E402
|
|
89
|
+
_classify_import_error,
|
|
90
|
+
_is_headless,
|
|
91
|
+
_keychain_safe_storage_ok,
|
|
92
|
+
)
|
|
93
|
+
except Exception: # pragma: no cover - defensive
|
|
94
|
+
def _classify_import_error(detail): # type: ignore[misc]
|
|
95
|
+
return "unknown"
|
|
96
|
+
|
|
97
|
+
def _is_headless(): # type: ignore[misc]
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
def _keychain_safe_storage_ok(browser_label="Chrome"): # type: ignore[misc]
|
|
101
|
+
return True, "probe unavailable"
|
|
102
|
+
|
|
103
|
+
# Vendored cookie copier - stdlib-only browser/profile detection plus the
|
|
104
|
+
# actual keychain-decrypt + CDP-inject copy. Same module connect_x uses,
|
|
105
|
+
# parameterized here by --domains reddit.com.
|
|
106
|
+
try:
|
|
107
|
+
import copy_browser_cookies as _cbc # noqa: E402
|
|
108
|
+
except Exception:
|
|
109
|
+
_cbc = None
|
|
110
|
+
|
|
111
|
+
# Same-origin me.json fetch through the logged-in harness page. This is the
|
|
112
|
+
# ONLY fetch path Reddit reliably 200s (bare urllib/curl 403s from residential
|
|
113
|
+
# IPs since 2026-05-28); it also yields to an active posting drain.
|
|
114
|
+
try:
|
|
115
|
+
from reddit_browser_fetch import browser_get_json # noqa: E402
|
|
116
|
+
except Exception:
|
|
117
|
+
browser_get_json = None
|
|
118
|
+
|
|
119
|
+
# --- Config -----------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# Same managed Chrome the reddit pipeline uses (skill/lib/reddit-backend.sh).
|
|
122
|
+
CDP = os.environ.get(
|
|
123
|
+
"S4L_REDDIT_CDP_URL", os.environ.get("REDDIT_CDP_URL", "http://127.0.0.1:9557")
|
|
124
|
+
).rstrip("/")
|
|
125
|
+
PORT = int(CDP.rsplit(":", 1)[-1]) if CDP.rsplit(":", 1)[-1].isdigit() else 9557
|
|
126
|
+
PROFILE_DIR = Path.home() / ".claude" / "browser-profiles" / "reddit-harness"
|
|
127
|
+
PID_FILE = Path.home() / ".claude" / "browser-profiles" / "reddit-harness.chrome.pid"
|
|
128
|
+
|
|
129
|
+
DOMAINS = "reddit.com"
|
|
130
|
+
ME_JSON_URL = "https://www.reddit.com/api/me.json"
|
|
131
|
+
|
|
132
|
+
VENDORED_COOKIE_SCRIPT = Path(__file__).resolve().parent / "copy_browser_cookies.py"
|
|
133
|
+
|
|
134
|
+
# Expectation thresholds for the fresh-account warning (advisory only; most
|
|
135
|
+
# subreddits' AutoMod removes comments from young / low-karma accounts).
|
|
136
|
+
FRESH_ACCOUNT_MIN_AGE_DAYS = 30
|
|
137
|
+
FRESH_ACCOUNT_MIN_COMMENT_KARMA = 100
|
|
138
|
+
|
|
139
|
+
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
140
|
+
_CONFIG_JSON = os.path.join(_REPO_ROOT, "config.json")
|
|
141
|
+
_USERNAME_PLACEHOLDERS = {"", "your-reddit-username", "u/your-reddit-username"}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --- Chrome lifecycle (mirrors setup_twitter_auth.py) ------------------------
|
|
145
|
+
|
|
146
|
+
def _port_open(port: int) -> bool:
|
|
147
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
148
|
+
s.settimeout(0.5)
|
|
149
|
+
try:
|
|
150
|
+
s.connect(("127.0.0.1", port))
|
|
151
|
+
return True
|
|
152
|
+
except OSError:
|
|
153
|
+
return False
|
|
154
|
+
finally:
|
|
155
|
+
s.close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _cdp_alive() -> bool:
|
|
159
|
+
if not _port_open(PORT):
|
|
160
|
+
return False
|
|
161
|
+
try:
|
|
162
|
+
with urllib.request.urlopen(f"{CDP}/json/version", timeout=1.5) as r:
|
|
163
|
+
return r.status == 200
|
|
164
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _resolve_chrome_bin() -> "str | None":
|
|
169
|
+
env = os.environ.get("BH_CHROME_BIN")
|
|
170
|
+
if env and Path(env).exists():
|
|
171
|
+
return env
|
|
172
|
+
candidates = [
|
|
173
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
174
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
175
|
+
"/usr/bin/google-chrome",
|
|
176
|
+
"/usr/bin/google-chrome-stable",
|
|
177
|
+
"/usr/bin/chromium",
|
|
178
|
+
"/usr/bin/chromium-browser",
|
|
179
|
+
"/snap/bin/chromium",
|
|
180
|
+
]
|
|
181
|
+
for p in candidates:
|
|
182
|
+
if Path(p).exists():
|
|
183
|
+
return p
|
|
184
|
+
import shutil
|
|
185
|
+
for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
|
|
186
|
+
found = shutil.which(name)
|
|
187
|
+
if found:
|
|
188
|
+
return found
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _launch_chrome() -> bool:
|
|
193
|
+
"""Launch the managed reddit-harness Chrome on PORT, ON-SCREEN.
|
|
194
|
+
|
|
195
|
+
On-screen (unlike the pipeline's off-screen parking) because during setup
|
|
196
|
+
the user may need to see this window to log in. Cookies persist on disk,
|
|
197
|
+
so later off-screen relaunches by the pipeline inherit the session.
|
|
198
|
+
|
|
199
|
+
IMPORTANT flag parity: skill/lib/reddit-backend.sh launches this profile
|
|
200
|
+
WITHOUT --password-store=basic / --use-mock-keychain (unlike the twitter
|
|
201
|
+
harness). Chrome encrypts the cookie SQLite with whichever key the launch
|
|
202
|
+
flags select; mixing keyed and mock-keychain launches on the SAME profile
|
|
203
|
+
makes Chrome unable to decrypt its own cookies and silently drops the
|
|
204
|
+
session. So this launcher must match the pipeline's flags exactly.
|
|
205
|
+
"""
|
|
206
|
+
chrome = _resolve_chrome_bin()
|
|
207
|
+
if not chrome:
|
|
208
|
+
return False
|
|
209
|
+
cmd = [
|
|
210
|
+
chrome,
|
|
211
|
+
f"--remote-debugging-port={PORT}",
|
|
212
|
+
f"--user-data-dir={PROFILE_DIR}",
|
|
213
|
+
"--no-first-run",
|
|
214
|
+
"--no-default-browser-check",
|
|
215
|
+
"--disable-hang-monitor",
|
|
216
|
+
"--disable-features=ChromeWhatsNewUI,CalculateNativeWinOcclusion",
|
|
217
|
+
"--disable-backgrounding-occluded-windows",
|
|
218
|
+
]
|
|
219
|
+
is_linux = sys.platform.startswith("linux")
|
|
220
|
+
has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
221
|
+
if is_linux:
|
|
222
|
+
cmd += ["--no-sandbox", "--disable-dev-shm-usage"]
|
|
223
|
+
if not has_display:
|
|
224
|
+
cmd += ["--headless=new", "--disable-gpu"]
|
|
225
|
+
else:
|
|
226
|
+
cmd += ["--window-position=80,80", "--window-size=1100,900"]
|
|
227
|
+
cmd.append("about:blank")
|
|
228
|
+
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
# start_new_session: don't let a transient launchd caller's process-group
|
|
230
|
+
# SIGKILL reap this Chrome on exit (same fix as the backends, 2026-07-12).
|
|
231
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
232
|
+
start_new_session=True)
|
|
233
|
+
try:
|
|
234
|
+
PID_FILE.write_text(str(proc.pid))
|
|
235
|
+
except OSError:
|
|
236
|
+
pass
|
|
237
|
+
# 40s poll window, wider than the twitter helper's 15s: the FIRST reddit
|
|
238
|
+
# connect creates the harness profile from scratch, and on a cold box that
|
|
239
|
+
# first launch takes >15s to bind the debug port (observed on the QA box
|
|
240
|
+
# 2026-07-15: ensure_chrome gave up at 15s, Chrome bound seconds later and
|
|
241
|
+
# the flow misreported browser_launch_failed).
|
|
242
|
+
for _ in range(40):
|
|
243
|
+
if _cdp_alive():
|
|
244
|
+
return True
|
|
245
|
+
time.sleep(1)
|
|
246
|
+
return _cdp_alive()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def ensure_chrome(launch: bool = True) -> bool:
|
|
250
|
+
if _cdp_alive():
|
|
251
|
+
return True
|
|
252
|
+
if not launch:
|
|
253
|
+
return False
|
|
254
|
+
return _launch_chrome()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# --- CDP attach (cookie polling + window control) ----------------------------
|
|
258
|
+
|
|
259
|
+
def _attach():
|
|
260
|
+
targets = json.load(urllib.request.urlopen(f"{CDP}/json", timeout=10))
|
|
261
|
+
page = next((t for t in targets if t.get("type") == "page"), None)
|
|
262
|
+
if not page:
|
|
263
|
+
page = json.load(urllib.request.urlopen(
|
|
264
|
+
urllib.request.Request(f"{CDP}/json/new?about:blank", method="PUT"), timeout=10))
|
|
265
|
+
ws = create_connection(page["webSocketDebuggerUrl"], timeout=20, suppress_origin=True)
|
|
266
|
+
state = {"id": 0}
|
|
267
|
+
|
|
268
|
+
def send(method, params=None):
|
|
269
|
+
state["id"] += 1
|
|
270
|
+
ws.send(json.dumps({"id": state["id"], "method": method, "params": params or {}}))
|
|
271
|
+
while True:
|
|
272
|
+
msg = json.loads(ws.recv())
|
|
273
|
+
if msg.get("id") == state["id"]:
|
|
274
|
+
return msg
|
|
275
|
+
return ws, send
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _has_reddit_session_cookie(send) -> bool:
|
|
279
|
+
# reddit_session ONLY: token_v2 is an anonymous-session JWT Reddit sets for
|
|
280
|
+
# every visitor (logged in or not), so keying on it false-positives the
|
|
281
|
+
# moment the harness merely VISITS reddit.com (observed on the QA box
|
|
282
|
+
# 2026-07-15). reddit_session exists only for a logged-in account.
|
|
283
|
+
r = send("Network.getAllCookies")
|
|
284
|
+
cks = r.get("result", {}).get("cookies", []) or []
|
|
285
|
+
return any(
|
|
286
|
+
c.get("name") == "reddit_session"
|
|
287
|
+
and "reddit.com" in (c.get("domain") or "")
|
|
288
|
+
for c in cks
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _has_session_quick() -> bool:
|
|
293
|
+
"""Read-only: reddit_session cookie present in the live browser?
|
|
294
|
+
Never navigates, so it's safe while a pipeline run is active. A revoked
|
|
295
|
+
cookie can false-positive; the me.json validation is authoritative."""
|
|
296
|
+
ws, send = _attach()
|
|
297
|
+
try:
|
|
298
|
+
send("Network.enable")
|
|
299
|
+
return _has_reddit_session_cookie(send)
|
|
300
|
+
finally:
|
|
301
|
+
try:
|
|
302
|
+
ws.close()
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --- me.json validation -------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def _fetch_me() -> "dict | None":
|
|
310
|
+
"""Fetch api/me.json through the logged-in harness page (same-origin fetch,
|
|
311
|
+
the only transport Reddit reliably 200s). Returns the parsed `data` dict on
|
|
312
|
+
a logged-in session, {} when logged out, None on transport failure."""
|
|
313
|
+
if browser_get_json is None:
|
|
314
|
+
return None
|
|
315
|
+
body, status = browser_get_json(ME_JSON_URL, cdp_url=CDP)
|
|
316
|
+
if status != 200 or not body:
|
|
317
|
+
return None
|
|
318
|
+
try:
|
|
319
|
+
j = json.loads(body)
|
|
320
|
+
except json.JSONDecodeError:
|
|
321
|
+
return None
|
|
322
|
+
if not isinstance(j, dict):
|
|
323
|
+
return None
|
|
324
|
+
data = j.get("data")
|
|
325
|
+
return data if isinstance(data, dict) else {}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _account_fields(data: dict) -> dict:
|
|
329
|
+
"""Account context for onboarding expectations: username, age, karma, and a
|
|
330
|
+
non-blocking warning for fresh/low-karma accounts (AutoMod gates those)."""
|
|
331
|
+
name = (data.get("name") or "").strip()
|
|
332
|
+
created = data.get("created_utc") or data.get("created")
|
|
333
|
+
age_days = None
|
|
334
|
+
try:
|
|
335
|
+
if created:
|
|
336
|
+
age_days = max(0, int((time.time() - float(created)) / 86400))
|
|
337
|
+
except (TypeError, ValueError):
|
|
338
|
+
age_days = None
|
|
339
|
+
comment_karma = data.get("comment_karma")
|
|
340
|
+
total_karma = data.get("total_karma", data.get("link_karma"))
|
|
341
|
+
warning = None
|
|
342
|
+
reasons = []
|
|
343
|
+
if age_days is not None and age_days < FRESH_ACCOUNT_MIN_AGE_DAYS:
|
|
344
|
+
reasons.append(f"the account is only {age_days} day(s) old")
|
|
345
|
+
if isinstance(comment_karma, (int, float)) and comment_karma < FRESH_ACCOUNT_MIN_COMMENT_KARMA:
|
|
346
|
+
reasons.append(f"it has {int(comment_karma)} comment karma")
|
|
347
|
+
if reasons:
|
|
348
|
+
warning = (
|
|
349
|
+
"Heads up: " + " and ".join(reasons) + ". Most subreddits' AutoMod "
|
|
350
|
+
"silently removes or holds comments from fresh, low-karma accounts, so "
|
|
351
|
+
"early replies may not be visible to others. This does not block setup; "
|
|
352
|
+
"expect slower traction until the account ages and earns karma "
|
|
353
|
+
"(participating manually for a few weeks helps)."
|
|
354
|
+
)
|
|
355
|
+
return {
|
|
356
|
+
"username": name or None,
|
|
357
|
+
"account_age_days": age_days,
|
|
358
|
+
"comment_karma": int(comment_karma) if isinstance(comment_karma, (int, float)) else None,
|
|
359
|
+
"total_karma": int(total_karma) if isinstance(total_karma, (int, float)) else None,
|
|
360
|
+
"suspended": bool(data.get("is_suspended")),
|
|
361
|
+
"warning": warning,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _validate_session() -> "dict | None":
|
|
366
|
+
"""Authoritative check: me.json through the live harness. Returns the
|
|
367
|
+
account-fields dict when logged in with a usable account, or None."""
|
|
368
|
+
data = _fetch_me()
|
|
369
|
+
if not data: # None (transport fail) or {} (logged out)
|
|
370
|
+
return None
|
|
371
|
+
fields = _account_fields(data)
|
|
372
|
+
if not fields["username"]:
|
|
373
|
+
return None
|
|
374
|
+
return fields
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# --- On-disk session detection (no keychain, no decryption) -------------------
|
|
378
|
+
|
|
379
|
+
def _cookies_db_path() -> "Path | None":
|
|
380
|
+
candidates = [
|
|
381
|
+
PROFILE_DIR / "Default" / "Network" / "Cookies",
|
|
382
|
+
PROFILE_DIR / "Default" / "Cookies",
|
|
383
|
+
]
|
|
384
|
+
existing = [p for p in candidates if p.exists()]
|
|
385
|
+
if not existing:
|
|
386
|
+
return None
|
|
387
|
+
return max(existing, key=lambda p: p.stat().st_mtime)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _profile_dir_has_session() -> bool:
|
|
391
|
+
"""True when the HARNESS profile's on-disk Cookies SQLite has a
|
|
392
|
+
reddit_session row (login cookie ONLY; token_v2 is set for anonymous
|
|
393
|
+
visitors too). Presence-only (value stays encrypted), so no
|
|
394
|
+
macOS Safe Storage prompt. Lets `status` report connected_idle while the
|
|
395
|
+
harness Chrome is down (the pipeline relaunches it with the same profile)."""
|
|
396
|
+
if _cbc is None:
|
|
397
|
+
return False
|
|
398
|
+
db = _cookies_db_path()
|
|
399
|
+
if db is None:
|
|
400
|
+
return False
|
|
401
|
+
tmp = _cbc.copy_db(db)
|
|
402
|
+
if tmp is None:
|
|
403
|
+
return False
|
|
404
|
+
import shutil
|
|
405
|
+
import sqlite3
|
|
406
|
+
try:
|
|
407
|
+
conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
|
408
|
+
try:
|
|
409
|
+
row = conn.execute(
|
|
410
|
+
"SELECT 1 FROM cookies WHERE name = 'reddit_session' "
|
|
411
|
+
"AND host_key LIKE '%reddit.com' LIMIT 1"
|
|
412
|
+
).fetchone()
|
|
413
|
+
return row is not None
|
|
414
|
+
finally:
|
|
415
|
+
conn.close()
|
|
416
|
+
except Exception:
|
|
417
|
+
return False
|
|
418
|
+
finally:
|
|
419
|
+
shutil.rmtree(tmp.parent, ignore_errors=True)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _configured_username() -> "str | None":
|
|
423
|
+
"""accounts.reddit.username from config.json (read-only), or None if empty /
|
|
424
|
+
placeholder. Reads through the same file account_resolver reads."""
|
|
425
|
+
try:
|
|
426
|
+
with open(_CONFIG_JSON, encoding="utf-8") as f:
|
|
427
|
+
cfg = json.load(f)
|
|
428
|
+
u = ((cfg.get("accounts") or {}).get("reddit") or {}).get("username") or ""
|
|
429
|
+
except Exception:
|
|
430
|
+
return None
|
|
431
|
+
u = u.strip()
|
|
432
|
+
if u.lower() in _USERNAME_PLACEHOLDERS:
|
|
433
|
+
return None
|
|
434
|
+
if u.lower().startswith("u/"):
|
|
435
|
+
u = u[2:]
|
|
436
|
+
return u or None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# --- Source detection ---------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
def _profile_has_reddit_session(profile) -> bool:
|
|
442
|
+
"""True if `profile`'s Cookies DB has a reddit.com session cookie row.
|
|
443
|
+
Filesystem + SQLite only; no keychain prompt."""
|
|
444
|
+
if _cbc is None:
|
|
445
|
+
return False
|
|
446
|
+
cookies_path = profile.path / "Cookies"
|
|
447
|
+
if not cookies_path.exists():
|
|
448
|
+
nested = profile.path / "Network" / "Cookies"
|
|
449
|
+
cookies_path = nested if nested.exists() else cookies_path
|
|
450
|
+
if not cookies_path.exists():
|
|
451
|
+
return False
|
|
452
|
+
tmp = _cbc.copy_db(cookies_path)
|
|
453
|
+
if tmp is None:
|
|
454
|
+
return False
|
|
455
|
+
import shutil
|
|
456
|
+
import sqlite3
|
|
457
|
+
try:
|
|
458
|
+
conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
|
459
|
+
try:
|
|
460
|
+
row = conn.execute(
|
|
461
|
+
"SELECT 1 FROM cookies WHERE name = 'reddit_session' "
|
|
462
|
+
"AND host_key LIKE '%reddit.com' LIMIT 1"
|
|
463
|
+
).fetchone()
|
|
464
|
+
return row is not None
|
|
465
|
+
finally:
|
|
466
|
+
conn.close()
|
|
467
|
+
except Exception:
|
|
468
|
+
return False
|
|
469
|
+
finally:
|
|
470
|
+
shutil.rmtree(tmp.parent, ignore_errors=True)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _list_sources() -> "list[dict]":
|
|
474
|
+
if _cbc is None:
|
|
475
|
+
return []
|
|
476
|
+
out: "list[dict]" = []
|
|
477
|
+
for p in _cbc.detect_browsers():
|
|
478
|
+
out.append({
|
|
479
|
+
"spec": f"{p.browser}:{p.name}",
|
|
480
|
+
"browser": p.browser,
|
|
481
|
+
"profile": p.name,
|
|
482
|
+
"label": f"{p.browser.capitalize()} - {p.name}",
|
|
483
|
+
"reddit_session": _profile_has_reddit_session(p),
|
|
484
|
+
})
|
|
485
|
+
out.sort(key=lambda s: (s["browser"] != "chrome", not s["reddit_session"]))
|
|
486
|
+
return out
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def cmd_detect_sources(args) -> dict:
|
|
490
|
+
sources = _list_sources()
|
|
491
|
+
recommended = next((s["spec"] for s in sources if s["reddit_session"]), None)
|
|
492
|
+
if not recommended:
|
|
493
|
+
recommended = next((s["spec"] for s in sources if s["spec"] == "chrome:Default"),
|
|
494
|
+
sources[0]["spec"] if sources else "chrome:Default")
|
|
495
|
+
return {"ok": True, "sources": sources, "recommended": recommended}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# --- Cookie import -------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
def _import_from(source: str) -> dict:
|
|
501
|
+
"""Copy reddit.com cookies from `source` into the managed harness Chrome via
|
|
502
|
+
the vendored copier. Values are never surfaced; the copier prints counts."""
|
|
503
|
+
if not VENDORED_COOKIE_SCRIPT.exists():
|
|
504
|
+
return {
|
|
505
|
+
"ok": False,
|
|
506
|
+
"error": f"vendored cookie copier missing at {VENDORED_COOKIE_SCRIPT}",
|
|
507
|
+
}
|
|
508
|
+
cmd = [
|
|
509
|
+
sys.executable, str(VENDORED_COOKIE_SCRIPT), "copy",
|
|
510
|
+
"--from", source, "--to", CDP, "--domains", DOMAINS,
|
|
511
|
+
]
|
|
512
|
+
# Keychain auth dialogs need human time; same generous window as connect_x.
|
|
513
|
+
_raw_to = os.environ.get("S4L_COOKIE_COPY_TIMEOUT", "600").strip()
|
|
514
|
+
try:
|
|
515
|
+
copy_timeout = float(_raw_to) if _raw_to else None
|
|
516
|
+
except ValueError:
|
|
517
|
+
copy_timeout = 600.0
|
|
518
|
+
if copy_timeout is not None and copy_timeout <= 0:
|
|
519
|
+
copy_timeout = None
|
|
520
|
+
try:
|
|
521
|
+
proc = subprocess.run(
|
|
522
|
+
cmd, capture_output=True, text=True, timeout=copy_timeout,
|
|
523
|
+
cwd=str(VENDORED_COOKIE_SCRIPT.parent),
|
|
524
|
+
)
|
|
525
|
+
except subprocess.TimeoutExpired:
|
|
526
|
+
_to_label = f"{copy_timeout:g}s" if copy_timeout is not None else "no limit"
|
|
527
|
+
return {"ok": False, "error": f"cookie copy from {source} timed out ({_to_label})"}
|
|
528
|
+
return {
|
|
529
|
+
"ok": proc.returncode == 0,
|
|
530
|
+
"returncode": proc.returncode,
|
|
531
|
+
"stdout": proc.stdout.strip(),
|
|
532
|
+
"stderr": proc.stderr.strip(),
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# --- Manual login fallback ------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
def _show_window_and_open_login() -> bool:
|
|
539
|
+
"""Make the harness Chrome window visible + focused on the Reddit login page
|
|
540
|
+
so the user can sign in by hand. Mirrors the X manual-login discipline."""
|
|
541
|
+
try:
|
|
542
|
+
ws, send = _attach()
|
|
543
|
+
except Exception:
|
|
544
|
+
return False
|
|
545
|
+
try:
|
|
546
|
+
try:
|
|
547
|
+
win = send("Browser.getWindowForTarget")
|
|
548
|
+
win_id = (win.get("result", {}) or {}).get("windowId")
|
|
549
|
+
if win_id is not None:
|
|
550
|
+
send("Browser.setWindowBounds",
|
|
551
|
+
{"windowId": win_id, "bounds": {"windowState": "normal"}})
|
|
552
|
+
send("Browser.setWindowBounds",
|
|
553
|
+
{"windowId": win_id,
|
|
554
|
+
"bounds": {"left": 80, "top": 80, "width": 1100, "height": 900}})
|
|
555
|
+
except Exception:
|
|
556
|
+
pass
|
|
557
|
+
try:
|
|
558
|
+
send("Page.enable")
|
|
559
|
+
send("Page.navigate", {"url": "https://www.reddit.com/login/"})
|
|
560
|
+
send("Page.bringToFront")
|
|
561
|
+
return True
|
|
562
|
+
except Exception:
|
|
563
|
+
return False
|
|
564
|
+
finally:
|
|
565
|
+
try:
|
|
566
|
+
ws.close()
|
|
567
|
+
except Exception:
|
|
568
|
+
pass
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _poll_for_login(timeout: float = 90.0, interval: float = 2.0) -> bool:
|
|
572
|
+
"""Block until the user finishes a MANUAL login (session cookie appears) or
|
|
573
|
+
the bounded window elapses. Read-only polling; never navigates, so it can't
|
|
574
|
+
disrupt the login flow. Prevents the caller re-checking faster than a human
|
|
575
|
+
can type a password + 2FA (the detection race connect_x had)."""
|
|
576
|
+
try:
|
|
577
|
+
ws, send = _attach()
|
|
578
|
+
except Exception:
|
|
579
|
+
return False
|
|
580
|
+
try:
|
|
581
|
+
send("Network.enable")
|
|
582
|
+
deadline = time.time() + max(0.0, timeout)
|
|
583
|
+
while True:
|
|
584
|
+
try:
|
|
585
|
+
if _has_reddit_session_cookie(send):
|
|
586
|
+
return True
|
|
587
|
+
except Exception:
|
|
588
|
+
pass
|
|
589
|
+
if time.time() >= deadline:
|
|
590
|
+
return False
|
|
591
|
+
time.sleep(max(0.5, interval))
|
|
592
|
+
finally:
|
|
593
|
+
try:
|
|
594
|
+
ws.close()
|
|
595
|
+
except Exception:
|
|
596
|
+
pass
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# --- Commands -------------------------------------------------------------------
|
|
600
|
+
|
|
601
|
+
def _connected_result(state: str, fields: dict, source: "str | None" = None,
|
|
602
|
+
note: "str | None" = None, attempts: "list | None" = None) -> dict:
|
|
603
|
+
out = {
|
|
604
|
+
"ok": True,
|
|
605
|
+
"connected": True,
|
|
606
|
+
"state": state,
|
|
607
|
+
"username": fields.get("username"),
|
|
608
|
+
"account_age_days": fields.get("account_age_days"),
|
|
609
|
+
"comment_karma": fields.get("comment_karma"),
|
|
610
|
+
"total_karma": fields.get("total_karma"),
|
|
611
|
+
"warning": fields.get("warning"),
|
|
612
|
+
"cdp": CDP,
|
|
613
|
+
}
|
|
614
|
+
if source:
|
|
615
|
+
out["source"] = source
|
|
616
|
+
if note:
|
|
617
|
+
out["note"] = note
|
|
618
|
+
if attempts:
|
|
619
|
+
out["attempts"] = attempts
|
|
620
|
+
return out
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _posting_drain_active() -> bool:
|
|
624
|
+
"""True while a poster is mid-drain (fresh reddit-posting-active.json).
|
|
625
|
+
status must not grab the shared harness tab out from under it, and a live
|
|
626
|
+
drain is itself proof of a working session."""
|
|
627
|
+
flag = os.path.join(
|
|
628
|
+
os.path.expanduser(os.environ.get("S4L_STATE_DIR") or "~/.social-autoposter-mcp"),
|
|
629
|
+
"reddit-posting-active.json",
|
|
630
|
+
)
|
|
631
|
+
try:
|
|
632
|
+
return (time.time() - os.path.getmtime(flag)) < 120
|
|
633
|
+
except OSError:
|
|
634
|
+
return False
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def cmd_status(args) -> dict:
|
|
638
|
+
# An active posting drain owns the one shared harness tab; don't navigate it
|
|
639
|
+
# (and don't sit in reddit_browser_fetch's 5-minute yield inside a status
|
|
640
|
+
# probe). The drain posting right now IS a valid session.
|
|
641
|
+
if _posting_drain_active():
|
|
642
|
+
return {
|
|
643
|
+
"ok": True,
|
|
644
|
+
"connected": True,
|
|
645
|
+
"state": "connected_idle",
|
|
646
|
+
"username": _configured_username(),
|
|
647
|
+
"note": "Reddit posting is actively draining right now (which requires a "
|
|
648
|
+
"valid session); skipped the live me.json probe to leave the browser tab "
|
|
649
|
+
"to the poster.",
|
|
650
|
+
"cdp": CDP,
|
|
651
|
+
}
|
|
652
|
+
if not ensure_chrome(launch=False):
|
|
653
|
+
# Harness down. The persistent profile IS the durability layer for
|
|
654
|
+
# reddit (the pipeline relaunches this exact profile), so trust an
|
|
655
|
+
# on-disk session cookie row instead of demanding a live browser.
|
|
656
|
+
if _profile_dir_has_session():
|
|
657
|
+
return {
|
|
658
|
+
"ok": True,
|
|
659
|
+
"connected": True,
|
|
660
|
+
"state": "connected_idle",
|
|
661
|
+
"username": _configured_username(),
|
|
662
|
+
"note": "Reddit is connected (session saved in the harness profile). "
|
|
663
|
+
"The reddit browser isn't running this moment; the next pipeline "
|
|
664
|
+
"run relaunches it with the same profile.",
|
|
665
|
+
"cdp": CDP,
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
"ok": True,
|
|
669
|
+
"connected": False,
|
|
670
|
+
"state": "browser_not_running",
|
|
671
|
+
"username": None,
|
|
672
|
+
"note": "The autoposter's reddit browser isn't running yet. Run "
|
|
673
|
+
"connect_reddit to start it and check/import your session.",
|
|
674
|
+
"cdp": CDP,
|
|
675
|
+
}
|
|
676
|
+
# Live browser is up: read-only reddit_session cookie check. Deliberately
|
|
677
|
+
# NOT the me.json validation: status is polled by the snapshot/menubar and
|
|
678
|
+
# the kicker gate, and a me.json fetch navigates the ONE shared harness tab
|
|
679
|
+
# (fighting discovery/posting). The authoritative me.json validation runs
|
|
680
|
+
# inside `connect` (already-valid short-circuit, post-import, post-login).
|
|
681
|
+
# A revoked-but-present cookie can false-positive here; the next connect or
|
|
682
|
+
# pipeline run surfaces that loudly.
|
|
683
|
+
try:
|
|
684
|
+
quick = _has_session_quick()
|
|
685
|
+
except Exception as e:
|
|
686
|
+
return {"ok": False, "connected": False, "state": "error",
|
|
687
|
+
"username": None, "error": str(e), "cdp": CDP}
|
|
688
|
+
if quick:
|
|
689
|
+
return {
|
|
690
|
+
"ok": True,
|
|
691
|
+
"connected": True,
|
|
692
|
+
"state": "connected",
|
|
693
|
+
"username": _configured_username(),
|
|
694
|
+
"cdp": CDP,
|
|
695
|
+
}
|
|
696
|
+
if _profile_dir_has_session():
|
|
697
|
+
return {
|
|
698
|
+
"ok": True,
|
|
699
|
+
"connected": True,
|
|
700
|
+
"state": "connected_idle",
|
|
701
|
+
"username": _configured_username(),
|
|
702
|
+
"note": "A reddit session exists in the harness profile on disk; the "
|
|
703
|
+
"live browser has not loaded it into memory yet.",
|
|
704
|
+
"cdp": CDP,
|
|
705
|
+
}
|
|
706
|
+
return {"ok": True, "connected": False, "state": "logged_out",
|
|
707
|
+
"username": None, "cdp": CDP}
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def cmd_connect(args) -> dict:
|
|
711
|
+
if not ensure_chrome(launch=not args.no_launch):
|
|
712
|
+
return {
|
|
713
|
+
"ok": False,
|
|
714
|
+
"connected": False,
|
|
715
|
+
"state": "browser_launch_failed",
|
|
716
|
+
"error": "Could not start the managed reddit Chrome (no Chrome/Chromium "
|
|
717
|
+
"found, or it failed to bind the debug port). Set BH_CHROME_BIN to your "
|
|
718
|
+
"Chrome path.",
|
|
719
|
+
"cdp": CDP,
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
# 1. Already logged in? Nothing to import.
|
|
723
|
+
fields = _validate_session()
|
|
724
|
+
if fields is not None and fields.get("suspended"):
|
|
725
|
+
return {
|
|
726
|
+
"ok": True,
|
|
727
|
+
"connected": False,
|
|
728
|
+
"state": "suspended",
|
|
729
|
+
"username": fields.get("username"),
|
|
730
|
+
"note": "This reddit account is suspended; posting is not possible.",
|
|
731
|
+
"cdp": CDP,
|
|
732
|
+
}
|
|
733
|
+
if fields is not None:
|
|
734
|
+
return _connected_result(
|
|
735
|
+
"connected", fields, source="existing_session",
|
|
736
|
+
note="Reddit is already connected in the autoposter's reddit browser; "
|
|
737
|
+
"nothing imported.")
|
|
738
|
+
|
|
739
|
+
# 1b. Choose the path (import vs manual login) before any keychain work.
|
|
740
|
+
if args.source == "all":
|
|
741
|
+
sources = ["chrome:Default", "arc:Default", "brave:Default", "edge:Default"]
|
|
742
|
+
has_importable = True
|
|
743
|
+
elif args.source:
|
|
744
|
+
sources = [args.source]
|
|
745
|
+
has_importable = True
|
|
746
|
+
else:
|
|
747
|
+
_srcs = _list_sources()
|
|
748
|
+
_with_session = [s["spec"] for s in _srcs if s.get("reddit_session")]
|
|
749
|
+
sources = _with_session or ["chrome:Default"]
|
|
750
|
+
has_importable = bool(_with_session)
|
|
751
|
+
|
|
752
|
+
manual_login = bool(getattr(args, "manual_login", False))
|
|
753
|
+
will_import = has_importable and not manual_login
|
|
754
|
+
|
|
755
|
+
# 1c. Headless keychain preflight, import path only (manual login reads no
|
|
756
|
+
# keychain and must never be blocked by this).
|
|
757
|
+
if will_import and _is_headless():
|
|
758
|
+
kc_ok, kc_detail = _keychain_safe_storage_ok("Chrome")
|
|
759
|
+
if not kc_ok:
|
|
760
|
+
return {
|
|
761
|
+
"ok": True,
|
|
762
|
+
"connected": False,
|
|
763
|
+
"state": "keychain_locked",
|
|
764
|
+
"error_type": "keychain_locked",
|
|
765
|
+
"headless": True,
|
|
766
|
+
"keychain_detail": kc_detail,
|
|
767
|
+
"note": (
|
|
768
|
+
"Cookie import requires reading the browser's Safe Storage from "
|
|
769
|
+
"the macOS Keychain, but this process can't access it (probably "
|
|
770
|
+
"running over SSH or another headless context). Run this once in "
|
|
771
|
+
"the same session:\n"
|
|
772
|
+
" security unlock-keychain ~/Library/Keychains/login.keychain-db\n"
|
|
773
|
+
"Then re-run connect_reddit. Or connect Reddit with manual login "
|
|
774
|
+
"instead; that signs in directly in the autoposter's own browser "
|
|
775
|
+
"and needs no keychain."
|
|
776
|
+
),
|
|
777
|
+
"remediation_cmd": "security unlock-keychain ~/Library/Keychains/login.keychain-db",
|
|
778
|
+
"cdp": CDP,
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
# 2. Import from the user's everyday browser.
|
|
782
|
+
attempts = []
|
|
783
|
+
for src in (sources if will_import else []):
|
|
784
|
+
res = _import_from(src)
|
|
785
|
+
copied = res.get("stdout", "")
|
|
786
|
+
detail = copied or res.get("error") or res.get("stderr")
|
|
787
|
+
error_type = None if res.get("ok") else _classify_import_error(detail)
|
|
788
|
+
attempts.append({
|
|
789
|
+
"source": src,
|
|
790
|
+
"ok": res.get("ok"),
|
|
791
|
+
"detail": detail,
|
|
792
|
+
"error_type": error_type,
|
|
793
|
+
})
|
|
794
|
+
if not res.get("ok"):
|
|
795
|
+
continue
|
|
796
|
+
fields = _validate_session()
|
|
797
|
+
if fields is not None and not fields.get("suspended"):
|
|
798
|
+
return _connected_result(
|
|
799
|
+
"imported", fields, source=src, attempts=attempts,
|
|
800
|
+
note=f"Imported your Reddit session from {src} into the autoposter's "
|
|
801
|
+
"reddit browser. The session persists in the harness profile on "
|
|
802
|
+
"disk, so pipeline relaunches inherit it.")
|
|
803
|
+
if fields is not None and fields.get("suspended"):
|
|
804
|
+
return {
|
|
805
|
+
"ok": True,
|
|
806
|
+
"connected": False,
|
|
807
|
+
"state": "suspended",
|
|
808
|
+
"username": fields.get("username"),
|
|
809
|
+
"attempts": attempts,
|
|
810
|
+
"note": "The imported reddit account is suspended; posting is not possible.",
|
|
811
|
+
"cdp": CDP,
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
# 3. Could not establish a valid session automatically.
|
|
815
|
+
distinct_error_types = {a.get("error_type") for a in attempts if a.get("error_type")}
|
|
816
|
+
rolled_up_error_type = (
|
|
817
|
+
next(iter(distinct_error_types)) if len(distinct_error_types) == 1 else None
|
|
818
|
+
)
|
|
819
|
+
open_login = (
|
|
820
|
+
manual_login
|
|
821
|
+
or not will_import
|
|
822
|
+
or rolled_up_error_type == "keychain_acl_denied"
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
shown = False
|
|
826
|
+
if open_login:
|
|
827
|
+
shown = _show_window_and_open_login()
|
|
828
|
+
login_wait = getattr(args, "login_wait", 300.0)
|
|
829
|
+
if login_wait and login_wait > 0 and _poll_for_login(timeout=login_wait):
|
|
830
|
+
fields = _validate_session()
|
|
831
|
+
if fields is not None and not fields.get("suspended"):
|
|
832
|
+
return _connected_result(
|
|
833
|
+
"connected", fields, source="manual_login", attempts=attempts,
|
|
834
|
+
note="You logged in manually; the autoposter detected the live "
|
|
835
|
+
"reddit session in its own browser profile.")
|
|
836
|
+
|
|
837
|
+
if rolled_up_error_type == "keychain_acl_denied":
|
|
838
|
+
note = (
|
|
839
|
+
"It looks like you clicked Deny (or Cancel) on the macOS Keychain prompt. "
|
|
840
|
+
"To import your Reddit session automatically, the autoposter needs to read "
|
|
841
|
+
"your browser's \"Safe Storage\" key from your Keychain. Re-run "
|
|
842
|
+
"connect_reddit and click Allow (or Always Allow) and the import will "
|
|
843
|
+
"finish on its own. If you'd rather not grant keychain access, there's "
|
|
844
|
+
"already a Chrome window open at the Reddit login page"
|
|
845
|
+
+ ("" if shown else " (look for a 'Google Chrome' window)")
|
|
846
|
+
+ "; just log in there by hand and ask me to re-check. "
|
|
847
|
+
"(Auto-import tried: " + ", ".join(sources) + ".)"
|
|
848
|
+
)
|
|
849
|
+
extra = {"remediation": "rerun_connect_reddit_and_click_allow"}
|
|
850
|
+
elif open_login:
|
|
851
|
+
_why = (
|
|
852
|
+
"" if will_import
|
|
853
|
+
else "No existing Reddit session was found in your everyday browser to import, so "
|
|
854
|
+
)
|
|
855
|
+
_tried = (" (Auto-import tried: " + ", ".join(sources) + ".)") if will_import else ""
|
|
856
|
+
note = (
|
|
857
|
+
_why
|
|
858
|
+
+ "a Chrome window for the autoposter is open at the Reddit login page"
|
|
859
|
+
+ ("" if shown else " (if you don't see it, look for a 'Google Chrome' window)")
|
|
860
|
+
+ " and you are NOT logged in yet. Log in there yourself (username, password, "
|
|
861
|
+
"and 2FA if prompted) in that window. When your Reddit home feed shows, ask me "
|
|
862
|
+
"to confirm and I'll re-check (run connect_reddit again). The session is saved "
|
|
863
|
+
"to the autoposter's own profile, so this is a one-time step." + _tried
|
|
864
|
+
)
|
|
865
|
+
extra = {}
|
|
866
|
+
else:
|
|
867
|
+
note = (
|
|
868
|
+
"Couldn't import a Reddit session automatically (auto-import tried: "
|
|
869
|
+
+ ", ".join(sources) + "). This usually means you're not logged into Reddit "
|
|
870
|
+
"in your everyday browser, so there was no session to copy. I did NOT open "
|
|
871
|
+
"a login window. If you want to sign in by hand, ask me to connect Reddit "
|
|
872
|
+
"with manual login and I'll open a focused Reddit login page for you."
|
|
873
|
+
)
|
|
874
|
+
extra = {"manual_login_hint": "rerun_connect_reddit_with_manual_login"}
|
|
875
|
+
return {
|
|
876
|
+
"ok": True,
|
|
877
|
+
"connected": False,
|
|
878
|
+
"state": "needs_login",
|
|
879
|
+
"username": None,
|
|
880
|
+
"error_type": rolled_up_error_type,
|
|
881
|
+
"attempts": attempts,
|
|
882
|
+
"login_window_opened": shown,
|
|
883
|
+
"note": note,
|
|
884
|
+
"profile_dir": str(PROFILE_DIR),
|
|
885
|
+
"cdp": CDP,
|
|
886
|
+
**extra,
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def main() -> int:
|
|
891
|
+
ap = argparse.ArgumentParser(description="Reddit session bootstrap for MCP setup.")
|
|
892
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
893
|
+
sub.add_parser("status", help="Report whether the managed reddit session is valid.")
|
|
894
|
+
sub.add_parser("detect-sources",
|
|
895
|
+
help="List browsers/profiles to import the Reddit session from "
|
|
896
|
+
"(JSON). No keychain prompt.")
|
|
897
|
+
c = sub.add_parser("connect", help="Ensure browser + import/validate the Reddit session.")
|
|
898
|
+
c.add_argument("--source", default=None,
|
|
899
|
+
help="Browser profile to import from (e.g. chrome:Default, arc:Default), "
|
|
900
|
+
"or 'all' for the full sweep. Default: auto-pick the browser that "
|
|
901
|
+
"actually holds a reddit.com session.")
|
|
902
|
+
c.add_argument("--no-launch", action="store_true",
|
|
903
|
+
help="Do not launch Chrome if it's down (probe only).")
|
|
904
|
+
c.add_argument("--manual-login", action="store_true",
|
|
905
|
+
help="Explicitly opt into manual login: open a focused Reddit login "
|
|
906
|
+
"window and wait for the user to sign in by hand.")
|
|
907
|
+
c.add_argument("--login-wait", type=float, default=300.0,
|
|
908
|
+
help="Seconds to wait for a MANUAL login to complete before "
|
|
909
|
+
"returning needs_login (default 300; 0 disables the wait).")
|
|
910
|
+
args = ap.parse_args()
|
|
911
|
+
|
|
912
|
+
if args.cmd == "detect-sources":
|
|
913
|
+
out = cmd_detect_sources(args)
|
|
914
|
+
elif _WEBSOCKET_IMPORT_ERROR is not None and args.cmd != "status":
|
|
915
|
+
out = {"ok": False, "state": "error", "error": _WEBSOCKET_IMPORT_ERROR}
|
|
916
|
+
elif args.cmd == "status":
|
|
917
|
+
out = cmd_status(args)
|
|
918
|
+
else:
|
|
919
|
+
out = cmd_connect(args)
|
|
920
|
+
print(json.dumps(out, indent=2))
|
|
921
|
+
return 0
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
if __name__ == "__main__":
|
|
925
|
+
sys.exit(main())
|