@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
package/scripts/snapshot.py
CHANGED
|
@@ -291,6 +291,40 @@ def _x_status():
|
|
|
291
291
|
return val
|
|
292
292
|
|
|
293
293
|
|
|
294
|
+
# ---- Reddit status (setup_reddit_auth.py status), cached --------------------
|
|
295
|
+
# Mirrors _x_status for the optional Reddit platform. The reddit status command
|
|
296
|
+
# is deliberately CHEAP (reddit_session cookie presence over CDP or on-disk
|
|
297
|
+
# profile check; it never navigates the shared harness tab), so polling it on
|
|
298
|
+
# the same TTL as X is safe.
|
|
299
|
+
_reddit_cache = {"at": 0.0, "val": None}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _reddit_status():
|
|
303
|
+
now = time.time()
|
|
304
|
+
if _reddit_cache["val"] is not None and now - _reddit_cache["at"] < _X_TTL:
|
|
305
|
+
return _reddit_cache["val"]
|
|
306
|
+
val = {"connected": False, "state": "", "username": None}
|
|
307
|
+
if _runtime_ready():
|
|
308
|
+
try:
|
|
309
|
+
py = os.environ.get("S4L_PYTHON") or sys.executable or "python3"
|
|
310
|
+
res = subprocess.run(
|
|
311
|
+
[py, os.path.join(_repo_dir(), "scripts", "setup_reddit_auth.py"), "status"],
|
|
312
|
+
capture_output=True, text=True, timeout=90,
|
|
313
|
+
)
|
|
314
|
+
parsed = json.loads("\n".join(res.stdout.strip().splitlines()[-50:]))
|
|
315
|
+
val = {
|
|
316
|
+
"connected": bool(parsed.get("connected")),
|
|
317
|
+
"state": parsed.get("state") or "",
|
|
318
|
+
"username": parsed.get("username"),
|
|
319
|
+
}
|
|
320
|
+
except Exception:
|
|
321
|
+
val = {"connected": False, "state": "status_unavailable", "username": None}
|
|
322
|
+
else:
|
|
323
|
+
val = {"connected": False, "state": "runtime_not_ready", "username": None}
|
|
324
|
+
_reddit_cache.update(at=now, val=val)
|
|
325
|
+
return val
|
|
326
|
+
|
|
327
|
+
|
|
294
328
|
# ---- version (resolveVersion + latest release + semver), cached ------------
|
|
295
329
|
# Latest-version SOURCE = GitHub releases/latest first, npm only as a fallback.
|
|
296
330
|
# This mirrors mcp/src/version.ts::latestPublishedVersion and is load-bearing:
|
|
@@ -658,13 +692,46 @@ def _onboarding_live(live_status):
|
|
|
658
692
|
# list. Mirror onboarding-ledger.cjs publicSnapshot() ordering via MILESTONES.
|
|
659
693
|
order = ["environment_checked", "runtime_ready", "x_connected", "profile_scanned",
|
|
660
694
|
"mode_chosen", "project_ready", "topics_seeded", "tasks_scheduled"]
|
|
695
|
+
# Optional reddit milestones render only once touched; they never gate
|
|
696
|
+
# completion (mirror of onboarding-ledger.cjs OPTIONAL_MILESTONES).
|
|
697
|
+
optional = ["reddit_connected", "reddit_verified"]
|
|
698
|
+
|
|
699
|
+
def _st(mid):
|
|
700
|
+
rec = ms.get(mid) if isinstance(ms, dict) else None
|
|
701
|
+
st = (rec or {}).get("status", "pending")
|
|
702
|
+
return live_status.get(mid, st)
|
|
703
|
+
|
|
704
|
+
# Any-of platform completion (mirror of onboarding-ledger.cjs
|
|
705
|
+
# milestoneSatisfied): a required X milestone also counts when its Reddit
|
|
706
|
+
# counterpart is complete, and profile_scanned (an X-account scan) is
|
|
707
|
+
# required only while X is the connected platform.
|
|
708
|
+
def _satisfied(mid):
|
|
709
|
+
if _st(mid) == "complete":
|
|
710
|
+
return True
|
|
711
|
+
if mid == "x_connected":
|
|
712
|
+
return _st("reddit_connected") == "complete"
|
|
713
|
+
if mid == "profile_scanned":
|
|
714
|
+
return _st("x_connected") != "complete" and _st("reddit_connected") == "complete"
|
|
715
|
+
return False
|
|
716
|
+
|
|
661
717
|
out = []
|
|
662
718
|
if isinstance(ms, dict):
|
|
663
719
|
for mid in order:
|
|
720
|
+
# Omit a pristine-pending required row that the other platform
|
|
721
|
+
# satisfies (reddit-only install), same as the ledger snapshot.
|
|
722
|
+
if _st(mid) == "pending" and _satisfied(mid):
|
|
723
|
+
continue
|
|
664
724
|
rec = dict(ms.get(mid) or {"status": "pending", "attempts": 0})
|
|
665
725
|
rec["id"] = mid
|
|
666
|
-
|
|
667
|
-
|
|
726
|
+
rec["status"] = _st(mid)
|
|
727
|
+
out.append(rec)
|
|
728
|
+
for mid in optional:
|
|
729
|
+
if _st(mid) == "pending":
|
|
730
|
+
continue
|
|
731
|
+
rec = dict(ms.get(mid) or {"status": "pending", "attempts": 0})
|
|
732
|
+
rec["id"] = mid
|
|
733
|
+
rec["status"] = _st(mid)
|
|
734
|
+
rec["optional"] = True
|
|
668
735
|
out.append(rec)
|
|
669
736
|
elif isinstance(ms, list):
|
|
670
737
|
for rec in ms:
|
|
@@ -674,7 +741,21 @@ def _onboarding_live(live_status):
|
|
|
674
741
|
out.append(rec)
|
|
675
742
|
result = dict(led)
|
|
676
743
|
result["milestones"] = out
|
|
677
|
-
|
|
744
|
+
if isinstance(ms, dict):
|
|
745
|
+
result["complete"] = all(_satisfied(mid) for mid in order)
|
|
746
|
+
else:
|
|
747
|
+
result["complete"] = bool(out) and all(m.get("status") == "complete" for m in out)
|
|
748
|
+
# Blocker suppression (mirror of onboarding-ledger.cjs publicSnapshot): a
|
|
749
|
+
# blocker on an optional reddit milestone (failed reddit attempt on an X
|
|
750
|
+
# box) or on a milestone the other platform satisfies (failed X attempt on
|
|
751
|
+
# a reddit-only box) must not flag attention on the dashboard.
|
|
752
|
+
blocker = result.get("current_blocker")
|
|
753
|
+
if isinstance(blocker, dict):
|
|
754
|
+
bmid = blocker.get("milestone")
|
|
755
|
+
if bmid in optional:
|
|
756
|
+
result["current_blocker"] = None
|
|
757
|
+
elif isinstance(ms, dict) and bmid in order and _satisfied(bmid):
|
|
758
|
+
result["current_blocker"] = None
|
|
678
759
|
return result
|
|
679
760
|
|
|
680
761
|
|
|
@@ -694,7 +775,11 @@ def compute() -> dict:
|
|
|
694
775
|
if persona is not None and flags.get("personal_brand"):
|
|
695
776
|
projects = projects + [persona]
|
|
696
777
|
any_ready = any(p["ready"] for p in projects)
|
|
697
|
-
|
|
778
|
+
# Any-of platform completion (2026-07-15): setup is complete with AT LEAST
|
|
779
|
+
# ONE platform connected. X stays the default suggestion; a reddit-only
|
|
780
|
+
# install must not read "not set up" forever.
|
|
781
|
+
reddit = _reddit_status()
|
|
782
|
+
setup_complete = rt_ready and any_ready and (bool(x["connected"]) or bool(reddit["connected"]))
|
|
698
783
|
|
|
699
784
|
installed = _resolve_version()
|
|
700
785
|
channel = _channel()
|
|
@@ -708,6 +793,10 @@ def compute() -> dict:
|
|
|
708
793
|
"project_ready": "complete" if any_ready else "pending",
|
|
709
794
|
"tasks_scheduled": "complete" if schedule_state == "ok" else "pending",
|
|
710
795
|
}
|
|
796
|
+
# Live reddit overlay only when connected: never force a pending overlay
|
|
797
|
+
# onto a ledger-complete reddit milestone from a transient status miss.
|
|
798
|
+
if reddit["connected"]:
|
|
799
|
+
live_status["reddit_connected"] = "complete"
|
|
711
800
|
|
|
712
801
|
return {
|
|
713
802
|
"projects": projects,
|
|
@@ -716,6 +805,9 @@ def compute() -> dict:
|
|
|
716
805
|
"x_connected": bool(x["connected"]),
|
|
717
806
|
"x_state": x["state"] or "",
|
|
718
807
|
"x_handle": x["handle"],
|
|
808
|
+
"reddit_connected": bool(reddit["connected"]),
|
|
809
|
+
"reddit_state": reddit["state"] or "",
|
|
810
|
+
"reddit_username": reddit["username"],
|
|
719
811
|
"autopilot_on": _autopilot_on(),
|
|
720
812
|
"autopilot_stalled": setup_complete and _autopilot_stalled(),
|
|
721
813
|
"schedule_state": schedule_state,
|
package/scripts/stats.py
CHANGED
|
@@ -36,24 +36,30 @@ from http_api import api_get, api_post, api_patch, load_env
|
|
|
36
36
|
def _http_list_reddit_active_posts():
|
|
37
37
|
"""Walk /api/v1/posts in pages and return rows for the Reddit refresh job.
|
|
38
38
|
|
|
39
|
-
The /api/v1/posts GET caps a single page at 500.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
The /api/v1/posts GET caps a single page at 500. 2026-07-14 fix: the old
|
|
40
|
+
version broke after the FIRST page on the (long-stale) assumption that the
|
|
41
|
+
active-post count was "well under 500" — with ~12k active reddit rows that
|
|
42
|
+
silently pinned Step 2 to the oldest 500 rows forever, so recent posts
|
|
43
|
+
never got deletion-detected. The API has no id cursor, but `since` filters
|
|
44
|
+
posted_at >= X, so we page by an ascending posted_at cursor: `since` is
|
|
45
|
+
inclusive, the boundary row re-appears on the next page and is deduped via
|
|
46
|
+
seen_ids. We need scan_no_change_count, posted_at, engagement_updated_at,
|
|
47
|
+
deletion_detect_count, upvotes, comments_count.
|
|
43
48
|
"""
|
|
44
49
|
out = []
|
|
45
50
|
seen_ids = set()
|
|
46
|
-
|
|
47
|
-
last_seen_id = 0
|
|
51
|
+
cursor = None # ISO posted_at of the newest row seen so far
|
|
48
52
|
while True:
|
|
49
53
|
query = {
|
|
50
54
|
"platform": "reddit",
|
|
51
55
|
"status": "active",
|
|
52
56
|
"has_our_url": "true",
|
|
53
|
-
"order_by": "
|
|
57
|
+
"order_by": "posted_at",
|
|
54
58
|
"order_dir": "asc",
|
|
55
59
|
"limit": 500,
|
|
56
60
|
}
|
|
61
|
+
if cursor:
|
|
62
|
+
query["since"] = cursor
|
|
57
63
|
resp = api_get("/api/v1/posts", query=query)
|
|
58
64
|
rows = ((resp or {}).get("data") or {}).get("posts") or []
|
|
59
65
|
new_rows = [r for r in rows if r.get("id") and r["id"] not in seen_ids]
|
|
@@ -62,12 +68,10 @@ def _http_list_reddit_active_posts():
|
|
|
62
68
|
for r in new_rows:
|
|
63
69
|
seen_ids.add(r["id"])
|
|
64
70
|
out.append(r)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
# is well under 500 so one page covers it.
|
|
70
|
-
break
|
|
71
|
+
stamps = [r.get("posted_at") for r in new_rows if r.get("posted_at")]
|
|
72
|
+
if len(rows) < 500 or not stamps:
|
|
73
|
+
break
|
|
74
|
+
cursor = max(stamps)
|
|
71
75
|
return out
|
|
72
76
|
|
|
73
77
|
|
|
@@ -441,6 +445,26 @@ def fetch_reddit_json(url, user_agent, max_retries=2, timeout=15):
|
|
|
441
445
|
(success AND error) into _reddit_rate_state so the caller can pace.
|
|
442
446
|
On 429, honors Retry-After (capped to 120s) and retries.
|
|
443
447
|
"""
|
|
448
|
+
# 2026-07-14 transport fix: Reddit started 403-blocking plain urllib on
|
|
449
|
+
# *.json (2026-05-28), which silently killed this scan (every poll came
|
|
450
|
+
# back as an HTML block page -> 'empty'/'error', zero rows patched since).
|
|
451
|
+
# Route through the logged-in harness browser first, the same transport
|
|
452
|
+
# reddit_tools._do_request has used since 2026-05-29 (the replies pass in
|
|
453
|
+
# this file already goes through it via batch_fetch_info and kept working).
|
|
454
|
+
# urllib below stays as the fallback; REDDIT_FETCH_BACKEND=urllib forces it.
|
|
455
|
+
if os.environ.get("REDDIT_FETCH_BACKEND", "harness").lower() != "urllib":
|
|
456
|
+
try:
|
|
457
|
+
from reddit_browser_fetch import browser_get_json
|
|
458
|
+
body, code = browser_get_json(url)
|
|
459
|
+
if code == 200 and body:
|
|
460
|
+
try:
|
|
461
|
+
return ("ok", json.loads(body))
|
|
462
|
+
except Exception:
|
|
463
|
+
pass # non-JSON body -> fall through to urllib
|
|
464
|
+
elif code == 404:
|
|
465
|
+
return ("not_found", None)
|
|
466
|
+
except Exception:
|
|
467
|
+
pass
|
|
444
468
|
req = urllib.request.Request(url, headers={"User-Agent": user_agent})
|
|
445
469
|
for attempt in range(max_retries + 1):
|
|
446
470
|
try:
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression test for two recurring bug classes documented across many past
|
|
3
|
+
incidents (X handle -> hardcoded "m13v_" impersonation, DEFAULT_ACCOUNTS,
|
|
4
|
+
bare "python3" reply/post subprocess call sites that lack Playwright on a
|
|
5
|
+
fresh install): a missing config value silently substituting a plausible
|
|
6
|
+
(but wrong, or crash-prone) default instead of failing loudly.
|
|
7
|
+
|
|
8
|
+
Two checks:
|
|
9
|
+
|
|
10
|
+
1. check_no_bare_playwright_subprocess() -- static scan of scripts/*.py and
|
|
11
|
+
seo/*.py for `subprocess.run/check_output/check_call/Popen(["python3", ...])`
|
|
12
|
+
call sites whose target script imports playwright. A script that needs
|
|
13
|
+
Playwright must be launched via the pinned interpreter
|
|
14
|
+
(`PYTHON = os.environ.get("S4L_PYTHON") or sys.executable`, see
|
|
15
|
+
scripts/twitter_post_plan.py:131), never the literal "python3" -- bare
|
|
16
|
+
"python3" resolves to the caller's system Python on PATH, which has no
|
|
17
|
+
Playwright on a fresh install, and the subprocess dies silently
|
|
18
|
+
(no_reply_json / no_such_module errors surfaced only in production,
|
|
19
|
+
see bug_twitter_reply_bare_python3_no_playwright.md).
|
|
20
|
+
|
|
21
|
+
2. check_account_resolver_hard_fails() -- account_resolver.resolve()/require()
|
|
22
|
+
must return None / raise on missing config, never fall back to a real
|
|
23
|
+
person's handle (the "m13v_" / DEFAULT_ACCOUNTS impersonation bug, see
|
|
24
|
+
bug_twitter_handle_scrape_brittle_m13v_fallback.md and
|
|
25
|
+
bug_multitenant_no_install_scoping_account_fallback.md).
|
|
26
|
+
|
|
27
|
+
Run:
|
|
28
|
+
python3 scripts/test_no_silent_fallbacks.py
|
|
29
|
+
Exit 0 = all pass; non-zero with FAIL lines otherwise.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import ast
|
|
34
|
+
import os
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
38
|
+
SCAN_DIRS = [os.path.join(REPO_ROOT, "scripts"), os.path.join(REPO_ROOT, "seo")]
|
|
39
|
+
SUBPROCESS_FUNCS = {"run", "check_output", "check_call", "Popen", "call"}
|
|
40
|
+
|
|
41
|
+
# file:line entries explicitly reviewed and confirmed safe to leave as bare
|
|
42
|
+
# "python3" (target does not import playwright, or the target path could not
|
|
43
|
+
# be resolved statically and was hand-verified). Every entry must carry a
|
|
44
|
+
# one-line reason -- this is a reviewed exception list, not a way to silence
|
|
45
|
+
# noise.
|
|
46
|
+
ALLOWLIST = {
|
|
47
|
+
# e.g. "scripts/some_script.py:42": "targets log_run.py, no playwright import",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
FAILS = []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check(name, cond, detail=""):
|
|
54
|
+
print(f"{'PASS' if cond else 'FAIL'} {name}" + (f" -- {detail}" if detail else ""))
|
|
55
|
+
if not cond:
|
|
56
|
+
FAILS.append(name)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Check 1: bare "python3" spawning a playwright-dependent script
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def _imports_playwright(tree):
|
|
64
|
+
"""True iff the AST contains a real `import playwright[...]` or
|
|
65
|
+
`from playwright[...] import ...` statement, anywhere in the file
|
|
66
|
+
(module-level or nested inside a function). Deliberately AST-based, not
|
|
67
|
+
a substring search on the source text -- several scripts (engage_reddit.py,
|
|
68
|
+
post_reddit.py, twitter_post_plan.py) contain the STRING "import
|
|
69
|
+
playwright" as a `python -c "import playwright"` preflight-check
|
|
70
|
+
argument without ever importing it in their own process; a text search
|
|
71
|
+
misclassifies those as playwright-dependent."""
|
|
72
|
+
for node in ast.walk(tree):
|
|
73
|
+
if isinstance(node, ast.Import):
|
|
74
|
+
if any(alias.name == "playwright" or alias.name.startswith("playwright.") for alias in node.names):
|
|
75
|
+
return True
|
|
76
|
+
elif isinstance(node, ast.ImportFrom):
|
|
77
|
+
if node.module and (node.module == "playwright" or node.module.startswith("playwright.")):
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _find_playwright_dependent_scripts():
|
|
83
|
+
"""Basename -> True for every scanned .py file that actually imports
|
|
84
|
+
playwright (module-level or inside a function -- either way the
|
|
85
|
+
interpreter running that file needs Playwright installed)."""
|
|
86
|
+
dependent = set()
|
|
87
|
+
for d in SCAN_DIRS:
|
|
88
|
+
if not os.path.isdir(d):
|
|
89
|
+
continue
|
|
90
|
+
for name in os.listdir(d):
|
|
91
|
+
if not name.endswith(".py"):
|
|
92
|
+
continue
|
|
93
|
+
path = os.path.join(d, name)
|
|
94
|
+
try:
|
|
95
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
96
|
+
src = f.read()
|
|
97
|
+
tree = ast.parse(src, filename=path)
|
|
98
|
+
except (OSError, SyntaxError):
|
|
99
|
+
continue
|
|
100
|
+
if _imports_playwright(tree):
|
|
101
|
+
dependent.add(name)
|
|
102
|
+
return dependent
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _const_str(node, literals, file_path):
|
|
106
|
+
"""Best-effort static string resolution: literals, known module-level
|
|
107
|
+
names, `__file__` (resolved to the real path of the file being scanned),
|
|
108
|
+
and os.path.join/expanduser/dirname/abspath of resolvable parts -- this
|
|
109
|
+
covers the repo's common `REPO_DIR = os.path.dirname(os.path.dirname(
|
|
110
|
+
os.path.abspath(__file__)))` idiom. Not full data-flow analysis --
|
|
111
|
+
anything else resolves to None and is reported separately, never
|
|
112
|
+
silently treated as safe."""
|
|
113
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
114
|
+
return node.value
|
|
115
|
+
if isinstance(node, ast.Name):
|
|
116
|
+
if node.id == "__file__":
|
|
117
|
+
return file_path
|
|
118
|
+
return literals.get(node.id)
|
|
119
|
+
if isinstance(node, ast.Call):
|
|
120
|
+
func = node.func
|
|
121
|
+
if isinstance(func, ast.Attribute) and func.attr == "join" and node.args:
|
|
122
|
+
parts = [_const_str(a, literals, file_path) for a in node.args]
|
|
123
|
+
if all(p is not None for p in parts):
|
|
124
|
+
return os.path.join(*parts)
|
|
125
|
+
if isinstance(func, ast.Attribute) and func.attr in ("expanduser", "dirname", "abspath", "normpath") and len(node.args) == 1:
|
|
126
|
+
inner = _const_str(node.args[0], literals, file_path)
|
|
127
|
+
if inner is not None:
|
|
128
|
+
if func.attr == "expanduser":
|
|
129
|
+
return os.path.expanduser(inner)
|
|
130
|
+
if func.attr == "dirname":
|
|
131
|
+
return os.path.dirname(inner)
|
|
132
|
+
if func.attr == "abspath":
|
|
133
|
+
return os.path.abspath(inner)
|
|
134
|
+
if func.attr == "normpath":
|
|
135
|
+
return os.path.normpath(inner)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _resolve_module_literals(tree, file_path):
|
|
140
|
+
literals = {}
|
|
141
|
+
for node in ast.walk(tree):
|
|
142
|
+
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
143
|
+
val = _const_str(node.value, literals, file_path)
|
|
144
|
+
if val is not None:
|
|
145
|
+
literals[node.targets[0].id] = val
|
|
146
|
+
return literals
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _scan_file(path, playwright_scripts, fails, notes):
|
|
150
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
151
|
+
src = f.read()
|
|
152
|
+
try:
|
|
153
|
+
tree = ast.parse(src, filename=path)
|
|
154
|
+
except SyntaxError:
|
|
155
|
+
return
|
|
156
|
+
literals = _resolve_module_literals(tree, path)
|
|
157
|
+
rel = os.path.relpath(path, REPO_ROOT)
|
|
158
|
+
|
|
159
|
+
for node in ast.walk(tree):
|
|
160
|
+
if not isinstance(node, ast.Call):
|
|
161
|
+
continue
|
|
162
|
+
func = node.func
|
|
163
|
+
is_subprocess_call = (
|
|
164
|
+
isinstance(func, ast.Attribute)
|
|
165
|
+
and func.attr in SUBPROCESS_FUNCS
|
|
166
|
+
and isinstance(func.value, ast.Name)
|
|
167
|
+
and func.value.id == "subprocess"
|
|
168
|
+
)
|
|
169
|
+
if not is_subprocess_call or not node.args:
|
|
170
|
+
continue
|
|
171
|
+
first_arg = node.args[0]
|
|
172
|
+
if not isinstance(first_arg, ast.List) or not first_arg.elts:
|
|
173
|
+
continue
|
|
174
|
+
interp = first_arg.elts[0]
|
|
175
|
+
if not (isinstance(interp, ast.Constant) and interp.value in ("python3", "python")):
|
|
176
|
+
continue
|
|
177
|
+
if len(first_arg.elts) < 2:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
key = f"{rel}:{node.lineno}"
|
|
181
|
+
if key in ALLOWLIST:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
target = _const_str(first_arg.elts[1], literals, path)
|
|
185
|
+
if target is None:
|
|
186
|
+
notes.append((key, "target script path not statically resolvable -- review manually"))
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
basename = os.path.basename(target)
|
|
190
|
+
if basename in playwright_scripts:
|
|
191
|
+
fails.append((key, f'spawns {basename} (imports playwright) via bare "{interp.value}"'))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check_no_bare_playwright_subprocess():
|
|
195
|
+
playwright_scripts = _find_playwright_dependent_scripts()
|
|
196
|
+
fails, notes = [], []
|
|
197
|
+
for d in SCAN_DIRS:
|
|
198
|
+
if not os.path.isdir(d):
|
|
199
|
+
continue
|
|
200
|
+
for name in sorted(os.listdir(d)):
|
|
201
|
+
if name.endswith(".py"):
|
|
202
|
+
_scan_file(os.path.join(d, name), playwright_scripts, fails, notes)
|
|
203
|
+
|
|
204
|
+
print(
|
|
205
|
+
f" scanned {sum(1 for d in SCAN_DIRS for _ in os.listdir(d) if _.endswith('.py')) if all(os.path.isdir(d) for d in SCAN_DIRS) else '?'} "
|
|
206
|
+
f"files; {len(playwright_scripts)} playwright-dependent scripts: {', '.join(sorted(playwright_scripts))}"
|
|
207
|
+
)
|
|
208
|
+
for key, reason in notes:
|
|
209
|
+
print(f" NOTE {key} -- {reason}")
|
|
210
|
+
|
|
211
|
+
check("no bare-python3 subprocess call targets a playwright-dependent script", not fails,
|
|
212
|
+
f"{len(fails)} violation(s)" if fails else "")
|
|
213
|
+
for key, reason in fails:
|
|
214
|
+
print(f" FAIL {key} -- {reason}")
|
|
215
|
+
if fails:
|
|
216
|
+
print(
|
|
217
|
+
' fix: route through PYTHON = os.environ.get("S4L_PYTHON") or sys.executable '
|
|
218
|
+
"instead of the literal \"python3\" (pattern: scripts/twitter_post_plan.py:131)"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Check 2: account_resolver must hard-fail, never impersonate
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def check_account_resolver_hard_fails():
|
|
227
|
+
sys.path.insert(0, os.path.join(REPO_ROOT, "scripts"))
|
|
228
|
+
import account_resolver # noqa: E402
|
|
229
|
+
|
|
230
|
+
saved_load_config = account_resolver._load_config
|
|
231
|
+
saved_env = dict(os.environ)
|
|
232
|
+
try:
|
|
233
|
+
# Strip every account-related env var + simulate an empty config.json
|
|
234
|
+
# (no accounts section at all -- the "fresh install, nothing
|
|
235
|
+
# configured yet" state).
|
|
236
|
+
for k in list(os.environ):
|
|
237
|
+
if k.startswith("AUTOPOSTER_"):
|
|
238
|
+
del os.environ[k]
|
|
239
|
+
account_resolver._load_config = lambda: {}
|
|
240
|
+
|
|
241
|
+
for platform in ("twitter", "reddit", "linkedin", "github", "moltbook"):
|
|
242
|
+
result = account_resolver.resolve(platform)
|
|
243
|
+
check(f'resolve("{platform}") with no config returns None (not a hardcoded fallback)',
|
|
244
|
+
result is None, f"got {result!r}")
|
|
245
|
+
|
|
246
|
+
raised = False
|
|
247
|
+
try:
|
|
248
|
+
account_resolver.require("twitter")
|
|
249
|
+
except RuntimeError:
|
|
250
|
+
raised = True
|
|
251
|
+
check('require("twitter") with no config raises RuntimeError (hard-fail, never impersonate)', raised)
|
|
252
|
+
|
|
253
|
+
# Positive case: a real configured value still resolves correctly,
|
|
254
|
+
# so this test can't be satisfied by breaking resolve() outright.
|
|
255
|
+
account_resolver._load_config = lambda: {"accounts": {"twitter": {"handle": "@some_handle"}}}
|
|
256
|
+
result = account_resolver.resolve("twitter")
|
|
257
|
+
check('resolve("twitter") returns the configured handle when config IS present',
|
|
258
|
+
result == "some_handle", f"got {result!r}")
|
|
259
|
+
finally:
|
|
260
|
+
account_resolver._load_config = saved_load_config
|
|
261
|
+
os.environ.clear()
|
|
262
|
+
os.environ.update(saved_env)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main():
|
|
266
|
+
print("-- check 1: no bare-python3 subprocess spawning a playwright-dependent script --")
|
|
267
|
+
check_no_bare_playwright_subprocess()
|
|
268
|
+
print("-- check 2: account_resolver hard-fails instead of impersonating --")
|
|
269
|
+
check_account_resolver_hard_fails()
|
|
270
|
+
|
|
271
|
+
if FAILS:
|
|
272
|
+
print(f"\n{len(FAILS)} FAILURE(S):")
|
|
273
|
+
for name in FAILS:
|
|
274
|
+
print(f" - {name}")
|
|
275
|
+
return 1
|
|
276
|
+
print("\nALL PASS")
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
sys.exit(main())
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# One-shot promotion-lane test trigger for the LOCAL S4L install (operator tool).
|
|
3
|
+
#
|
|
4
|
+
# Purpose: verify the promotion-lane prep prompt (size + structure) without
|
|
5
|
+
# hand-running the twitter cycle. It flips mode.json to promotion-only via
|
|
6
|
+
# s4l_mode.py (byte-for-byte what the menubar toggle writes), lets the
|
|
7
|
+
# PRODUCTION launchd kicker start the next cycle on its own, restores the
|
|
8
|
+
# original mode.json the moment that promotion cycle has started (the driver
|
|
9
|
+
# reads mode.json once, at cycle start), then waits for the cycle's
|
|
10
|
+
# [prep_prompt_snapshot] line and prints the size plus content verification.
|
|
11
|
+
#
|
|
12
|
+
# Guarantees:
|
|
13
|
+
# - never runs the cycle itself (launchd stays the only driver)
|
|
14
|
+
# - never touches the installed package; only mode.json state
|
|
15
|
+
# - aborts if draft-only is OFF (a promo test must not auto-post)
|
|
16
|
+
# - restores mode.json on ANY exit (trap), exposure window = one cycle start
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
STATE="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}"
|
|
20
|
+
PKG="$STATE/repo/package"
|
|
21
|
+
PY="$STATE/runtime/.venv/bin/python3"
|
|
22
|
+
LOGS="$PKG/skill/logs"
|
|
23
|
+
MODE="$STATE/mode.json"
|
|
24
|
+
|
|
25
|
+
BACKUP=$(mktemp -t s4l_mode_backup_XXXXXX)
|
|
26
|
+
cp "$MODE" "$BACKUP"
|
|
27
|
+
RESTORED=0
|
|
28
|
+
restore() {
|
|
29
|
+
if [ "$RESTORED" = "0" ] && [ -f "$BACKUP" ]; then
|
|
30
|
+
cp "$BACKUP" "$MODE"
|
|
31
|
+
RESTORED=1
|
|
32
|
+
echo "[trigger] mode.json restored to original"
|
|
33
|
+
fi
|
|
34
|
+
rm -f "$BACKUP"
|
|
35
|
+
}
|
|
36
|
+
trap restore EXIT
|
|
37
|
+
|
|
38
|
+
PERSONA=$(S4L_STATE_DIR="$STATE" "$PY" "$PKG/scripts/s4l_mode.py" persona-name 2>/dev/null || echo "")
|
|
39
|
+
if [ "$(S4L_STATE_DIR="$STATE" "$PY" "$PKG/scripts/s4l_mode.py" draft-only)" != "1" ]; then
|
|
40
|
+
echo "[trigger] ABORT: draft-only is OFF; a promotion test cycle could auto-post"
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
MARKER=$(mktemp -t s4l_promo_marker_XXXXXX)
|
|
45
|
+
echo "[trigger] saved mode.json; flipping to promotion-only (persona project: ${PERSONA:-none})"
|
|
46
|
+
S4L_STATE_DIR="$STATE" "$PY" "$PKG/scripts/s4l_mode.py" set promotion >/dev/null
|
|
47
|
+
|
|
48
|
+
DEADLINE=$(( $(date +%s) + 2400 ))
|
|
49
|
+
PROMO_LOG=""
|
|
50
|
+
echo "[trigger] waiting for the launchd kicker to start a promotion cycle (up to 40 min)..."
|
|
51
|
+
while [ -z "$PROMO_LOG" ]; do
|
|
52
|
+
if [ "$(date +%s)" -gt "$DEADLINE" ]; then
|
|
53
|
+
echo "[trigger] TIMEOUT: no promotion cycle started"
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
for f in $(find "$LOGS" -name 'twitter-cycle-*.log' -newer "$MARKER" 2>/dev/null); do
|
|
57
|
+
sel=$(grep -m1 'Selected projects:' "$f" 2>/dev/null | sed 's/.*Selected projects: //') || true
|
|
58
|
+
[ -z "$sel" ] && continue
|
|
59
|
+
if [ -n "$PERSONA" ] && [ "$sel" = "$PERSONA" ]; then
|
|
60
|
+
continue # persona cycle that raced the flip; keep waiting
|
|
61
|
+
fi
|
|
62
|
+
PROMO_LOG="$f"
|
|
63
|
+
break
|
|
64
|
+
done
|
|
65
|
+
sleep 10
|
|
66
|
+
done
|
|
67
|
+
echo "[trigger] promotion cycle started: $(basename "$PROMO_LOG") (Selected projects: $sel)"
|
|
68
|
+
restore # the running cycle already read the mode; put the toggle back NOW
|
|
69
|
+
|
|
70
|
+
until grep -q 'prep_prompt_snapshot' "$PROMO_LOG" 2>/dev/null; do
|
|
71
|
+
if [ "$(date +%s)" -gt "$DEADLINE" ]; then
|
|
72
|
+
echo "[trigger] TIMEOUT waiting for prep_prompt_snapshot; cycle tail:"
|
|
73
|
+
tail -5 "$PROMO_LOG"
|
|
74
|
+
exit 1
|
|
75
|
+
fi
|
|
76
|
+
sleep 10
|
|
77
|
+
done
|
|
78
|
+
|
|
79
|
+
LINE=$(grep -m1 'prep_prompt_snapshot' "$PROMO_LOG")
|
|
80
|
+
echo "[trigger] $LINE"
|
|
81
|
+
SNAP=$(echo "$LINE" | sed 's/.*path=//')
|
|
82
|
+
if [ -f "$SNAP" ]; then
|
|
83
|
+
echo "[trigger] verification of $SNAP:"
|
|
84
|
+
echo " bytes = $(wc -c < "$SNAP" | tr -d ' ')"
|
|
85
|
+
echo " history occurrences = $(grep -c '"history"' "$SNAP" || true)"
|
|
86
|
+
echo " prefs blocks = $(grep -c '"draft_style_notes"' "$SNAP" || true) (must be 1)"
|
|
87
|
+
echo " global prefs line = $(grep -c 'Global learned preferences' "$SNAP" || true) (must be 1)"
|
|
88
|
+
echo " projects in routing = $(grep -c '"voice_relationship"' "$SNAP" || true)"
|
|
89
|
+
echo " ops keys leaked = $(grep -cE '"posthog"|"onboarded_at"|"web_chat"|"seo_author"' "$SNAP" || true) (must be 0)"
|
|
90
|
+
fi
|
|
91
|
+
echo "[trigger] done"
|