@m13v/s4l 1.7.6-rc.2 → 1.7.6-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 +256 -92
- package/mcp/dist/telemetry.js +2 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +144 -18
- package/mcp/menubar/s4l_card_canvas.py +135 -14
- package/mcp/menubar/s4l_menubar.py +216 -73
- package/mcp/menubar/s4l_state.py +45 -130
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +35 -1
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/draft_prompt_core.py +100 -17
- package/scripts/engagement_styles.py +135 -98
- package/scripts/merge_review_queue.py +192 -129
- package/scripts/platform_strike_events.py +54 -5
- package/scripts/post_reddit.py +10 -0
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/reap_stale_claude_sessions.py +7 -6
- package/scripts/reddit_ban_check.py +220 -0
- package/scripts/reddit_browser.py +24 -0
- package/scripts/release-mcpb.sh +38 -7
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/store_patch.py +111 -0
- package/scripts/strike_alert.py +161 -12
- package/scripts/test_no_silent_fallbacks.py +11 -0
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/skill/archive-old-logs.sh +24 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-twitter-cycle.sh +146 -66
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic Reddit community-ban detection.
|
|
3
|
+
|
|
4
|
+
When a post is discovered removed, "was that one post moderated, or is our
|
|
5
|
+
account BANNED from the whole community?" is not answerable from the removal
|
|
6
|
+
object itself: a mod-removed comment scrubs to author=[deleted]/body=[removed]
|
|
7
|
+
with no reason, and a ban does NOT retroactively delete our older comments, so
|
|
8
|
+
"our comments are still visible there" is NOT evidence we are un-banned.
|
|
9
|
+
|
|
10
|
+
The authoritative signal is the logged-in view of `/r/<sub>/about.json`, whose
|
|
11
|
+
`user_is_banned` field is populated per the requesting account's session. We
|
|
12
|
+
already hold a logged-in Reddit browser (the reddit-agent / reddit-harness
|
|
13
|
+
Chrome); this module CDP-attaches to it read-only (one navigation, N same-origin
|
|
14
|
+
fetches, no clicks/posts) and reads that field. Mirrors the read-only carve-out
|
|
15
|
+
pattern used elsewhere (linkedin_browser sidebar pre-check).
|
|
16
|
+
|
|
17
|
+
Graceful degradation: if no Reddit session is reachable (customer install with
|
|
18
|
+
no browser, wrong account, session logged out), every checker returns None and
|
|
19
|
+
callers MUST treat None as "unknown, do nothing" — never as "not banned".
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
python3 scripts/reddit_ban_check.py ClaudeAI theravada rust # ad-hoc
|
|
23
|
+
python3 scripts/reddit_ban_check.py --record --project podlog r/X # detect+persist
|
|
24
|
+
|
|
25
|
+
Public API:
|
|
26
|
+
banned_state(subs) -> {sub_lower: True|False|None}
|
|
27
|
+
is_banned(sub) -> True|False|None
|
|
28
|
+
record_confirmed_bans(subs, project=None)
|
|
29
|
+
-> {"banned": [...], "recorded": [...], "upgraded": [...], "unknown": [...]}
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
40
|
+
|
|
41
|
+
# about.json JS: fetch each sub's logged-in metadata, return the ban flags.
|
|
42
|
+
_ABOUT_JS = r"""
|
|
43
|
+
async (subs) => {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const s of subs) {
|
|
46
|
+
try {
|
|
47
|
+
const r = await fetch(`https://old.reddit.com/r/${s}/about.json`,
|
|
48
|
+
{headers:{'Accept':'application/json'}, credentials:'include'});
|
|
49
|
+
if (!r.ok) { out[s] = {http: r.status}; continue; }
|
|
50
|
+
const d = (await r.json()).data || {};
|
|
51
|
+
out[s] = { banned: d.user_is_banned === true,
|
|
52
|
+
muted: d.user_is_muted === true,
|
|
53
|
+
has_flag: ('user_is_banned' in d) };
|
|
54
|
+
} catch (e) { out[s] = {err: String(e).slice(0,80)}; }
|
|
55
|
+
await new Promise(x => setTimeout(x, 250));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _norm(sub: str) -> str:
|
|
63
|
+
s = (sub or "").strip()
|
|
64
|
+
m = re.search(r"/r/([^/]+)", s)
|
|
65
|
+
if m:
|
|
66
|
+
s = m.group(1)
|
|
67
|
+
return s.strip().strip("/").lower()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reddit_page(pw):
|
|
71
|
+
"""Attach to the running logged-in Reddit Chrome and return (browser, page).
|
|
72
|
+
|
|
73
|
+
Prefers REDDIT_CDP_URL, else discovers the reddit-agent port via
|
|
74
|
+
reddit_browser.find_reddit_cdp_port. Opens a DEDICATED page so we never
|
|
75
|
+
fight the live harness tab. Returns (None, None) if unreachable."""
|
|
76
|
+
import urllib.request
|
|
77
|
+
|
|
78
|
+
ws = None
|
|
79
|
+
cdp_url = (os.environ.get("REDDIT_CDP_URL") or "").strip()
|
|
80
|
+
if cdp_url:
|
|
81
|
+
ws = cdp_url
|
|
82
|
+
else:
|
|
83
|
+
try:
|
|
84
|
+
from reddit_browser import find_reddit_cdp_port
|
|
85
|
+
port = find_reddit_cdp_port()
|
|
86
|
+
if not port:
|
|
87
|
+
return None, None
|
|
88
|
+
with urllib.request.urlopen(
|
|
89
|
+
f"http://127.0.0.1:{port}/json/version", timeout=3
|
|
90
|
+
) as r:
|
|
91
|
+
ws = json.load(r)["webSocketDebuggerUrl"]
|
|
92
|
+
except Exception:
|
|
93
|
+
return None, None
|
|
94
|
+
try:
|
|
95
|
+
browser = pw.chromium.connect_over_cdp(ws)
|
|
96
|
+
ctx = browser.contexts[0] if browser.contexts else browser.new_context()
|
|
97
|
+
page = ctx.new_page()
|
|
98
|
+
page.set_default_timeout(20000)
|
|
99
|
+
return browser, page
|
|
100
|
+
except Exception:
|
|
101
|
+
return None, None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def banned_state(subs) -> dict:
|
|
105
|
+
"""Return {sub_lower: True|False|None} for each sub. None = unknown
|
|
106
|
+
(no session / fetch failed): callers MUST NOT treat None as un-banned."""
|
|
107
|
+
norm = []
|
|
108
|
+
for s in subs:
|
|
109
|
+
n = _norm(s)
|
|
110
|
+
if n and n not in norm:
|
|
111
|
+
norm.append(n)
|
|
112
|
+
result = {n: None for n in norm}
|
|
113
|
+
if not norm:
|
|
114
|
+
return result
|
|
115
|
+
try:
|
|
116
|
+
from playwright.sync_api import sync_playwright
|
|
117
|
+
except Exception:
|
|
118
|
+
return result
|
|
119
|
+
with sync_playwright() as pw:
|
|
120
|
+
browser, page = _reddit_page(pw)
|
|
121
|
+
if page is None:
|
|
122
|
+
return result
|
|
123
|
+
try:
|
|
124
|
+
page.goto("https://old.reddit.com/", wait_until="domcontentloaded",
|
|
125
|
+
timeout=20000)
|
|
126
|
+
# confirm we actually have a logged-in session; a logged-out view
|
|
127
|
+
# reports user_is_banned=false for everything, a false negative.
|
|
128
|
+
me = page.evaluate(
|
|
129
|
+
"async () => { try { const r = await fetch("
|
|
130
|
+
"'https://old.reddit.com/api/me.json', {credentials:'include'}); "
|
|
131
|
+
"const d = await r.json(); return (d.data && d.data.name) || null; } "
|
|
132
|
+
"catch(e){ return null; } }"
|
|
133
|
+
)
|
|
134
|
+
if not me:
|
|
135
|
+
return result # not logged in -> all unknown
|
|
136
|
+
raw = page.evaluate(_ABOUT_JS, norm)
|
|
137
|
+
except Exception:
|
|
138
|
+
return result
|
|
139
|
+
finally:
|
|
140
|
+
try:
|
|
141
|
+
page.close()
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
for n in norm:
|
|
145
|
+
v = (raw or {}).get(n) or {}
|
|
146
|
+
if v.get("has_flag"):
|
|
147
|
+
result[n] = bool(v.get("banned"))
|
|
148
|
+
else:
|
|
149
|
+
result[n] = None # http error / missing field -> unknown
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_banned(sub) -> "bool|None":
|
|
154
|
+
return banned_state([sub]).get(_norm(sub))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def record_confirmed_bans(subs, project=None) -> dict:
|
|
158
|
+
"""Check each sub and persist newly-confirmed community bans into
|
|
159
|
+
config.json subreddit_bans.comment_blocked with reason
|
|
160
|
+
'account_blocked_in_sub' (the reason platform_strike_events reads to emit
|
|
161
|
+
platform_banned learning events). Upgrades pre-existing weak entries
|
|
162
|
+
(reason=None) to the confirmed reason. Never records on None (unknown)."""
|
|
163
|
+
from config import config_path
|
|
164
|
+
from post_reddit import _make_ban_entry, _ban_entry_sub
|
|
165
|
+
|
|
166
|
+
state = banned_state(subs)
|
|
167
|
+
banned = [s for s, v in state.items() if v is True]
|
|
168
|
+
unknown = [s for s, v in state.items() if v is None]
|
|
169
|
+
recorded, upgraded = [], []
|
|
170
|
+
if not banned:
|
|
171
|
+
return {"banned": [], "recorded": [], "upgraded": [], "unknown": unknown}
|
|
172
|
+
|
|
173
|
+
path = config_path()
|
|
174
|
+
with open(path) as f:
|
|
175
|
+
cfg = json.load(f)
|
|
176
|
+
bans = cfg.setdefault("subreddit_bans", {})
|
|
177
|
+
blocked = bans.setdefault("comment_blocked", [])
|
|
178
|
+
by_sub = {(_ban_entry_sub(e) or "").lower(): e for e in blocked}
|
|
179
|
+
|
|
180
|
+
for sub in banned:
|
|
181
|
+
entry = by_sub.get(sub)
|
|
182
|
+
if entry is None:
|
|
183
|
+
blocked.append(_make_ban_entry(sub, "account_blocked_in_sub", project))
|
|
184
|
+
recorded.append(sub)
|
|
185
|
+
elif entry.get("reason") != "account_blocked_in_sub":
|
|
186
|
+
# upgrade a weak/manual entry so the digest ban event can fire
|
|
187
|
+
from datetime import datetime, timezone
|
|
188
|
+
entry["reason"] = "account_blocked_in_sub"
|
|
189
|
+
if not entry.get("added_at"):
|
|
190
|
+
entry["added_at"] = datetime.now(timezone.utc).strftime(
|
|
191
|
+
"%Y-%m-%dT%H:%M:%SZ")
|
|
192
|
+
entry.setdefault("noticed_by_project", project)
|
|
193
|
+
upgraded.append(sub)
|
|
194
|
+
|
|
195
|
+
if recorded or upgraded:
|
|
196
|
+
blocked.sort(key=lambda e: (_ban_entry_sub(e) or ""))
|
|
197
|
+
with open(path, "w") as f:
|
|
198
|
+
json.dump(cfg, f, indent=2)
|
|
199
|
+
f.write("\n")
|
|
200
|
+
return {"banned": banned, "recorded": recorded, "upgraded": upgraded,
|
|
201
|
+
"unknown": unknown}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main() -> int:
|
|
205
|
+
import argparse
|
|
206
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
207
|
+
ap.add_argument("subs", nargs="+", help="subreddit names or /r/x URLs")
|
|
208
|
+
ap.add_argument("--record", action="store_true",
|
|
209
|
+
help="persist confirmed bans to config.json")
|
|
210
|
+
ap.add_argument("--project", default=None, help="audit breadcrumb")
|
|
211
|
+
args = ap.parse_args()
|
|
212
|
+
if args.record:
|
|
213
|
+
print(json.dumps(record_confirmed_bans(args.subs, args.project), indent=2))
|
|
214
|
+
else:
|
|
215
|
+
print(json.dumps(banned_state(args.subs), indent=2))
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
sys.exit(main())
|
|
@@ -341,6 +341,29 @@ def _touch_browser_heartbeat(*_args):
|
|
|
341
341
|
pass
|
|
342
342
|
|
|
343
343
|
|
|
344
|
+
def _register_reddit_park_on_exit(cdp_base=""):
|
|
345
|
+
"""Shared tab lifecycle (2026-07-17 unification): park reddit tabs on
|
|
346
|
+
reddit's own /robots.txt at process exit — the SAME single implementation
|
|
347
|
+
twitter_browser uses (scripts/browser_lifecycle.py). Static page = no SPA
|
|
348
|
+
to leak/crash while idle, URL still matches the reddit.com reuse
|
|
349
|
+
preference above so no new tabs (Target.createTarget is the proven
|
|
350
|
+
focus-steal trigger). S4L_NO_TAB_PARK=1 escape hatch inside the module.
|
|
351
|
+
Best-effort: parking must never fail an attach."""
|
|
352
|
+
try:
|
|
353
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
354
|
+
from browser_lifecycle import register_park_on_exit
|
|
355
|
+
|
|
356
|
+
register_park_on_exit(
|
|
357
|
+
(cdp_base or os.environ.get("REDDIT_CDP_URL", "").strip()
|
|
358
|
+
or "http://127.0.0.1:9557").rstrip("/"),
|
|
359
|
+
("reddit.com",),
|
|
360
|
+
"https://www.reddit.com/robots.txt",
|
|
361
|
+
"reddit_browser",
|
|
362
|
+
)
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
|
|
366
|
+
|
|
344
367
|
def get_browser_and_page(playwright):
|
|
345
368
|
"""Instrumented entry point: attach via _get_browser_and_page_raw, then
|
|
346
369
|
(L1) set default per-op/navigation timeouts so no page call can hang
|
|
@@ -395,6 +418,7 @@ def _get_browser_and_page_raw(playwright):
|
|
|
395
418
|
try:
|
|
396
419
|
cdp_browser = playwright.chromium.connect_over_cdp(cdp_url_env)
|
|
397
420
|
_bh_activity_log("attach_harness", cdp_url_env)
|
|
421
|
+
_register_reddit_park_on_exit(cdp_url_env)
|
|
398
422
|
# Prefer a context that already carries a live reddit_session; else
|
|
399
423
|
# fall back to the first context (harness is single-profile, logged in).
|
|
400
424
|
chosen = None
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -384,6 +384,28 @@ NODE_RUNTIME_PLATFORM="darwin-arm64"
|
|
|
384
384
|
NODE_VENDOR_DIR="$MCP_DIR/vendor/node-$NODE_RUNTIME_PLATFORM"
|
|
385
385
|
NODE_VENDOR_BIN="$NODE_VENDOR_DIR/bin/node"
|
|
386
386
|
NODE_CACHE_DIR="$REPO_ROOT/.cache/node-runtime"
|
|
387
|
+
# Can THIS build host execute the vendored target binary? The .mcpb always
|
|
388
|
+
# vendors darwin-arm64 (that is where it runs), but the release itself is cut
|
|
389
|
+
# from CI on linux-x64. Executing a darwin-arm64 node on a linux-x64 runner is
|
|
390
|
+
# an `Exec format error` (exit 126) -- what broke every rc from 1.7.6-rc.11 on.
|
|
391
|
+
# Run the exec-based `--version` sanity checks ONLY when the host os/arch matches
|
|
392
|
+
# the vendored target; otherwise verify by presence + executable bit, which is
|
|
393
|
+
# all a cross-build can honestly assert.
|
|
394
|
+
case "$(uname -s)" in
|
|
395
|
+
Darwin) HOST_OS="darwin" ;;
|
|
396
|
+
Linux) HOST_OS="linux" ;;
|
|
397
|
+
*) HOST_OS="unknown" ;;
|
|
398
|
+
esac
|
|
399
|
+
case "$(uname -m)" in
|
|
400
|
+
arm64|aarch64) HOST_ARCH="arm64" ;;
|
|
401
|
+
x86_64|amd64) HOST_ARCH="x64" ;;
|
|
402
|
+
*) HOST_ARCH="unknown" ;;
|
|
403
|
+
esac
|
|
404
|
+
if [[ "$HOST_OS-$HOST_ARCH" == "$NODE_RUNTIME_PLATFORM" ]]; then
|
|
405
|
+
HOST_CAN_EXEC_VENDORED_NODE=1
|
|
406
|
+
else
|
|
407
|
+
HOST_CAN_EXEC_VENDORED_NODE=0
|
|
408
|
+
fi
|
|
387
409
|
say "Vendoring Node $NODE_RUNTIME_VERSION ($NODE_RUNTIME_PLATFORM) for the .mcpb"
|
|
388
410
|
if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
|
|
389
411
|
NODE_TARBALL="node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM.tar.gz"
|
|
@@ -406,9 +428,13 @@ if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
|
|
|
406
428
|
chmod +x "$NODE_VENDOR_BIN"
|
|
407
429
|
fi
|
|
408
430
|
[[ -x "$NODE_VENDOR_BIN" ]] || die "vendored node binary missing/not executable at $NODE_VENDOR_BIN"
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
431
|
+
if [[ "$HOST_CAN_EXEC_VENDORED_NODE" == "1" ]]; then
|
|
432
|
+
VENDORED_NODE_VER="$("$NODE_VENDOR_BIN" --version)"
|
|
433
|
+
[[ "$VENDORED_NODE_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node reports $VENDORED_NODE_VER, expected $NODE_RUNTIME_VERSION"
|
|
434
|
+
echo " vendored node: $VENDORED_NODE_VER at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1))"
|
|
435
|
+
else
|
|
436
|
+
echo " vendored node: present at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1)); --version self-check skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
|
|
437
|
+
fi
|
|
412
438
|
|
|
413
439
|
# ---- 4. Pack the .mcpb ------------------------------------------------------
|
|
414
440
|
say "Packing $BUNDLE"
|
|
@@ -474,10 +500,15 @@ VENDOR_CHECK_DIR="$(mktemp -d)"
|
|
|
474
500
|
unzip -q -o "$BUNDLE" "vendor/node-$NODE_RUNTIME_PLATFORM/bin/node" -d "$VENDOR_CHECK_DIR"
|
|
475
501
|
VENDOR_CHECK_BIN="$VENDOR_CHECK_DIR/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node"
|
|
476
502
|
[[ -x "$VENDOR_CHECK_BIN" ]] || die "vendored node in bundle lost its executable bit"
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
503
|
+
if [[ "$HOST_CAN_EXEC_VENDORED_NODE" == "1" ]]; then
|
|
504
|
+
VENDOR_CHECK_VER="$("$VENDOR_CHECK_BIN" --version)"
|
|
505
|
+
[[ "$VENDOR_CHECK_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node in bundle reports $VENDOR_CHECK_VER, expected $NODE_RUNTIME_VERSION"
|
|
506
|
+
rm -rf "$VENDOR_CHECK_DIR"
|
|
507
|
+
echo " vendored node runs from bundle: $VENDOR_CHECK_VER ok"
|
|
508
|
+
else
|
|
509
|
+
rm -rf "$VENDOR_CHECK_DIR"
|
|
510
|
+
echo " vendored node survives bundle round-trip with exec bit intact; --version run skipped (host $HOST_OS-$HOST_ARCH cannot exec $NODE_RUNTIME_PLATFORM)"
|
|
511
|
+
fi
|
|
481
512
|
|
|
482
513
|
if [[ "$DO_RELEASE" == "0" ]]; then
|
|
483
514
|
say "Done (--no-release). Bundle ready at: $BUNDLE"
|
|
@@ -61,14 +61,8 @@ except ImportError:
|
|
|
61
61
|
"websocket-client not installed (needed for CDP). pip install websocket-client"
|
|
62
62
|
)
|
|
63
63
|
|
|
64
|
-
#
|
|
65
|
-
# @handle alongside the locally-mirrored cookies. Guarded so a missing dep never
|
|
66
|
-
# breaks setup.
|
|
64
|
+
# Make scripts-dir siblings (twitter_cookie_mirror, etc.) importable during setup.
|
|
67
65
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
68
|
-
try:
|
|
69
|
-
from twitter_account import resolve_handle # noqa: E402
|
|
70
|
-
except Exception:
|
|
71
|
-
resolve_handle = None
|
|
72
66
|
|
|
73
67
|
# Local 0600 cookie mirror — the keychain-independent durability layer (Gap B).
|
|
74
68
|
# Always importable (stdlib only); guarded so a path quirk never breaks setup.
|
|
@@ -345,12 +339,12 @@ _HANDLE_PLACEHOLDERS = {"", "your-twitter-handle", "@your-twitter-handle"}
|
|
|
345
339
|
def _resolve_live_handle(send) -> "str | None":
|
|
346
340
|
"""Read the logged-in @handle from the LIVE x.com session.
|
|
347
341
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
every post
|
|
342
|
+
On a fresh install config.json carries no handle yet, so this reads the actual
|
|
343
|
+
logged-in handle from the SAME authenticated session connect_x just validated
|
|
344
|
+
and stamps it on the durable cookie mirror (see _persist_session). That mirror
|
|
345
|
+
is what account_resolver.resolve('twitter') falls back to, so attribution is
|
|
346
|
+
ground truth instead of a hardcoded handle that would silently mis-attribute
|
|
347
|
+
every post. Two methods, most reliable first:
|
|
354
348
|
|
|
355
349
|
1. X's own account/settings.json (canonical `screen_name`). The web client
|
|
356
350
|
calls this on every load; it is stable across DOM redesigns, unlike the
|
|
@@ -405,41 +399,6 @@ def _resolve_live_handle(send) -> "str | None":
|
|
|
405
399
|
return None
|
|
406
400
|
|
|
407
401
|
|
|
408
|
-
def _write_handle_to_config(handle: "str | None") -> bool:
|
|
409
|
-
"""Persist the discovered handle to config.json accounts.twitter.handle, but
|
|
410
|
-
ONLY when the configured value is empty or the template placeholder, so we
|
|
411
|
-
never clobber a handle the user set on purpose. Returns True if written.
|
|
412
|
-
|
|
413
|
-
This is what makes account_resolver.resolve('twitter') return the REAL
|
|
414
|
-
account, so our_account (attribution, own-reply skip, account-keyed ops) is
|
|
415
|
-
correct instead of the poisonous 'your-twitter-handle' default. (2026-06-02)
|
|
416
|
-
"""
|
|
417
|
-
if not handle:
|
|
418
|
-
return False
|
|
419
|
-
try:
|
|
420
|
-
with open(_CONFIG_JSON, encoding="utf-8") as f:
|
|
421
|
-
cfg = json.load(f)
|
|
422
|
-
except Exception:
|
|
423
|
-
return False
|
|
424
|
-
accounts = cfg.setdefault("accounts", {})
|
|
425
|
-
if not isinstance(accounts, dict):
|
|
426
|
-
return False
|
|
427
|
-
tw = accounts.setdefault("twitter", {})
|
|
428
|
-
if not isinstance(tw, dict):
|
|
429
|
-
return False
|
|
430
|
-
cur = (tw.get("handle") or "").strip()
|
|
431
|
-
if cur.lower() not in _HANDLE_PLACEHOLDERS:
|
|
432
|
-
return False # a real handle is already set; do not overwrite
|
|
433
|
-
tw["handle"] = "@" + handle.lstrip("@")
|
|
434
|
-
try:
|
|
435
|
-
with open(_CONFIG_JSON, "w", encoding="utf-8") as f:
|
|
436
|
-
json.dump(cfg, f, indent=2)
|
|
437
|
-
f.write("\n")
|
|
438
|
-
return True
|
|
439
|
-
except Exception:
|
|
440
|
-
return False
|
|
441
|
-
|
|
442
|
-
|
|
443
402
|
def _persist_session() -> None:
|
|
444
403
|
"""Persist the validated live X session for auto-restore after ANY logout
|
|
445
404
|
(hard kill, crash, or a keychain re-lock wiping Chrome's Cookies DB).
|
|
@@ -479,17 +438,15 @@ def _persist_session() -> None:
|
|
|
479
438
|
if not cookies:
|
|
480
439
|
return
|
|
481
440
|
|
|
482
|
-
#
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
handle = None
|
|
492
|
-
|
|
441
|
+
# The handle's single durable home is the cookie mirror (written just below).
|
|
442
|
+
# We deliberately do NOT also copy it into config.json: account_resolver
|
|
443
|
+
# resolves env -> config override -> this mirror, so a second copy in config
|
|
444
|
+
# would be duplicated state that can drift — and its going missing there was the
|
|
445
|
+
# "posts land 0/N, no_account_configured" bug. config.accounts.twitter.handle
|
|
446
|
+
# stays a user-only override. When live resolution came up empty, save_cookies
|
|
447
|
+
# carries the previously-stamped handle forward, so a known account is never
|
|
448
|
+
# downgraded to null.
|
|
449
|
+
#
|
|
493
450
|
# Local mirror — keychain-independent durability. This is the only cookie
|
|
494
451
|
# store; the VM-era server store (/api/v1/twitter/session-cookies) was
|
|
495
452
|
# removed 2026-06-17 when we stopped running AppMaker VMs.
|
|
@@ -499,6 +456,10 @@ def _persist_session() -> None:
|
|
|
499
456
|
print(f"setup_twitter_auth: mirrored {n} x.com cookies to "
|
|
500
457
|
f"{twitter_cookie_mirror.MIRROR_PATH} (survives keychain re-lock "
|
|
501
458
|
"/ Cookies-DB wipe on relaunch)", file=sys.stderr)
|
|
459
|
+
if handle:
|
|
460
|
+
print(f"setup_twitter_auth: durable X handle @{handle} stamped on the "
|
|
461
|
+
"cookie mirror; account_resolver resolves it for attribution + "
|
|
462
|
+
"own-reply dedup", file=sys.stderr)
|
|
502
463
|
except Exception as e:
|
|
503
464
|
print(f"setup_twitter_auth: local mirror save skipped ({e})", file=sys.stderr)
|
|
504
465
|
|
|
@@ -1303,37 +1264,6 @@ def cmd_connect(args) -> dict:
|
|
|
1303
1264
|
}
|
|
1304
1265
|
|
|
1305
1266
|
|
|
1306
|
-
def cmd_resolve_handle(args) -> dict:
|
|
1307
|
-
"""Read the live logged-in @handle from the managed Chrome and persist it to
|
|
1308
|
-
config.json accounts.twitter.handle.
|
|
1309
|
-
|
|
1310
|
-
The MCP post preflight calls this to self-heal a missing handle — the onboarding
|
|
1311
|
-
gap where connect_x's best-effort live-DOM read silently no-op'd, leaving the
|
|
1312
|
-
install logged in but with accounts:null, so twitter_browser.py refused EVERY
|
|
1313
|
-
reply with no_account_configured. Reading the handle from the SAME session the
|
|
1314
|
-
poster posts through is ground truth, not a guess, so it's safe where a hardcoded
|
|
1315
|
-
fallback would not be. Best-effort: returns state=browser_not_running / no_handle
|
|
1316
|
-
on failure and never raises."""
|
|
1317
|
-
try:
|
|
1318
|
-
ws, send = _attach()
|
|
1319
|
-
except Exception as e:
|
|
1320
|
-
return {"ok": False, "state": "browser_not_running", "error": str(e)}
|
|
1321
|
-
handle = None
|
|
1322
|
-
try:
|
|
1323
|
-
handle = _resolve_live_handle(send)
|
|
1324
|
-
except Exception:
|
|
1325
|
-
handle = None
|
|
1326
|
-
finally:
|
|
1327
|
-
try:
|
|
1328
|
-
ws.close()
|
|
1329
|
-
except Exception:
|
|
1330
|
-
pass
|
|
1331
|
-
if not handle:
|
|
1332
|
-
return {"ok": False, "state": "no_handle"}
|
|
1333
|
-
persisted = _write_handle_to_config(handle)
|
|
1334
|
-
return {"ok": True, "state": "resolved", "handle": handle, "persisted": persisted}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
1267
|
def main() -> int:
|
|
1338
1268
|
ap = argparse.ArgumentParser(description="Twitter/X session bootstrap for MCP setup.")
|
|
1339
1269
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
@@ -1341,10 +1271,6 @@ def main() -> int:
|
|
|
1341
1271
|
sub.add_parser("detect-sources",
|
|
1342
1272
|
help="List browsers/profiles to import the X session from "
|
|
1343
1273
|
"(JSON, for the panel dropdown). No keychain prompt.")
|
|
1344
|
-
sub.add_parser("resolve-handle",
|
|
1345
|
-
help="Read the live logged-in @handle from the managed Chrome and "
|
|
1346
|
-
"persist it to config.json accounts.twitter.handle. Idempotent "
|
|
1347
|
-
"self-heal for the post preflight; never overwrites a real handle.")
|
|
1348
1274
|
c = sub.add_parser("connect", help="Ensure browser + import/validate the X session.")
|
|
1349
1275
|
c.add_argument("--source", default=None,
|
|
1350
1276
|
help="Browser profile to import from (e.g. chrome:Default, arc:Default), "
|
|
@@ -1375,8 +1301,6 @@ def main() -> int:
|
|
|
1375
1301
|
out = {"ok": False, "state": "error", "error": _WEBSOCKET_IMPORT_ERROR}
|
|
1376
1302
|
elif args.cmd == "status":
|
|
1377
1303
|
out = cmd_status(args)
|
|
1378
|
-
elif args.cmd == "resolve-handle":
|
|
1379
|
-
out = cmd_resolve_handle(args)
|
|
1380
1304
|
else:
|
|
1381
1305
|
out = cmd_connect(args)
|
|
1382
1306
|
print(json.dumps(out, indent=2))
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Locked field-patcher for the review-queue store.
|
|
3
|
+
|
|
4
|
+
The review store (review-queue.json) has three writers: the menubar
|
|
5
|
+
(s4l_state._store_update), merge_review_queue.py, and the MCP server. The
|
|
6
|
+
first two hold an fcntl.flock on `<store>.lock` around their read-modify-write;
|
|
7
|
+
the MCP server is Node, which has no native flock, so it routes its store
|
|
8
|
+
writes through THIS script instead of writing the file itself. That closes the
|
|
9
|
+
last unlocked writer (the last-writer-wins race that erased posted stamps on
|
|
10
|
+
2026-07-17 — six live replies rendered as duplicate_thread_pre_post kills).
|
|
11
|
+
|
|
12
|
+
Protocol-compatible with s4l_state._store_update: same lock file, LOCK_EX for
|
|
13
|
+
the whole read-mutate-replace span, atomic os.replace. fcntl (not an O_EXCL
|
|
14
|
+
lockfile) on purpose: the kernel drops the lock when the holder dies, so there
|
|
15
|
+
is no stale-lock stealing logic to get wrong.
|
|
16
|
+
|
|
17
|
+
Usage: store_patch.py <patches.json> (or '-' to read the JSON from stdin)
|
|
18
|
+
|
|
19
|
+
Input shape:
|
|
20
|
+
{"patches": [
|
|
21
|
+
{"candidate_id": 123, # match ALL rows with this id (ids are NOT
|
|
22
|
+
# unique: sandbox reruns/variant drafts
|
|
23
|
+
# append sibling rows; a stamp that hits
|
|
24
|
+
# only one sibling leaves a drainable twin)
|
|
25
|
+
"n": 4, # 1-based index fallback when candidate_id
|
|
26
|
+
# is absent/None (legacy rows)
|
|
27
|
+
"set": {"approved": true}, # fields to assign
|
|
28
|
+
"unset": ["discard_reason"]} # fields to delete
|
|
29
|
+
]}
|
|
30
|
+
|
|
31
|
+
Merge rules (mirror mergeApprovedStampsIntoStore):
|
|
32
|
+
- posted is sticky: a patch may set posted=true, but `set.terminal=true` is
|
|
33
|
+
ignored on a row whose posted is already true, and posted is never unset.
|
|
34
|
+
Prints {"ok": true, "patched": N} on stdout. Exit 0 even when N=0 (a patch
|
|
35
|
+
matching nothing is not an error; the row may have been absorbed elsewhere).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import fcntl
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import sys
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def store_path() -> str:
|
|
47
|
+
state_dir = os.environ.get("S4L_STATE_DIR") or os.path.join(
|
|
48
|
+
os.path.expanduser("~"), ".social-autoposter-mcp"
|
|
49
|
+
)
|
|
50
|
+
return os.path.join(state_dir, "review-queue.json")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _match_rows(cands: list, patch: dict) -> list:
|
|
54
|
+
cid = patch.get("candidate_id")
|
|
55
|
+
if cid is not None:
|
|
56
|
+
rows = [c for c in cands if c.get("candidate_id") == cid]
|
|
57
|
+
if rows:
|
|
58
|
+
return rows
|
|
59
|
+
n = patch.get("n")
|
|
60
|
+
if isinstance(n, int) and 1 <= n <= len(cands):
|
|
61
|
+
return [cands[n - 1]]
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def apply_patches(data: dict, patches: list) -> int:
|
|
66
|
+
cands = data.get("candidates") or []
|
|
67
|
+
patched = 0
|
|
68
|
+
for p in patches:
|
|
69
|
+
for c in _match_rows(cands, p):
|
|
70
|
+
sets = dict(p.get("set") or {})
|
|
71
|
+
if c.get("posted") is True:
|
|
72
|
+
sets.pop("terminal", None)
|
|
73
|
+
sets.pop("terminal_reason", None)
|
|
74
|
+
for k, v in sets.items():
|
|
75
|
+
if k == "posted" and v is not True and c.get("posted") is True:
|
|
76
|
+
continue # posted is sticky
|
|
77
|
+
c[k] = v
|
|
78
|
+
for k in p.get("unset") or []:
|
|
79
|
+
if k == "posted":
|
|
80
|
+
continue
|
|
81
|
+
c.pop(k, None)
|
|
82
|
+
patched += 1
|
|
83
|
+
return patched
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> int:
|
|
87
|
+
src = sys.argv[1] if len(sys.argv) > 1 else "-"
|
|
88
|
+
raw = sys.stdin.read() if src == "-" else open(src).read()
|
|
89
|
+
patches = (json.loads(raw) or {}).get("patches") or []
|
|
90
|
+
sp = store_path()
|
|
91
|
+
with open(sp + ".lock", "w") as lk:
|
|
92
|
+
fcntl.flock(lk, fcntl.LOCK_EX)
|
|
93
|
+
try:
|
|
94
|
+
try:
|
|
95
|
+
with open(sp) as f:
|
|
96
|
+
data = json.load(f)
|
|
97
|
+
except Exception:
|
|
98
|
+
data = {"candidates": []}
|
|
99
|
+
patched = apply_patches(data, patches)
|
|
100
|
+
tmp = f"{sp}.tmp.{os.getpid()}"
|
|
101
|
+
with open(tmp, "w") as f:
|
|
102
|
+
json.dump(data, f, indent=2)
|
|
103
|
+
os.replace(tmp, sp)
|
|
104
|
+
finally:
|
|
105
|
+
fcntl.flock(lk, fcntl.LOCK_UN)
|
|
106
|
+
print(json.dumps({"ok": True, "patched": patched}))
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|