@m13v/s4l 1.7.7 → 1.7.8-rc.2

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.
@@ -734,6 +734,49 @@ def _keychain_safe_storage_ok(browser_label: str = "Chrome") -> tuple[bool, str]
734
734
  return False, (err_tail[-1] if err_tail else f"exit {r.returncode}")
735
735
 
736
736
 
737
+ # --- Keychain prompt-once latch (2026-08-06) --------------------------------
738
+ # Product decision: the user is asked for keychain access AT MOST ONCE, by the
739
+ # import attempt itself (copy_browser_cookies' `security` call blocks on the
740
+ # macOS dialog with no timeout, so the one prompt waits for a human answer).
741
+ # Any Deny/Cancel is recorded here, and every later connect_x skips the import
742
+ # path entirely and goes straight to manual login. Without this latch the setup
743
+ # agent saw "keychain locked, re-run connect_x", retried the import, and
744
+ # re-fired the password prompt at a user who had already said no (install
745
+ # a208aa6b, 2026-08-06: three prompts in five minutes). Lives OUTSIDE the
746
+ # per-platform profile dir so the reddit importer can honor the same decision.
747
+ _KEYCHAIN_DECLINED_LATCH = (
748
+ Path.home() / ".claude" / "browser-profiles" / "keychain-import-declined.json"
749
+ )
750
+
751
+
752
+ def _keychain_declined() -> dict | None:
753
+ """The recorded Deny/Cancel from a previous import attempt, or None."""
754
+ try:
755
+ return json.loads(_KEYCHAIN_DECLINED_LATCH.read_text())
756
+ except Exception:
757
+ return None
758
+
759
+
760
+ def _keychain_mark_declined(source: str, detail: str | None) -> None:
761
+ try:
762
+ _KEYCHAIN_DECLINED_LATCH.parent.mkdir(parents=True, exist_ok=True)
763
+ _KEYCHAIN_DECLINED_LATCH.write_text(json.dumps({
764
+ "declined_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
765
+ "source": source,
766
+ "detail": (detail or "")[:500],
767
+ }))
768
+ except Exception as e:
769
+ print(f"setup_twitter_auth: could not write keychain latch ({e})",
770
+ file=sys.stderr)
771
+
772
+
773
+ def _keychain_clear_declined() -> None:
774
+ try:
775
+ _KEYCHAIN_DECLINED_LATCH.unlink(missing_ok=True)
776
+ except Exception:
777
+ pass
778
+
779
+
737
780
  def _classify_import_error(detail: str | None) -> str:
738
781
  """Map a copy_browser_cookies.py error string to a structured type so the
739
782
  upper layers (connect_x, the user) can show a precise remediation instead
@@ -750,9 +793,13 @@ def _classify_import_error(detail: str | None) -> str:
750
793
  # - unlock/confirm prompt, click Cancel/Deny -> errSecUserCanceled (-128)
751
794
  # Both mean "the user actively refused", and both have the same fix (re-run
752
795
  # and click Allow), so collapse them into one type.
796
+ # "exit 128" is `security`'s bare exit code for errSecUserCanceled when
797
+ # stderr is empty (observed install a208aa6b 2026-08-06); without this
798
+ # match a user's Cancel was misread as a locked keychain and retried.
753
799
  if (("access denied" in d) or ("errsecauth" in d) or ("-25293" in d)
754
800
  or ("user canceled" in d) or ("user cancelled" in d)
755
- or ("errsecusercanceled" in d) or ("-128" in d)):
801
+ or ("errsecusercanceled" in d) or ("-128" in d)
802
+ or ("exit 128" in d)):
756
803
  return "keychain_acl_denied"
757
804
  if ("not be found in the keychain" in d) or ("errsecitemnotfound" in d):
758
805
  return "keychain_entry_missing"
@@ -1055,33 +1102,37 @@ def cmd_connect(args) -> dict:
1055
1102
  # there is a real session to copy. Otherwise fall through to manual login.
1056
1103
  will_import = has_importable and not manual_login
1057
1104
 
1058
- # 1c. Headless + Keychain pre-flight (#3 + #4, added 2026-06-02) — relevant
1059
- # ONLY to the import path. copy_browser_cookies.py must read the per-browser
1060
- # Safe Storage entry from the OS keychain; SSH/launchd-invoked processes get
1061
- # errSecAuthFailed silently (no prompt). The manual-login path skips this.
1062
- if will_import and _is_headless():
1063
- # Probe Chrome (the autoposter default); if that's denied, the rest are too.
1064
- kc_ok, kc_detail = _keychain_safe_storage_ok("Chrome")
1065
- if not kc_ok:
1066
- return {
1067
- "ok": True,
1068
- "connected": False,
1069
- "state": "keychain_locked",
1070
- "error_type": "keychain_locked",
1071
- "headless": True,
1072
- "keychain_detail": kc_detail,
1073
- "note": (
1074
- "Cookie import requires reading Chrome's Safe Storage from the macOS "
1075
- "Keychain, but this process can't access it (probably running over SSH "
1076
- "or another headless context). No GUI prompt is shown for this — macOS "
1077
- "denies access silently. To fix, run this once in the same session:\n"
1078
- " security unlock-keychain ~/Library/Keychains/login.keychain-db\n"
1079
- "Then re-run connect_x. Or connect X with manual login instead — that "
1080
- "signs in directly in the autoposter's own browser and needs no keychain."
1081
- ),
1082
- "remediation_cmd": "security unlock-keychain ~/Library/Keychains/login.keychain-db",
1083
- "cdp": CDP,
1084
- }
1105
+ # 1c. Keychain prompt-once gate (2026-08-06; replaces the 2026-06-02
1106
+ # headless probe). The old probe pre-read "Chrome Safe Storage" with a
1107
+ # 10s timeout, which (a) fired a SEPARATE SecurityAgent prompt before
1108
+ # the import's own one, (b) auto-dismissed that dialog from under the
1109
+ # user when the timeout killed `security`, and (c) was gated on
1110
+ # _is_headless(), which is True for EVERY MCP-spawned run (stdin is
1111
+ # never a tty), so all desktop installs got told "you're probably on
1112
+ # SSH; run security unlock-keychain and re-run connect_x" — advice the
1113
+ # setup agent obediently looped on, re-prompting a user who had already
1114
+ # clicked Cancel.
1115
+ # Now the import attempt itself is the single keychain prompt: its
1116
+ # `security` call blocks on the dialog with no timeout, its Deny/Cancel
1117
+ # comes back as a classifiable error, and the latch below makes the
1118
+ # answer permanent. A truly headless (SSH/launchd) run fails that same
1119
+ # call fast with "User interaction is not allowed" -> keychain_locked,
1120
+ # which is NOT latched (no prompt was ever shown).
1121
+ if will_import:
1122
+ if getattr(args, "retry_keychain_import", False):
1123
+ _keychain_clear_declined()
1124
+ declined = _keychain_declined()
1125
+ if declined:
1126
+ # The one allowed prompt was already answered with Deny/Cancel.
1127
+ # Skip the import path for good and fall through to manual login
1128
+ # in THIS call (will_import False forces open_login below), so
1129
+ # setup keeps moving instead of asking again.
1130
+ will_import = False
1131
+ keychain_declined = declined
1132
+ else:
1133
+ keychain_declined = None
1134
+ else:
1135
+ keychain_declined = None
1085
1136
 
1086
1137
  # 2. Import from the user's everyday browser (skipped entirely when we're on
1087
1138
  # the manual-login path — `will_import` False makes this loop a no-op).
@@ -1098,6 +1149,13 @@ def cmd_connect(args) -> dict:
1098
1149
  "detail": detail,
1099
1150
  "error_type": error_type,
1100
1151
  })
1152
+ if error_type == "keychain_acl_denied":
1153
+ # The user answered the ONE allowed keychain prompt with
1154
+ # Deny/Cancel. Record it forever and stop the sweep — trying the
1155
+ # next source browser would fire another prompt, and once means
1156
+ # once. Every later connect_x skips the import path entirely.
1157
+ _keychain_mark_declined(src, detail)
1158
+ break
1101
1159
  if not res.get("ok"):
1102
1160
  continue
1103
1161
  # 3. Re-validate after this source.
@@ -1151,6 +1209,9 @@ def cmd_connect(args) -> dict:
1151
1209
  rolled_up_error_type = (
1152
1210
  next(iter(distinct_error_types)) if len(distinct_error_types) == 1 else None
1153
1211
  )
1212
+ # A Deny/Cancel on THIS run's prompt, regardless of what other sources
1213
+ # failed with (the single-type roll-up above misses mixed-error runs).
1214
+ denied_now = any(a.get("error_type") == "keychain_acl_denied" for a in attempts)
1154
1215
  # Open a focused login window when the user asked for manual login, when the
1155
1216
  # keychain prompt was denied (import can't proceed), OR when there was nothing
1156
1217
  # to import in the first place — in that last case manual login is the ONLY
@@ -1158,7 +1219,7 @@ def cmd_connect(args) -> dict:
1158
1219
  open_login = (
1159
1220
  manual_login
1160
1221
  or not will_import
1161
- or rolled_up_error_type == "keychain_acl_denied"
1222
+ or denied_now
1162
1223
  )
1163
1224
 
1164
1225
  shown = False
@@ -1198,23 +1259,36 @@ def cmd_connect(args) -> dict:
1198
1259
 
1199
1260
  # Build the needs_login note from the rolled-up cause + whether a window opened.
1200
1261
  extra = {}
1201
- if rolled_up_error_type == "keychain_acl_denied":
1202
- # The user clicked Deny/Cancel on the keychain prompt. Auto-import would
1203
- # have worked; they just refused keychain access. Tell them the real fix
1204
- # (re-run and click Allow), and since we DID open a login window for this
1205
- # case, point at it as the keychain-free fallback.
1262
+ if denied_now:
1263
+ # The user clicked Deny/Cancel on the ONE allowed keychain prompt. That
1264
+ # answer is final (latched above): do NOT tell the caller to re-run the
1265
+ # import — that advice made setup agents loop and re-prompt. Manual
1266
+ # login is the path now, and a login window is already open for it.
1206
1267
  note = (
1207
- "It looks like you clicked Deny (or Cancel) on the macOS Keychain prompt. "
1208
- "To import your X session automatically, the autoposter needs to read Chrome's "
1209
- "\"Safe Storage\" key from your Keychain. Re-run connect_x and click Allow (or "
1210
- "Always Allow) on that prompt and the import will finish on its own. "
1211
- "If you'd rather not grant keychain access, there's already a Chrome window open "
1212
- "at the X login page"
1268
+ "You declined the macOS Keychain prompt, so automatic import is off — "
1269
+ "we only ask once and won't show that prompt again. "
1270
+ "A Chrome window is open at the X login page"
1213
1271
  + ("" if shown else " (look for a 'Google Chrome' window)")
1214
- + " — just log in there by hand and ask me to re-check. "
1272
+ + " — log in there by hand (username, password, 2FA if asked) and "
1273
+ "ask me to re-check. Do NOT re-run the cookie import. "
1215
1274
  "(Auto-import tried: " + ", ".join(sources) + ".)"
1216
1275
  )
1217
- extra["remediation"] = "rerun_connect_x_and_click_allow"
1276
+ extra["remediation"] = "use_manual_login"
1277
+ extra["keychain_prompt"] = "declined_latched"
1278
+ elif keychain_declined:
1279
+ # A PREVIOUS run already recorded the Deny/Cancel; this call skipped the
1280
+ # import path entirely (no prompt was shown) and went straight to the
1281
+ # manual-login window.
1282
+ note = (
1283
+ "You previously declined the macOS Keychain prompt, so I skipped the "
1284
+ "automatic import (we only ask once). A Chrome window is open at the "
1285
+ "X login page"
1286
+ + ("" if shown else " (look for a 'Google Chrome' window)")
1287
+ + " — log in there by hand and ask me to re-check. Do NOT re-run "
1288
+ "the cookie import; it will not prompt again."
1289
+ )
1290
+ extra["remediation"] = "use_manual_login"
1291
+ extra["keychain_prompt"] = "declined_latched"
1218
1292
  elif open_login:
1219
1293
  # Manual login: either the caller asked for it (--manual-login) or there
1220
1294
  # was no existing X session anywhere to import, so this is the only path.
@@ -1291,6 +1365,11 @@ def main() -> int:
1291
1365
  "A human finding the on-screen/VNC window plus password + 2FA "
1292
1366
  "routinely exceeds 90s, so the wait is generous. Prevents the "
1293
1367
  "detection race that misreports the handle as missing.")
1368
+ c.add_argument("--retry-keychain-import", action="store_true",
1369
+ help="Clear the keychain-declined latch so the import path may "
1370
+ "show the macOS keychain prompt ONE more time. The prompt "
1371
+ "is once-only by policy; pass this only when the user "
1372
+ "explicitly asks to retry the automatic import.")
1294
1373
  args = ap.parse_args()
1295
1374
 
1296
1375
  if args.cmd == "detect-sources":
@@ -86,7 +86,18 @@ def calculate_reddit_virality_score(thread):
86
86
  # S4L_REDDIT_VIRALITY_*) beat these; explicit args beat env.
87
87
  _BAR_DEFAULTS = {
88
88
  "twitter": {"pctile": 0.99, "min_sample": 200, "hours": 24},
89
- "reddit": {"pctile": 0.99, "min_sample": 200, "hours": 24},
89
+ # Reddit calibration (2026-08-05): discovery volume per install is ~100x
90
+ # smaller than X (~60 scored/day vs ~9k), so X's 200-in-24h min-sample
91
+ # left the bar disarmed on all but spike days (observed: armed only
92
+ # Jul 30-31, ungated 139-post day Aug 2). A 72h window (fine for threads
93
+ # that stay actionable ~7 days) with min_sample 50 keeps the bar armed at
94
+ # normal volume while still failing open on genuinely fresh installs.
95
+ # These platform defaults are the SINGLE source of truth for gate
96
+ # calibration; the S4L_<PLATFORM>_VIRALITY_* env overrides are debug-only
97
+ # and must not be set in plists or shipped configs. (The percentile is
98
+ # governed server-side by installations.posting_mode regardless of the
99
+ # pctile requested here.)
100
+ "reddit": {"pctile": 0.99, "min_sample": 50, "hours": 72},
90
101
  }
91
102
 
92
103
 
@@ -171,6 +171,19 @@ PENDING_PLAT_ARG=""
171
171
  [ -n "$PLATFORM" ] && PENDING_PLAT_ARG="--platform $PLATFORM"
172
172
  PENDING_CONVOS=$(python3 "$REPO_DIR/scripts/dm_engage_helper.py" pending $PENDING_PLAT_ARG --limit 30 2>/dev/null || echo "null")
173
173
 
174
+ # Phase D scope guard (2026-08-05). The prompt used to tell the model to re-query
175
+ # the queue itself with `dm_conversation.py pending`, which has NO platform
176
+ # filter. A platform-scoped run (reddit fires at :13/:43) would pick up another
177
+ # platform's needs_reply rows in the 1-3 min window before the owning lane
178
+ # (twitter fires at :14) logged its outbound, and both would send: the recipient
179
+ # got two near-duplicate replies and the loser's log-outbound was DEDUP BLOCKED,
180
+ # so the duplicate never appeared in dm_messages (38 duplicate pairs May-Aug
181
+ # 2026, first reported by HireFireTeam on 2026-07-09). Phase D now re-queries
182
+ # through the server-side-scoped helper, and this note pins the boundary inside
183
+ # the prompt itself.
184
+ PHASE_D_SCOPE_NOTE=""
185
+ [ -n "$PLATFORM" ] && PHASE_D_SCOPE_NOTE="HARD PLATFORM BOUNDARY: this run handles platform '$PLATFORM' ONLY. The pending query above is already scoped to it. If a row or query result for any other platform surfaces anyway, SKIP it silently and make no DB change to it: another lane owns that row, and replying from this run sends the recipient a duplicate message."
186
+
174
187
  if [ "$PENDING_CONVOS" = "null" ] || [ -z "$PENDING_CONVOS" ]; then
175
188
  log "No conversations needing replies. Checking platforms for new inbound messages..."
176
189
  else
@@ -454,10 +467,12 @@ counting (event_id dedup will block it but don't even try).
454
467
  2. Find Reddit conversations needing a reply (includes both newly-ingested
455
468
  chat rooms and any legacy PMs logged via step 1):
456
469
  ```bash
457
- cd ~/social-autoposter && python3 scripts/dm_conversation.py pending
470
+ cd ~/social-autoposter && python3 scripts/dm_engage_helper.py pending --platform reddit --limit 30
458
471
  ```
459
- Scope to Reddit when needed via their_author + platform columns. This is
460
- the authoritative list; don't reconstruct it from sidebar scrapes.
472
+ This list is server-side scoped to Reddit; rows for other platforms never
473
+ belong to this phase (do NOT use the cross-platform `dm_conversation.py
474
+ pending` here). This is the authoritative list; don't reconstruct it from
475
+ sidebar scrapes.
461
476
 
462
477
  3. For each Reddit PM/comment-reply surfaced by step 1 that isn't already in
463
478
  dms, create a row and log the inbound (chat rooms already have rows from
@@ -660,10 +675,12 @@ $PHASE_C_BLOCK
660
675
 
661
676
  ## PHASE D: Reply to all conversations with pending inbound messages
662
677
 
663
- After scanning, query for all conversations needing replies:
678
+ After scanning, query for conversations needing replies (server-side scoped to this run's platform):
664
679
  \`\`\`bash
665
- cd ~/social-autoposter && python3 scripts/dm_conversation.py pending
680
+ cd ~/social-autoposter && python3 scripts/dm_engage_helper.py pending $PENDING_PLAT_ARG --limit 30
666
681
  \`\`\`
682
+ Do NOT use \`dm_conversation.py pending\` or hit /api/v1/dms/pending directly: both are cross-platform and will show you rows that belong to other lanes.
683
+ $PHASE_D_SCOPE_NOTE
667
684
 
668
685
  Known conversations from the database that already need replies:
669
686
  $PENDING_CONVOS