@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 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 });
@@ -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], {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.4-rc.25",
3
- "installedAt": "2026-07-13T15:58:56.002Z"
2
+ "version": "1.7.4-rc.26",
3
+ "installedAt": "2026-07-13T16:27:04.389Z"
4
4
  }
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.25",
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). We do NOT write
341
- # scheduled-tasks.json directly — that can't reliably target a just-switched-into
342
- # account, which is exactly the bug it caused.
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) and do NOT write the registry directly
790
- (can't reliably target a just-switched-into account).
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 a
814
- session directory already exists for the active account (2026-07-08):
815
- quit Claude, create the registration via a direct file write (the
816
- same heal() the update flow uses — see its module docstring for why
817
- this is equivalent to what create_scheduled_task would produce), then
818
- relaunch. Primary action for this case now instead of re-arm: no
819
- clipboard paste, no chat turn required. Re-arm remains the fallback
820
- for the rarer case where no session directory exists yet for the
821
- active account (see _build_menu) — fix 5 never fabricates one.
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 (2026-07-08): if the active account
3335
- # already has a session directory, _finish_schedule_setup can
3336
- # create the registration directly (heal() fix 5) with no
3337
- # clipboard paste needed. Fall back to re-arm only when fix 5
3338
- # has nowhere to write (no session dir yet for this account —
3339
- # it never fabricates one), since that's the one case where
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.25",
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.25",
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 EXISTING session
114
- directory (never fabricates a new directory). Same user-intent and
115
- worker_skill_ok guards as fix 4.
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 not session_dirs:
222
- continue # Desktop has never created a session for this
223
- # account on this box -- nowhere safe to write
224
- # Most-recently-touched session dir is the best available
225
- # guess for "the one Desktop is actively using".
226
- target_dir = max(session_dirs, key=lambda p: os.path.getmtime(p))
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 only if the active account (resolved the same
272
- way heal() does, via schedule_state's config.json lookup) has at least one
273
- EXISTING session directory to write into — fix 5 never fabricates one.
274
- Used by callers (the menu bar) to decide whether to offer an automatic
275
- "restart to finish setup" action or fall back to the manual re-arm prompt,
276
- BEFORE committing to a restart that would turn out to fix nothing."""
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__":