@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.
Files changed (48) hide show
  1. package/mcp/dist/index.js +571 -79
  2. package/mcp/dist/panel.html +2 -3
  3. package/mcp/dist/redditAuth.js +115 -0
  4. package/mcp/dist/runtime.js +83 -2
  5. package/mcp/dist/setup.js +142 -5
  6. package/mcp/dist/telemetry.js +13 -3
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +1 -1
  9. package/mcp/menubar/s4l_card.py +32 -6
  10. package/mcp/menubar/s4l_menubar.py +42 -111
  11. package/mcp/menubar/s4l_state.py +54 -187
  12. package/mcp/package.json +1 -1
  13. package/mcp/shared/onboarding-ledger.cjs +63 -4
  14. package/package.json +1 -1
  15. package/scripts/autopilot_stall_watch.py +3 -6
  16. package/scripts/browser_mutex.py +316 -0
  17. package/scripts/claude_job.py +21 -0
  18. package/scripts/config.py +118 -0
  19. package/scripts/counterparty_history.py +44 -6
  20. package/scripts/engage_github.py +7 -3
  21. package/scripts/engage_reddit.py +9 -3
  22. package/scripts/engagement_signal_feedback.py +256 -0
  23. package/scripts/learned_preferences.py +37 -14
  24. package/scripts/memory_snapshot.py +45 -0
  25. package/scripts/merge_review_queue.py +67 -3
  26. package/scripts/post_reddit.py +289 -24
  27. package/scripts/reddit_browser.py +62 -63
  28. package/scripts/reddit_browser_fetch.py +32 -0
  29. package/scripts/release-mcpb.sh +17 -0
  30. package/scripts/reset-test-machine.sh +13 -7
  31. package/scripts/scan_twitter_thread_followups.py +7 -1
  32. package/scripts/schedule_state.py +1 -1
  33. package/scripts/setup_reddit_auth.py +925 -0
  34. package/scripts/snapshot.py +96 -4
  35. package/scripts/stats.py +37 -13
  36. package/scripts/test_no_silent_fallbacks.py +281 -0
  37. package/scripts/trigger_promo_lane_test.sh +91 -0
  38. package/scripts/twitter_browser.py +183 -342
  39. package/scripts/twitter_scan.py +75 -10
  40. package/scripts/watchdog_hung_runs.py +15 -9
  41. package/skill/lib/harness-common.sh +344 -0
  42. package/skill/lib/linkedin-backend.sh +61 -161
  43. package/skill/lib/reddit-backend.sh +39 -125
  44. package/skill/lib/twitter-backend.sh +51 -273
  45. package/skill/lock.sh +57 -0
  46. package/skill/run-reddit-search-launchd.sh +4 -1
  47. package/skill/run-reddit-search.sh +51 -2
  48. package/skill/run-twitter-cycle.sh +44 -21
@@ -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
- if mid in live_status:
667
- rec["status"] = live_status[mid]
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
- result["complete"] = bool(out) and all(m.get("status") == "complete" for m in out)
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
- setup_complete = rt_ready and any_ready and bool(x["connected"])
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. Sort by id ASC so we can
40
- page deterministically; we re-issue with an increasing id cursor until the
41
- server returns a short page. We need scan_no_change_count, posted_at,
42
- engagement_updated_at, deletion_detect_count, upvotes, comments_count.
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
- cursor_since = None # unused for id-asc paging
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": "id",
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
- if r["id"] > last_seen_id:
66
- last_seen_id = r["id"]
67
- # Without a server-side cursor, we get the same first 500 every call.
68
- # Break to avoid an infinite loop; the typical Reddit active-post count
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"