@m13v/s4l 1.7.4-rc.25 → 1.7.4-rc.26
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/bin/cli.js +23 -0
- package/mcp/dist/runtime.js +31 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +20 -21
- package/mcp/package.json +1 -1
- package/package.json +2 -1
- package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +126 -0
- package/scripts/reddit_browser.py +46 -0
- package/scripts/scheduled_task_selfheal.py +213 -16
- package/scripts/test_scheduled_task_selfheal.py +121 -0
- package/scripts/twitter_browser.py +54 -0
- package/scripts/watchdog_hung_runs.py +106 -0
package/bin/cli.js
CHANGED
|
@@ -427,6 +427,29 @@ function installBrowserHarness() {
|
|
|
427
427
|
if (reset.status !== 0) {
|
|
428
428
|
console.warn(' WARNING: could not reset browser-harness clone to pinned commit; using existing checkout.');
|
|
429
429
|
}
|
|
430
|
+
// Vendored fix on top of the pin (keep in sync with runtime.ts): loopback
|
|
431
|
+
// CDP requests must never route through a proxy — macOS system proxy
|
|
432
|
+
// settings leak into urllib's default opener and a box-wide forwarder
|
|
433
|
+
// 403s every 127.0.0.1 probe (2026-07-13). Applied at install time since
|
|
434
|
+
// we don't control the upstream repo. If it stops applying, upstream has
|
|
435
|
+
// either merged the fix (detected; fine) or refactored (warn); never fail
|
|
436
|
+
// the install over it.
|
|
437
|
+
const harnessPatch = path.join(PKG_ROOT, 'scripts', 'patches', 'browser-harness-loopback-cdp-proxy.patch');
|
|
438
|
+
if (fs.existsSync(harnessPatch)) {
|
|
439
|
+
const chk = spawnSync('git', ['-C', harnessDir, 'apply', '--check', harnessPatch], { stdio: 'pipe' });
|
|
440
|
+
if (chk.status === 0) {
|
|
441
|
+
spawnSync('git', ['-C', harnessDir, 'apply', harnessPatch], { stdio: 'inherit' });
|
|
442
|
+
console.log(' applied loopback-proxy patch to browser-harness');
|
|
443
|
+
} else {
|
|
444
|
+
let fixedUpstream = false;
|
|
445
|
+
try {
|
|
446
|
+
fixedUpstream = fs.readFileSync(path.join(harnessDir, 'src', 'browser_harness', 'admin.py'), 'utf-8').includes('cdp_urlopen');
|
|
447
|
+
} catch { /* file moved — fall through to the warning */ }
|
|
448
|
+
if (!fixedUpstream) {
|
|
449
|
+
console.warn(' WARNING: browser-harness loopback-proxy patch no longer applies and the fix is absent upstream; CDP probes may 403 behind a system proxy.');
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
430
453
|
};
|
|
431
454
|
if (!fs.existsSync(harnessDir)) {
|
|
432
455
|
fs.mkdirSync(path.dirname(harnessDir), { recursive: true });
|
package/mcp/dist/runtime.js
CHANGED
|
@@ -828,6 +828,37 @@ async function provision(progress) {
|
|
|
828
828
|
timeoutMs: 120000,
|
|
829
829
|
});
|
|
830
830
|
await sh("git", ["-C", HARNESS_DIR, "reset", "--hard", "FETCH_HEAD"], { timeoutMs: 60000 });
|
|
831
|
+
// Vendored fix on top of the pin: loopback CDP requests must never route
|
|
832
|
+
// through a proxy. macOS system proxy settings leak into urllib's default
|
|
833
|
+
// opener, and a box-wide forwarder 403s every 127.0.0.1 probe, so Chrome
|
|
834
|
+
// reads as "wedged"/logged-out while it is actually fine (2026-07-13).
|
|
835
|
+
// Upstream doesn't carry the fix and we don't control that repo, so apply
|
|
836
|
+
// our patch at install time, on every machine. If it stops applying
|
|
837
|
+
// cleanly, upstream has either merged the fix (detected below; fine) or
|
|
838
|
+
// refactored the files (warn); never fail the install over it.
|
|
839
|
+
const harnessPatch = path.join(MATERIALIZED_REPO, "scripts", "patches", "browser-harness-loopback-cdp-proxy.patch");
|
|
840
|
+
if (fs.existsSync(harnessPatch)) {
|
|
841
|
+
const chk = await sh("git", ["-C", HARNESS_DIR, "apply", "--check", harnessPatch], {
|
|
842
|
+
timeoutMs: 30000,
|
|
843
|
+
});
|
|
844
|
+
if (chk.code === 0) {
|
|
845
|
+
await sh("git", ["-C", HARNESS_DIR, "apply", harnessPatch], { timeoutMs: 30000 });
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
let fixedUpstream = false;
|
|
849
|
+
try {
|
|
850
|
+
fixedUpstream = fs
|
|
851
|
+
.readFileSync(path.join(HARNESS_DIR, "src", "browser_harness", "admin.py"), "utf-8")
|
|
852
|
+
.includes("cdp_urlopen");
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
/* file moved — fall through to the warning */
|
|
856
|
+
}
|
|
857
|
+
if (!fixedUpstream) {
|
|
858
|
+
console.error("[runtime] browser-harness loopback-proxy patch no longer applies and the fix is absent upstream; CDP probes may 403 behind a system proxy");
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
831
862
|
// Install the CLI via uv tool (lands at ~/.local/bin/browser-harness).
|
|
832
863
|
// --force so a refreshed source / changed entry point is reinstalled.
|
|
833
864
|
const inst = await sh(uv, ["tool", "install", "--force", "-e", HARNESS_DIR], {
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.4-rc.
|
|
5
|
+
"version": "1.7.4-rc.26",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
|
@@ -337,9 +337,9 @@ UPDATE_PROMPT = "Update the S4L plugin to the latest version."
|
|
|
337
337
|
# Re-arm goes through the HOST create_scheduled_task path (the same one onboarding
|
|
338
338
|
# uses) — it registers the routines under whatever account is logged in and shows
|
|
339
339
|
# up in Routines. The host tool only runs inside an agent chat, so the menu bar
|
|
340
|
-
# hands Claude this prompt (auto-typed, clipboard+paste fallback).
|
|
341
|
-
#
|
|
342
|
-
#
|
|
340
|
+
# hands Claude this prompt (auto-typed, clipboard+paste fallback). The automatic
|
|
341
|
+
# fix can now direct-write after resolving lastActiveOrg; keep this host path for
|
|
342
|
+
# Keychain denial, missing/stale cookie, and other resolution failures.
|
|
343
343
|
REARM_PROMPT = (
|
|
344
344
|
"Set up the S4L draft autopilot schedule for this Claude account. "
|
|
345
345
|
"If queue_setup is available, call it; then for s4l-worker call the host tool "
|
|
@@ -786,8 +786,8 @@ class S4LMenuBar(rumps.App):
|
|
|
786
786
|
bridge is panel-only), so the reliable path here is: copy the prompt to the
|
|
787
787
|
clipboard, open Claude, and tell the user to paste it. (The dashboard
|
|
788
788
|
widget's button does this in one click via app.sendMessage — no paste.) We
|
|
789
|
-
do NOT auto-type (focus/timing flaky)
|
|
790
|
-
|
|
789
|
+
do NOT auto-type (focus/timing flaky). This remains the fallback when the
|
|
790
|
+
automatic direct-write path cannot resolve/decrypt the active org cookie.
|
|
791
791
|
|
|
792
792
|
The click itself is captured (mirroring _diagnose_fix) — previously this
|
|
793
793
|
button had NO telemetry at all, so a past incident (Karol, 2026-07-07)
|
|
@@ -810,15 +810,15 @@ class S4LMenuBar(rumps.App):
|
|
|
810
810
|
|
|
811
811
|
def _finish_schedule_setup(self, _=None):
|
|
812
812
|
"""One-click fix for schedule_state == 'missing' when
|
|
813
|
-
scheduled_task_selfheal.can_create_for_active_account() confirms
|
|
814
|
-
session directory
|
|
815
|
-
quit Claude, create the registration
|
|
816
|
-
same heal() the update flow uses — see
|
|
817
|
-
this is equivalent to what
|
|
818
|
-
relaunch. Primary action
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
813
|
+
scheduled_task_selfheal.can_create_for_active_account() confirms an
|
|
814
|
+
existing session directory OR the active-org cookie needed to make
|
|
815
|
+
the exact account/org directory: quit Claude, create the registration
|
|
816
|
+
via a direct file write (the same heal() the update flow uses — see
|
|
817
|
+
its module docstring for why this is equivalent to what
|
|
818
|
+
create_scheduled_task would produce), then relaunch. Primary action
|
|
819
|
+
for this case now instead of re-arm: no clipboard paste or chat turn.
|
|
820
|
+
Re-arm remains the fallback when the cookie is unavailable or macOS
|
|
821
|
+
denies its Safe Storage Keychain read.
|
|
822
822
|
|
|
823
823
|
No confirm dialog: the restart is already disclosed in the menu item
|
|
824
824
|
label itself ("...(restarts Claude)", _build_menu) — a modal repeating
|
|
@@ -3331,13 +3331,12 @@ class S4LMenuBar(rumps.App):
|
|
|
3331
3331
|
items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
|
|
3332
3332
|
else:
|
|
3333
3333
|
items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
|
|
3334
|
-
# Prefer the automatic fix
|
|
3335
|
-
#
|
|
3336
|
-
#
|
|
3337
|
-
#
|
|
3338
|
-
#
|
|
3339
|
-
#
|
|
3340
|
-
# only the live create_scheduled_task host tool can help.
|
|
3334
|
+
# Prefer the automatic fix: an existing session directory OR
|
|
3335
|
+
# lastActiveOrg cookie lets _finish_schedule_setup target the
|
|
3336
|
+
# active account exactly (heal() fix 5), with no clipboard
|
|
3337
|
+
# paste. Fall back to the live create_scheduled_task host tool
|
|
3338
|
+
# when neither source is available; decryption denial after a
|
|
3339
|
+
# click is also caught by the post-restart verification.
|
|
3341
3340
|
can_selfheal = False
|
|
3342
3341
|
try:
|
|
3343
3342
|
import scheduled_task_selfheal
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.4-rc.
|
|
3
|
+
"version": "1.7.4-rc.26",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l",
|
|
3
|
-
"version": "1.7.4-rc.
|
|
3
|
+
"version": "1.7.4-rc.26",
|
|
4
4
|
"description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"social-autoposter": "bin/cli.js",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"!bin/server.js",
|
|
18
18
|
"!bin/auth.js",
|
|
19
19
|
"scripts/*.py",
|
|
20
|
+
"scripts/patches/",
|
|
20
21
|
"!scripts/db_direct.py",
|
|
21
22
|
"!scripts/sync_blocked_authors.py",
|
|
22
23
|
"!scripts/backfill_real_clicks.py",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
From a97b7cdae960813f13af39ef99ce878426008dcc Mon Sep 17 00:00:00 2001
|
|
2
|
+
From: m13v <i@m13v.com>
|
|
3
|
+
Date: Mon, 13 Jul 2026 08:48:50 -0700
|
|
4
|
+
Subject: [PATCH] Never route loopback CDP requests through a proxy
|
|
5
|
+
|
|
6
|
+
urllib's default opener honors proxy env vars and macOS system proxy
|
|
7
|
+
settings (scutil --proxy). On a machine with a system-wide proxy (e.g. a
|
|
8
|
+
local gost/ISP forwarder), every 127.0.0.1 CDP probe is sent to the proxy
|
|
9
|
+
and returns 403 Forbidden, so Chrome looks unreachable while curl (env
|
|
10
|
+
proxies only) and Playwright (own websocket stack) work fine. Route all
|
|
11
|
+
loopback CDP HTTP calls through a proxy-free opener; non-loopback CDP
|
|
12
|
+
URLs keep default proxy behavior.
|
|
13
|
+
|
|
14
|
+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
15
|
+
---
|
|
16
|
+
src/browser_harness/admin.py | 24 +++++++++++++++++++++++-
|
|
17
|
+
src/browser_harness/daemon.py | 7 ++++---
|
|
18
|
+
src/browser_harness/run.py | 3 ++-
|
|
19
|
+
3 files changed, 29 insertions(+), 5 deletions(-)
|
|
20
|
+
|
|
21
|
+
diff --git a/src/browser_harness/admin.py b/src/browser_harness/admin.py
|
|
22
|
+
index c72a8fb..64addee 100644
|
|
23
|
+
--- a/src/browser_harness/admin.py
|
|
24
|
+
+++ b/src/browser_harness/admin.py
|
|
25
|
+
@@ -5,6 +5,7 @@ import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
+import urllib.parse
|
|
30
|
+
import urllib.request
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
@@ -445,8 +446,29 @@ def _stop_cloud_browser(browser_id):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
+_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
38
|
+
+
|
|
39
|
+
+
|
|
40
|
+
+def cdp_urlopen(url, timeout=15):
|
|
41
|
+
+ """urlopen for CDP endpoints: never send loopback traffic through a proxy.
|
|
42
|
+
+
|
|
43
|
+
+ urllib's default opener honors proxy env vars AND macOS system proxy
|
|
44
|
+
+ settings (scutil --proxy). On a box with a system-wide proxy (e.g. a local
|
|
45
|
+
+ gost/ISP forwarder) every 127.0.0.1 CDP probe gets routed to the proxy and
|
|
46
|
+
+ comes back 403 Forbidden — Chrome then looks unreachable/"wedged" while
|
|
47
|
+
+ curl (env proxies only) and Playwright (own websocket stack) work fine.
|
|
48
|
+
+ Loopback must always connect direct; non-loopback CDP URLs keep the
|
|
49
|
+
+ default proxy behavior (a remote/cloud CDP endpoint may legitimately need
|
|
50
|
+
+ the proxy for reachability).
|
|
51
|
+
+ """
|
|
52
|
+
+ host = (urllib.parse.urlsplit(url).hostname or "").lower()
|
|
53
|
+
+ if host in ("127.0.0.1", "localhost", "::1"):
|
|
54
|
+
+ return _NO_PROXY_OPENER.open(url, timeout=timeout)
|
|
55
|
+
+ return urllib.request.urlopen(url, timeout=timeout)
|
|
56
|
+
+
|
|
57
|
+
+
|
|
58
|
+
def _cdp_ws_from_url(cdp_url):
|
|
59
|
+
- return json.loads(urllib.request.urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
|
|
60
|
+
+ return json.loads(cdp_urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _has_local_gui():
|
|
64
|
+
diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py
|
|
65
|
+
index 0f0f255..ceb08b9 100644
|
|
66
|
+
--- a/src/browser_harness/daemon.py
|
|
67
|
+
+++ b/src/browser_harness/daemon.py
|
|
68
|
+
@@ -5,6 +5,7 @@ from collections import deque
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
|
|
71
|
+
from . import _ipc as ipc
|
|
72
|
+
+from .admin import cdp_urlopen
|
|
73
|
+
from cdp_use.client import CDPClient
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@@ -113,7 +114,7 @@ def get_ws_url():
|
|
77
|
+
base_url = url.rstrip("/")
|
|
78
|
+
while time.time() < deadline:
|
|
79
|
+
try:
|
|
80
|
+
- return json.loads(urllib.request.urlopen(f"{base_url}/json/version", timeout=5).read())["webSocketDebuggerUrl"]
|
|
81
|
+
+ return json.loads(cdp_urlopen(f"{base_url}/json/version", timeout=5).read())["webSocketDebuggerUrl"]
|
|
82
|
+
except urllib.error.HTTPError as e:
|
|
83
|
+
last_err = e
|
|
84
|
+
if e.code == 404 and (ws := _ws_from_devtools_active_port(url)):
|
|
85
|
+
@@ -139,7 +140,7 @@ def get_ws_url():
|
|
86
|
+
deadline = time.time() + 30
|
|
87
|
+
while time.time() < deadline:
|
|
88
|
+
try:
|
|
89
|
+
- return json.loads(urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1).read())["webSocketDebuggerUrl"]
|
|
90
|
+
+ return json.loads(cdp_urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1).read())["webSocketDebuggerUrl"]
|
|
91
|
+
except urllib.error.HTTPError as e:
|
|
92
|
+
# Chrome 147+ disables /json/* HTTP discovery on the default user-data-dir;
|
|
93
|
+
# the ws path Chrome wrote to DevToolsActivePort still works.
|
|
94
|
+
@@ -153,7 +154,7 @@ def get_ws_url():
|
|
95
|
+
)
|
|
96
|
+
for probe_port in (9222, 9223):
|
|
97
|
+
try:
|
|
98
|
+
- with urllib.request.urlopen(f"http://127.0.0.1:{probe_port}/json/version", timeout=1) as r:
|
|
99
|
+
+ with cdp_urlopen(f"http://127.0.0.1:{probe_port}/json/version", timeout=1) as r:
|
|
100
|
+
return json.loads(r.read())["webSocketDebuggerUrl"]
|
|
101
|
+
except (OSError, KeyError, ValueError):
|
|
102
|
+
continue
|
|
103
|
+
diff --git a/src/browser_harness/run.py b/src/browser_harness/run.py
|
|
104
|
+
index 8ab1f0f..cbd3835 100644
|
|
105
|
+
--- a/src/browser_harness/run.py
|
|
106
|
+
+++ b/src/browser_harness/run.py
|
|
107
|
+
@@ -9,6 +9,7 @@ if hasattr(sys.stdout, "reconfigure"):
|
|
108
|
+
|
|
109
|
+
from .admin import (
|
|
110
|
+
_version,
|
|
111
|
+
+ cdp_urlopen,
|
|
112
|
+
NAME,
|
|
113
|
+
daemon_alive,
|
|
114
|
+
ensure_daemon,
|
|
115
|
+
@@ -59,7 +60,7 @@ USAGE = """Usage:
|
|
116
|
+
def _local_chrome_listening():
|
|
117
|
+
for port in (9222, 9223):
|
|
118
|
+
try:
|
|
119
|
+
- urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.3).close()
|
|
120
|
+
+ cdp_urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.3).close()
|
|
121
|
+
return True
|
|
122
|
+
except OSError: pass
|
|
123
|
+
return False
|
|
124
|
+
--
|
|
125
|
+
2.43.0
|
|
126
|
+
|
|
@@ -347,7 +347,53 @@ def _refresh_browser_lock():
|
|
|
347
347
|
_heartbeat_bash_lease()
|
|
348
348
|
|
|
349
349
|
|
|
350
|
+
# --- L1/L2 browser-hang guards (2026-07-13, mirrors twitter_browser.py) ------
|
|
351
|
+
# L1: default per-op/navigation timeouts so no Playwright call can hang forever.
|
|
352
|
+
# L2: liveness heartbeat on the shell reddit-browser lock dir; lock held +
|
|
353
|
+
# stale heartbeat = wedged holder, TERMed by watchdog_hung_runs.py in ~30 min
|
|
354
|
+
# instead of the multi-hour age cap. See twitter_browser.py for the incident
|
|
355
|
+
# writeup (2026-07-13 pid 1380 media-capture wedge during a network flap).
|
|
356
|
+
BROWSER_OP_TIMEOUT_MS = 30_000
|
|
357
|
+
BROWSER_NAV_TIMEOUT_MS = 60_000
|
|
358
|
+
_BROWSER_LOCK_HEARTBEAT = "/tmp/social-autoposter-reddit-browser.lock/heartbeat"
|
|
359
|
+
_HB_THROTTLE_S = 5
|
|
360
|
+
_hb_last_touch = 0.0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _touch_browser_heartbeat(*_args):
|
|
364
|
+
global _hb_last_touch
|
|
365
|
+
now = time.time()
|
|
366
|
+
if now - _hb_last_touch < _HB_THROTTLE_S:
|
|
367
|
+
return
|
|
368
|
+
_hb_last_touch = now
|
|
369
|
+
try:
|
|
370
|
+
if os.path.isdir(os.path.dirname(_BROWSER_LOCK_HEARTBEAT)):
|
|
371
|
+
with open(_BROWSER_LOCK_HEARTBEAT, "w") as f:
|
|
372
|
+
f.write(str(int(now)))
|
|
373
|
+
except Exception:
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
|
|
350
377
|
def get_browser_and_page(playwright):
|
|
378
|
+
"""Instrumented entry point: attach via _get_browser_and_page_raw, then
|
|
379
|
+
(L1) set default per-op/navigation timeouts so no page call can hang
|
|
380
|
+
forever, and (L2) wire the browser-lock liveness heartbeat. All new code
|
|
381
|
+
should use THIS, never the raw variant."""
|
|
382
|
+
browser, page, is_cdp = _get_browser_and_page_raw(playwright)
|
|
383
|
+
try:
|
|
384
|
+
page.set_default_timeout(BROWSER_OP_TIMEOUT_MS)
|
|
385
|
+
page.set_default_navigation_timeout(BROWSER_NAV_TIMEOUT_MS)
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
_touch_browser_heartbeat()
|
|
389
|
+
try:
|
|
390
|
+
page.on("request", _touch_browser_heartbeat)
|
|
391
|
+
except Exception:
|
|
392
|
+
pass
|
|
393
|
+
return browser, page, is_cdp
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _get_browser_and_page_raw(playwright):
|
|
351
397
|
"""Get a logged-in Reddit page, preferring CDP-attach over launch_persistent_context.
|
|
352
398
|
|
|
353
399
|
Two paths:
|
|
@@ -29,12 +29,18 @@ Run as a script -> heals, then prints {"ok": true, ...} as JSON.
|
|
|
29
29
|
from __future__ import annotations
|
|
30
30
|
|
|
31
31
|
import glob
|
|
32
|
+
import ctypes
|
|
33
|
+
import ctypes.util
|
|
34
|
+
import hashlib
|
|
32
35
|
import json
|
|
33
36
|
import os
|
|
34
37
|
import shutil
|
|
38
|
+
import sqlite3
|
|
39
|
+
import subprocess
|
|
35
40
|
import sys
|
|
36
41
|
import tempfile
|
|
37
42
|
import time
|
|
43
|
+
import uuid as uuidlib
|
|
38
44
|
|
|
39
45
|
import schedule_state # noqa: E402 (lives next to this file in scripts/)
|
|
40
46
|
|
|
@@ -53,6 +59,180 @@ SCHED_REGISTRY_GLOB = os.path.join(
|
|
|
53
59
|
"claude-code-sessions", "*", "*", "scheduled-tasks.json",
|
|
54
60
|
)
|
|
55
61
|
|
|
62
|
+
CLAUDE_COOKIE_HOST = ".claude.ai"
|
|
63
|
+
CLAUDE_SAFE_STORAGE_SERVICE = "Claude Safe Storage"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _normalized_uuid(value) -> str | None:
|
|
67
|
+
"""Return a canonical UUID string, or None for anything path-unsafe."""
|
|
68
|
+
if not isinstance(value, str):
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
return str(uuidlib.UUID(value.strip()))
|
|
72
|
+
except (ValueError, AttributeError):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _last_active_org_cookie_row(root: str):
|
|
77
|
+
"""Read only the lastActiveOrg row from this Claude profile's cookie DB.
|
|
78
|
+
|
|
79
|
+
Opening through SQLite (rather than copying/parsing the file ourselves)
|
|
80
|
+
also lets SQLite include a live WAL when Claude is still running. Cookie
|
|
81
|
+
values never leave this helper except as the one selected row.
|
|
82
|
+
"""
|
|
83
|
+
db = os.path.join(root, "Cookies")
|
|
84
|
+
if not os.path.isfile(db):
|
|
85
|
+
return None
|
|
86
|
+
conn = None
|
|
87
|
+
try:
|
|
88
|
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1)
|
|
89
|
+
return conn.execute(
|
|
90
|
+
"""
|
|
91
|
+
SELECT host_key, value, encrypted_value
|
|
92
|
+
FROM cookies
|
|
93
|
+
WHERE name = 'lastActiveOrg'
|
|
94
|
+
AND (host_key = '.claude.ai' OR host_key = 'claude.ai')
|
|
95
|
+
ORDER BY last_access_utc DESC
|
|
96
|
+
LIMIT 1
|
|
97
|
+
"""
|
|
98
|
+
).fetchone()
|
|
99
|
+
except Exception:
|
|
100
|
+
return None
|
|
101
|
+
finally:
|
|
102
|
+
if conn is not None:
|
|
103
|
+
try:
|
|
104
|
+
conn.close()
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _has_last_active_org_cookie(root: str) -> bool:
|
|
110
|
+
"""Cheap capability probe that never reads Keychain or shows a prompt."""
|
|
111
|
+
row = _last_active_org_cookie_row(root)
|
|
112
|
+
if not row:
|
|
113
|
+
return False
|
|
114
|
+
_host, value, encrypted = row
|
|
115
|
+
if _normalized_uuid(value):
|
|
116
|
+
return True
|
|
117
|
+
return isinstance(encrypted, bytes) and encrypted.startswith(b"v10")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _claude_safe_storage_password() -> bytes | None:
|
|
121
|
+
"""Read Electron's per-user cookie key from macOS Keychain.
|
|
122
|
+
|
|
123
|
+
There is deliberately no password field in S4L. macOS owns authorization:
|
|
124
|
+
the read commonly succeeds silently, but it may show its standard Keychain
|
|
125
|
+
dialog for the logged-in user. A denial, locked keychain, missing item, or
|
|
126
|
+
unanswered dialog simply makes this best-effort resolver return None.
|
|
127
|
+
"""
|
|
128
|
+
if sys.platform != "darwin":
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
[
|
|
133
|
+
"/usr/bin/security",
|
|
134
|
+
"find-generic-password",
|
|
135
|
+
"-w",
|
|
136
|
+
"-s",
|
|
137
|
+
CLAUDE_SAFE_STORAGE_SERVICE,
|
|
138
|
+
],
|
|
139
|
+
stdin=subprocess.DEVNULL,
|
|
140
|
+
stdout=subprocess.PIPE,
|
|
141
|
+
stderr=subprocess.DEVNULL,
|
|
142
|
+
timeout=20,
|
|
143
|
+
check=False,
|
|
144
|
+
)
|
|
145
|
+
if result.returncode != 0 or not result.stdout:
|
|
146
|
+
return None
|
|
147
|
+
password = result.stdout
|
|
148
|
+
if password.endswith(b"\r\n"):
|
|
149
|
+
password = password[:-2]
|
|
150
|
+
elif password.endswith(b"\n"):
|
|
151
|
+
password = password[:-1]
|
|
152
|
+
return password or None
|
|
153
|
+
except Exception:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _aes_128_cbc_decrypt(ciphertext: bytes, key: bytes, iv: bytes) -> bytes | None:
|
|
158
|
+
"""AES-CBC/PKCS7 via macOS CommonCrypto, available through libSystem.
|
|
159
|
+
|
|
160
|
+
Keeping this in-process avoids passing the derived cookie key on an
|
|
161
|
+
`openssl -K ...` command line, where another local process could see it.
|
|
162
|
+
"""
|
|
163
|
+
if sys.platform != "darwin" or not ciphertext:
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
system_lib = ctypes.util.find_library("System") or "/usr/lib/libSystem.dylib"
|
|
167
|
+
common_crypto = ctypes.CDLL(system_lib)
|
|
168
|
+
cc_crypt = common_crypto.CCCrypt
|
|
169
|
+
cc_crypt.argtypes = [
|
|
170
|
+
ctypes.c_uint32, # operation
|
|
171
|
+
ctypes.c_uint32, # algorithm
|
|
172
|
+
ctypes.c_uint32, # options
|
|
173
|
+
ctypes.c_void_p, ctypes.c_size_t, # key, key length
|
|
174
|
+
ctypes.c_void_p, # IV
|
|
175
|
+
ctypes.c_void_p, ctypes.c_size_t, # input, input length
|
|
176
|
+
ctypes.c_void_p, ctypes.c_size_t, # output, output capacity
|
|
177
|
+
ctypes.POINTER(ctypes.c_size_t), # bytes written
|
|
178
|
+
]
|
|
179
|
+
cc_crypt.restype = ctypes.c_int32
|
|
180
|
+
|
|
181
|
+
key_buf = ctypes.create_string_buffer(key, len(key))
|
|
182
|
+
iv_buf = ctypes.create_string_buffer(iv, len(iv))
|
|
183
|
+
input_buf = ctypes.create_string_buffer(ciphertext, len(ciphertext))
|
|
184
|
+
output_buf = ctypes.create_string_buffer(len(ciphertext) + 16)
|
|
185
|
+
output_len = ctypes.c_size_t()
|
|
186
|
+
status = cc_crypt(
|
|
187
|
+
1, # kCCDecrypt
|
|
188
|
+
0, # kCCAlgorithmAES
|
|
189
|
+
1, # kCCOptionPKCS7Padding
|
|
190
|
+
key_buf, len(key),
|
|
191
|
+
iv_buf,
|
|
192
|
+
input_buf, len(ciphertext),
|
|
193
|
+
output_buf, len(output_buf),
|
|
194
|
+
ctypes.byref(output_len),
|
|
195
|
+
)
|
|
196
|
+
if status != 0:
|
|
197
|
+
return None
|
|
198
|
+
return output_buf.raw[:output_len.value]
|
|
199
|
+
except Exception:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _active_org_uuid_from_cookie(root: str) -> str | None:
|
|
204
|
+
"""Resolve the active org without any claude-code-sessions directory.
|
|
205
|
+
|
|
206
|
+
Chromium's macOS v10 cookie format uses a PBKDF2-derived AES key from the
|
|
207
|
+
app's Safe Storage item. Newer databases bind plaintext to the cookie host
|
|
208
|
+
by prepending SHA-256(host_key); older ones omit that prefix, so accept
|
|
209
|
+
both formats but only return a syntactically valid UUID.
|
|
210
|
+
"""
|
|
211
|
+
row = _last_active_org_cookie_row(root)
|
|
212
|
+
if not row:
|
|
213
|
+
return None
|
|
214
|
+
host, value, encrypted = row
|
|
215
|
+
plain_uuid = _normalized_uuid(value)
|
|
216
|
+
if plain_uuid:
|
|
217
|
+
return plain_uuid
|
|
218
|
+
if not isinstance(encrypted, bytes) or not encrypted.startswith(b"v10"):
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
password = _claude_safe_storage_password()
|
|
222
|
+
if not password:
|
|
223
|
+
return None
|
|
224
|
+
key = hashlib.pbkdf2_hmac("sha1", password, b"saltysalt", 1003, 16)
|
|
225
|
+
clear = _aes_128_cbc_decrypt(encrypted[3:], key, b" " * 16)
|
|
226
|
+
if clear is None:
|
|
227
|
+
return None
|
|
228
|
+
host_digest = hashlib.sha256(str(host).encode("utf-8")).digest()
|
|
229
|
+
if clear.startswith(host_digest):
|
|
230
|
+
clear = clear[len(host_digest):]
|
|
231
|
+
try:
|
|
232
|
+
return _normalized_uuid(clear.decode("utf-8"))
|
|
233
|
+
except UnicodeDecodeError:
|
|
234
|
+
return None
|
|
235
|
+
|
|
56
236
|
|
|
57
237
|
def _ensure_worker_skill_md() -> bool:
|
|
58
238
|
"""Make sure ~/.claude/scheduled-tasks/s4l-worker/SKILL.md exists before we
|
|
@@ -110,9 +290,13 @@ def heal() -> dict:
|
|
|
110
290
|
tree held zero registry files. Resolves the active account via
|
|
111
291
|
schedule_state.py's config.json lookup (lastKnownAccountUuid,
|
|
112
292
|
verified correct against real installs 2026-07-08) and writes a
|
|
113
|
-
fresh worker entry into its most-recently-touched
|
|
114
|
-
directory
|
|
115
|
-
|
|
293
|
+
fresh worker entry into its most-recently-touched existing session
|
|
294
|
+
directory. If the account has no directory yet, decrypts Claude
|
|
295
|
+
Desktop's .claude.ai/lastActiveOrg cookie with the current user's
|
|
296
|
+
macOS `Claude Safe Storage` Keychain item and creates the exact
|
|
297
|
+
<account>/<organization> pair. Keychain denial/missing-cookie cases
|
|
298
|
+
fail closed and retain the live-host-tool fallback. Same user-intent
|
|
299
|
+
and worker_skill_ok guards as fix 4.
|
|
116
300
|
Best-effort: never raises. Returns a small summary dict for logging."""
|
|
117
301
|
summary = {"ok": True, "edited": [], "created": [], "error": None}
|
|
118
302
|
try:
|
|
@@ -218,12 +402,25 @@ def heal() -> dict:
|
|
|
218
402
|
p for p in glob.glob(os.path.join(account_dir, "*"))
|
|
219
403
|
if os.path.isdir(p)
|
|
220
404
|
]
|
|
221
|
-
if
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
405
|
+
if session_dirs:
|
|
406
|
+
# Most-recently-touched session dir is the best available
|
|
407
|
+
# choice when Desktop has already materialized one.
|
|
408
|
+
target_dir = max(session_dirs, key=lambda p: os.path.getmtime(p))
|
|
409
|
+
else:
|
|
410
|
+
# A login creates lastActiveOrg before agent mode creates
|
|
411
|
+
# claude-code-sessions. It is therefore the missing
|
|
412
|
+
# server-assigned half of the exact account/org path.
|
|
413
|
+
org_uuid = _active_org_uuid_from_cookie(root)
|
|
414
|
+
if not org_uuid:
|
|
415
|
+
continue
|
|
416
|
+
target_dir = os.path.join(account_dir, org_uuid)
|
|
417
|
+
try:
|
|
418
|
+
# Private identity-scoped state. Do not chmod a path
|
|
419
|
+
# Desktop already made; mode applies only when new.
|
|
420
|
+
os.makedirs(account_dir, mode=0o700, exist_ok=True)
|
|
421
|
+
os.makedirs(target_dir, mode=0o700, exist_ok=True)
|
|
422
|
+
except Exception:
|
|
423
|
+
continue
|
|
227
424
|
target_file = os.path.join(target_dir, "scheduled-tasks.json")
|
|
228
425
|
new_entry = {
|
|
229
426
|
"id": WORKER_TASK_ID,
|
|
@@ -268,12 +465,12 @@ def heal() -> dict:
|
|
|
268
465
|
|
|
269
466
|
def can_create_for_active_account() -> bool:
|
|
270
467
|
"""Read-only: would fix 5 (see heal()) actually be able to create a fresh
|
|
271
|
-
registration right now? True
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
468
|
+
registration right now? True if the active account has either an existing
|
|
469
|
+
session directory or a plausible lastActiveOrg cookie. This passive menu/
|
|
470
|
+
status probe deliberately does NOT read Keychain (which could show an
|
|
471
|
+
authorization dialog); heal() performs and validates decryption only after
|
|
472
|
+
the user chooses the restart action. Any denial then fails closed and the
|
|
473
|
+
post-restart verification points the user to the manual re-arm fallback."""
|
|
277
474
|
try:
|
|
278
475
|
for cfg in schedule_state._config_json_paths():
|
|
279
476
|
root = os.path.dirname(cfg)
|
|
@@ -285,7 +482,7 @@ def can_create_for_active_account() -> bool:
|
|
|
285
482
|
p for p in glob.glob(os.path.join(account_dir, "*"))
|
|
286
483
|
if os.path.isdir(p)
|
|
287
484
|
]
|
|
288
|
-
if session_dirs:
|
|
485
|
+
if session_dirs or _has_last_active_org_cookie(root):
|
|
289
486
|
return True
|
|
290
487
|
except Exception:
|
|
291
488
|
pass
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Focused tests for fresh-account scheduled-task registry creation."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from unittest import mock
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
15
|
+
import scheduled_task_selfheal as selfheal # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
ACCOUNT_UUID = "f598e7b5-55be-46e8-815e-e8ddb394fc73"
|
|
19
|
+
ORG_UUID = "d03d2d69-12d0-45bf-9f67-ae085d6848e5"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _write_cookie_db(root: str, *, value: str = "", encrypted: bytes = b"v10fixture"):
|
|
23
|
+
db = os.path.join(root, "Cookies")
|
|
24
|
+
conn = sqlite3.connect(db)
|
|
25
|
+
try:
|
|
26
|
+
conn.execute(
|
|
27
|
+
"""
|
|
28
|
+
CREATE TABLE cookies (
|
|
29
|
+
host_key TEXT,
|
|
30
|
+
name TEXT,
|
|
31
|
+
value TEXT,
|
|
32
|
+
encrypted_value BLOB,
|
|
33
|
+
last_access_utc INTEGER
|
|
34
|
+
)
|
|
35
|
+
"""
|
|
36
|
+
)
|
|
37
|
+
conn.execute(
|
|
38
|
+
"INSERT INTO cookies VALUES (?, 'lastActiveOrg', ?, ?, 1)",
|
|
39
|
+
(selfheal.CLAUDE_COOKIE_HOST, value, encrypted),
|
|
40
|
+
)
|
|
41
|
+
conn.commit()
|
|
42
|
+
finally:
|
|
43
|
+
conn.close()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ScheduledTaskSelfhealTest(unittest.TestCase):
|
|
47
|
+
def test_decrypted_cookie_strips_chromium_host_digest(self):
|
|
48
|
+
with tempfile.TemporaryDirectory() as root:
|
|
49
|
+
_write_cookie_db(root)
|
|
50
|
+
clear = (
|
|
51
|
+
hashlib.sha256(selfheal.CLAUDE_COOKIE_HOST.encode()).digest()
|
|
52
|
+
+ ORG_UUID.encode()
|
|
53
|
+
)
|
|
54
|
+
with (
|
|
55
|
+
mock.patch.object(
|
|
56
|
+
selfheal, "_claude_safe_storage_password", return_value=b"fixture"
|
|
57
|
+
),
|
|
58
|
+
mock.patch.object(selfheal, "_aes_128_cbc_decrypt", return_value=clear),
|
|
59
|
+
):
|
|
60
|
+
self.assertEqual(selfheal._active_org_uuid_from_cookie(root), ORG_UUID)
|
|
61
|
+
|
|
62
|
+
def test_capability_probe_does_not_read_keychain(self):
|
|
63
|
+
with tempfile.TemporaryDirectory() as root:
|
|
64
|
+
os.makedirs(root, exist_ok=True)
|
|
65
|
+
config = os.path.join(root, "config.json")
|
|
66
|
+
with open(config, "w") as fh:
|
|
67
|
+
json.dump({"lastKnownAccountUuid": ACCOUNT_UUID}, fh)
|
|
68
|
+
_write_cookie_db(root)
|
|
69
|
+
with (
|
|
70
|
+
mock.patch.object(
|
|
71
|
+
selfheal.schedule_state, "_config_json_paths", return_value=[config]
|
|
72
|
+
),
|
|
73
|
+
mock.patch.object(
|
|
74
|
+
selfheal,
|
|
75
|
+
"_claude_safe_storage_password",
|
|
76
|
+
side_effect=AssertionError("passive probe touched Keychain"),
|
|
77
|
+
),
|
|
78
|
+
):
|
|
79
|
+
self.assertTrue(selfheal.can_create_for_active_account())
|
|
80
|
+
|
|
81
|
+
def test_heal_creates_exact_account_org_registry_without_session_dir(self):
|
|
82
|
+
with tempfile.TemporaryDirectory() as root:
|
|
83
|
+
config = os.path.join(root, "config.json")
|
|
84
|
+
with open(config, "w") as fh:
|
|
85
|
+
json.dump({"lastKnownAccountUuid": ACCOUNT_UUID}, fh)
|
|
86
|
+
worker_cwd = os.path.join(root, "worker")
|
|
87
|
+
registry_glob = os.path.join(
|
|
88
|
+
root, "claude-code-sessions", "*", "*", "scheduled-tasks.json"
|
|
89
|
+
)
|
|
90
|
+
expected = os.path.join(
|
|
91
|
+
root,
|
|
92
|
+
"claude-code-sessions",
|
|
93
|
+
ACCOUNT_UUID,
|
|
94
|
+
ORG_UUID,
|
|
95
|
+
"scheduled-tasks.json",
|
|
96
|
+
)
|
|
97
|
+
with (
|
|
98
|
+
mock.patch.object(
|
|
99
|
+
selfheal.schedule_state, "_config_json_paths", return_value=[config]
|
|
100
|
+
),
|
|
101
|
+
mock.patch.object(
|
|
102
|
+
selfheal, "_active_org_uuid_from_cookie", return_value=ORG_UUID
|
|
103
|
+
),
|
|
104
|
+
mock.patch.object(selfheal, "_ensure_worker_skill_md", return_value=True),
|
|
105
|
+
mock.patch.object(selfheal, "WORKER_CWD", worker_cwd),
|
|
106
|
+
mock.patch.object(selfheal, "SCHED_REGISTRY_GLOB", registry_glob),
|
|
107
|
+
mock.patch.object(selfheal, "DEPRECATED_TASK_IDS", ()),
|
|
108
|
+
mock.patch.object(selfheal, "LEGACY_WORKER_TASK_IDS", ()),
|
|
109
|
+
):
|
|
110
|
+
summary = selfheal.heal()
|
|
111
|
+
|
|
112
|
+
self.assertEqual(summary["created"], [expected])
|
|
113
|
+
with open(expected) as fh:
|
|
114
|
+
registry = json.load(fh)
|
|
115
|
+
self.assertEqual(registry["recordedSkips"], [])
|
|
116
|
+
self.assertEqual(registry["scheduledTasks"][0]["id"], selfheal.WORKER_TASK_ID)
|
|
117
|
+
self.assertEqual(registry["scheduledTasks"][0]["cwd"], worker_cwd)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
unittest.main()
|
|
@@ -610,7 +610,61 @@ def _cdp_diagnostics(cdp_url):
|
|
|
610
610
|
return diag
|
|
611
611
|
|
|
612
612
|
|
|
613
|
+
# --- L1/L2 browser-hang guards (2026-07-13) ----------------------------------
|
|
614
|
+
# L1: no single Playwright operation may block forever. Until today NO script
|
|
615
|
+
# set a default timeout, so any page op could hang indefinitely — a twitter
|
|
616
|
+
# cycle wedged 60+ min in Phase 2b-prep media capture during a network flap
|
|
617
|
+
# (2026-07-13 08:02, pid 1380) while holding the twitter-browser lock, blocking
|
|
618
|
+
# every peer pipeline until the 180-min watchdog cap. Healthy ops here finish
|
|
619
|
+
# in <10s; navigation on the x.com SPA gets extra slack. Callers all have
|
|
620
|
+
# retry layers above, so failing fast is strictly better than hanging.
|
|
621
|
+
BROWSER_OP_TIMEOUT_MS = 30_000
|
|
622
|
+
BROWSER_NAV_TIMEOUT_MS = 60_000
|
|
623
|
+
# L2: liveness heartbeat for the SHELL browser lock (the cross-pipeline mutex,
|
|
624
|
+
# /tmp/social-autoposter-twitter-browser.lock). Touched on attach and on every
|
|
625
|
+
# page network request (throttled), so "lock held + heartbeat stale" means the
|
|
626
|
+
# holder is wedged, not just slow — a healthy 65-min phase2b-gen hold shows
|
|
627
|
+
# constant traffic. watchdog_hung_runs.py reads this to TERM wedged holders in
|
|
628
|
+
# ~30 min instead of the 180-min age cap. Best-effort: never fails the caller.
|
|
629
|
+
_BROWSER_LOCK_HEARTBEAT = "/tmp/social-autoposter-twitter-browser.lock/heartbeat"
|
|
630
|
+
_HB_THROTTLE_S = 5
|
|
631
|
+
_hb_last_touch = 0.0
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _touch_browser_heartbeat(*_args):
|
|
635
|
+
global _hb_last_touch
|
|
636
|
+
now = time.time()
|
|
637
|
+
if now - _hb_last_touch < _HB_THROTTLE_S:
|
|
638
|
+
return
|
|
639
|
+
_hb_last_touch = now
|
|
640
|
+
try:
|
|
641
|
+
if os.path.isdir(os.path.dirname(_BROWSER_LOCK_HEARTBEAT)):
|
|
642
|
+
with open(_BROWSER_LOCK_HEARTBEAT, "w") as f:
|
|
643
|
+
f.write(str(int(now)))
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
646
|
+
|
|
647
|
+
|
|
613
648
|
def get_browser_and_page(playwright):
|
|
649
|
+
"""Instrumented entry point: attach via _get_browser_and_page_raw, then
|
|
650
|
+
(L1) set default per-op/navigation timeouts so no page call can hang
|
|
651
|
+
forever, and (L2) wire the browser-lock liveness heartbeat. All new code
|
|
652
|
+
should use THIS, never the raw variant."""
|
|
653
|
+
browser, page, is_cdp = _get_browser_and_page_raw(playwright)
|
|
654
|
+
try:
|
|
655
|
+
page.set_default_timeout(BROWSER_OP_TIMEOUT_MS)
|
|
656
|
+
page.set_default_navigation_timeout(BROWSER_NAV_TIMEOUT_MS)
|
|
657
|
+
except Exception:
|
|
658
|
+
pass
|
|
659
|
+
_touch_browser_heartbeat()
|
|
660
|
+
try:
|
|
661
|
+
page.on("request", _touch_browser_heartbeat)
|
|
662
|
+
except Exception:
|
|
663
|
+
pass
|
|
664
|
+
return browser, page, is_cdp
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _get_browser_and_page_raw(playwright):
|
|
614
668
|
"""Connect to the running twitter-harness Chrome via CDP.
|
|
615
669
|
|
|
616
670
|
Returns (browser, page, is_cdp=True). `page` is a reused existing Twitter
|
|
@@ -19,6 +19,34 @@ REPO = Path("/Users/matthewdi/social-autoposter")
|
|
|
19
19
|
LOG_RUN_PY = REPO / "scripts" / "log_run.py"
|
|
20
20
|
SKILL_PATH_MARKER = "/social-autoposter/skill/"
|
|
21
21
|
MAX_AGE_SEC = 45 * 60
|
|
22
|
+
|
|
23
|
+
# --- L2: browser-lock liveness (2026-07-13) ----------------------------------
|
|
24
|
+
# The age caps below are backstops sized ABOVE the worst-case duration of a
|
|
25
|
+
# HEALTHY run, so they can't tell "90 min in and progressing" from "wedged
|
|
26
|
+
# since minute 4". This check can: the shared browser libs (twitter_browser.py
|
|
27
|
+
# / reddit_browser.py get_browser_and_page) touch <lock_dir>/heartbeat on every
|
|
28
|
+
# attach and page network event, so a *-browser lock held by a LIVE process
|
|
29
|
+
# whose heartbeat has gone stale means the holder is wedged mid-browser-work
|
|
30
|
+
# (2026-07-13: pid 1380 hung 60+ min in Phase 2b-prep media capture during a
|
|
31
|
+
# network flap, blocking every peer pipeline until the 180-min age cap).
|
|
32
|
+
# Thresholds: WARN at 30 min stale (log-only). KILL at 90 min stale — above
|
|
33
|
+
# the gen phase's documented 60-min worst-case ceiling, so a legit long hold
|
|
34
|
+
# with no python browser invocations can't be false-killed — and ONLY when
|
|
35
|
+
# peers are actually queued waiting (an unwanted lock harms nobody). The
|
|
36
|
+
# 2026-07-12 lock.sh signal fix makes the TERM safe: the holder releases its
|
|
37
|
+
# locks and exits instead of continuing lock-protected work.
|
|
38
|
+
# ONLY the heartbeat-instrumented platforms. linkedin-browser holders write no
|
|
39
|
+
# heartbeat (the linkedin pipeline drives Chrome via the bh harness, not a
|
|
40
|
+
# shared python lib), so on pid-file age alone a healthy long linkedin run
|
|
41
|
+
# would be indistinguishable from a wedge — add it here only once its driver
|
|
42
|
+
# touches <lock_dir>/heartbeat too.
|
|
43
|
+
BROWSER_LOCKS_WATCHED = (
|
|
44
|
+
"/tmp/social-autoposter-twitter-browser.lock",
|
|
45
|
+
"/tmp/social-autoposter-reddit-browser.lock",
|
|
46
|
+
)
|
|
47
|
+
HB_WARN_SEC = 30 * 60
|
|
48
|
+
HB_KILL_SEC = 90 * 60
|
|
49
|
+
HB_REWARN_SEC = 30 * 60 # re-log the warning at most this often per holder
|
|
22
50
|
# Per-script cap overrides for pipelines that legitimately run longer than
|
|
23
51
|
# the 45 min global (stats.py over ~4-5k posts + rate-limit sleeps).
|
|
24
52
|
# Key is (script_file, platform_or_None). Lookup order: (script, platform),
|
|
@@ -317,6 +345,83 @@ def emit_job_log(label, elapsed_sec):
|
|
|
317
345
|
)
|
|
318
346
|
|
|
319
347
|
|
|
348
|
+
def _holder_cmdline(pid: int) -> str:
|
|
349
|
+
try:
|
|
350
|
+
out = subprocess.run(
|
|
351
|
+
["ps", "-p", str(pid), "-o", "command="],
|
|
352
|
+
capture_output=True, text=True, timeout=5,
|
|
353
|
+
).stdout.strip()
|
|
354
|
+
return out[:200]
|
|
355
|
+
except Exception:
|
|
356
|
+
return ""
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def check_browser_lock_liveness() -> None:
|
|
360
|
+
"""L2: TERM a live *-browser lock holder whose liveness heartbeat has gone
|
|
361
|
+
stale for HB_KILL_SEC while peers wait. See the constants block up top."""
|
|
362
|
+
now = time.time()
|
|
363
|
+
for lock_dir in BROWSER_LOCKS_WATCHED:
|
|
364
|
+
if not os.path.isdir(lock_dir):
|
|
365
|
+
continue
|
|
366
|
+
try:
|
|
367
|
+
with open(os.path.join(lock_dir, "pid")) as f:
|
|
368
|
+
holder = int((f.read().strip() or "0"))
|
|
369
|
+
except (OSError, ValueError):
|
|
370
|
+
continue
|
|
371
|
+
if holder <= 0:
|
|
372
|
+
continue
|
|
373
|
+
try:
|
|
374
|
+
os.kill(holder, 0) # liveness probe only
|
|
375
|
+
except ProcessLookupError:
|
|
376
|
+
continue # dead holder: acquire_lock's dead_pid reclaim owns this
|
|
377
|
+
except OSError:
|
|
378
|
+
continue
|
|
379
|
+
# Last liveness = freshest of the heartbeat (browser libs) and the pid
|
|
380
|
+
# file (stamped at acquire), so a just-acquired lock is never "stale".
|
|
381
|
+
marks = []
|
|
382
|
+
for name in ("heartbeat", "pid"):
|
|
383
|
+
try:
|
|
384
|
+
marks.append(os.path.getmtime(os.path.join(lock_dir, name)))
|
|
385
|
+
except OSError:
|
|
386
|
+
pass
|
|
387
|
+
if not marks:
|
|
388
|
+
continue
|
|
389
|
+
stale = now - max(marks)
|
|
390
|
+
if stale < HB_WARN_SEC:
|
|
391
|
+
continue
|
|
392
|
+
try:
|
|
393
|
+
waiters = len(os.listdir(f"{lock_dir}.queue"))
|
|
394
|
+
except OSError:
|
|
395
|
+
waiters = 0
|
|
396
|
+
lock_name = os.path.basename(lock_dir)
|
|
397
|
+
if stale >= HB_KILL_SEC and waiters >= 1:
|
|
398
|
+
cmd = _holder_cmdline(holder)
|
|
399
|
+
watchdog_log(
|
|
400
|
+
f"KILL-STALE-HOLDER {lock_name} pid={holder} heartbeat_stale={int(stale)}s "
|
|
401
|
+
f"waiters={waiters} cmd={cmd!r}"
|
|
402
|
+
)
|
|
403
|
+
killed = kill_tree(holder)
|
|
404
|
+
watchdog_log(f" killed pids: {killed}")
|
|
405
|
+
continue
|
|
406
|
+
# Warn tier: log-only, rate-limited via a marker inside the lock dir
|
|
407
|
+
# (dies with the lock, so each hold re-warns independently).
|
|
408
|
+
marker = os.path.join(lock_dir, "hb-warned")
|
|
409
|
+
try:
|
|
410
|
+
if now - os.path.getmtime(marker) < HB_REWARN_SEC:
|
|
411
|
+
continue
|
|
412
|
+
except OSError:
|
|
413
|
+
pass
|
|
414
|
+
try:
|
|
415
|
+
with open(marker, "w") as f:
|
|
416
|
+
f.write(str(int(now)))
|
|
417
|
+
except OSError:
|
|
418
|
+
pass
|
|
419
|
+
watchdog_log(
|
|
420
|
+
f"WARN-STALE-HOLDER {lock_name} pid={holder} heartbeat_stale={int(stale)}s "
|
|
421
|
+
f"waiters={waiters} cmd={_holder_cmdline(holder)!r}"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
320
425
|
def main() -> None:
|
|
321
426
|
procs = list_skill_shell_processes()
|
|
322
427
|
for pid, ppid, etimes, script_file, platform in procs:
|
|
@@ -337,6 +442,7 @@ def main() -> None:
|
|
|
337
442
|
watchdog_log(f" script trap already logged {label} — skipping watchdog emit")
|
|
338
443
|
else:
|
|
339
444
|
emit_job_log(label, etimes)
|
|
445
|
+
check_browser_lock_liveness()
|
|
340
446
|
|
|
341
447
|
|
|
342
448
|
if __name__ == "__main__":
|