switchroom 0.18.12 → 0.18.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/agent-scheduler/index.js +8 -0
  2. package/dist/auth-broker/index.js +63 -65
  3. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  4. package/dist/cli/notion-write-pretool.mjs +9 -1
  5. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  6. package/dist/cli/switchroom.js +952 -3126
  7. package/dist/host-control/main.js +216 -2862
  8. package/dist/vault/approvals/kernel-server.js +67 -0
  9. package/dist/vault/broker/server.js +98 -44
  10. package/package.json +1 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +49 -3
  12. package/telegram-plugin/dist/gateway/gateway.js +656 -2326
  13. package/telegram-plugin/dist/server.js +65 -3
  14. package/telegram-plugin/format.ts +19 -0
  15. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  16. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  17. package/telegram-plugin/gateway/gateway.ts +221 -73
  18. package/telegram-plugin/history.ts +51 -0
  19. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  20. package/telegram-plugin/model-unavailable.ts +41 -11
  21. package/telegram-plugin/outbound-field-redact.ts +69 -0
  22. package/telegram-plugin/render/render.ts +32 -14
  23. package/telegram-plugin/scoped-approval.ts +11 -2
  24. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  25. package/telegram-plugin/secret-detect/index.ts +12 -56
  26. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  27. package/telegram-plugin/send-gate.test.ts +25 -6
  28. package/telegram-plugin/send-gate.ts +82 -8
  29. package/telegram-plugin/session-tail.ts +82 -7
  30. package/telegram-plugin/subagent-watcher.ts +71 -16
  31. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  32. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  33. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  34. package/telegram-plugin/tests/history.test.ts +115 -0
  35. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  36. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  37. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  38. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  39. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  40. package/telegram-plugin/tests/render/render.test.ts +88 -0
  41. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  42. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  43. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  44. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  45. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  46. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  47. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  48. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  49. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -50,11 +50,13 @@
50
50
  *
51
51
  * SAFETY / ROLLOUT
52
52
  * ----------------
53
- * Feature-flagged and default-OFF (`SWITCHROOM_TELEGRAM_SEND_GATE=1` to enable,
54
- * following the `SWITCHROOM_*=== '1'` gateway flag convention). When disabled,
55
- * `gate()` is a pure passthrough to the wrapped call — zero behaviour change.
56
- * This is PR 1 of 3; priority-class shedding + degraded mode (PR 2) and
57
- * observability + operator alert (PR 3) build on the counters exposed here.
53
+ * ON BY DEFAULT in every install — an escape hatch, not an opt-in feature.
54
+ * `SWITCHROOM_TELEGRAM_SEND_GATE=0` (or `false`/`off`/`no`) is the safety valve
55
+ * that disables it without a rebuild, following the repo's default-on kill-
56
+ * switch convention (`midTurnFloorEnabled`, `SWITCHROOM_RATE_LIMIT_OVERAGE=0`).
57
+ * When disabled, `gate()` is a pure passthrough to the wrapped call — zero
58
+ * behaviour change. Priority-class shedding + degraded mode and observability +
59
+ * operator alert build on the counters exposed here.
58
60
  *
59
61
  * DETERMINISM / TESTABILITY
60
62
  * -------------------------
@@ -356,6 +358,26 @@ interface PendingEdit {
356
358
  promise: Promise<unknown>
357
359
  resolve: (v: unknown) => void
358
360
  reject: (e: unknown) => void
361
+ /**
362
+ * Effective priority of the CURRENTLY-queued edit for this message. Set when
363
+ * the pending edit is created and UPGRADED (never downgraded) on coalesce, so
364
+ * that a `critical` edit coalescing onto a non-critical driver still gets the
365
+ * critical fail-fast treatment. The driver reads THIS (not the driver-start
366
+ * opts) to decide fail-fast vs unbounded admit (F2, review 2026-07-12).
367
+ */
368
+ priorityClass: PriorityClass
369
+ }
370
+
371
+ /** Total order over priority classes: cosmetic < useful < critical. */
372
+ const PRIORITY_RANK: Record<PriorityClass, number> = {
373
+ cosmetic: 0,
374
+ useful: 1,
375
+ critical: 2,
376
+ }
377
+
378
+ /** Return the HIGHER-priority of two classes (upgrade-only; never downgrades). */
379
+ function maxPriority(a: PriorityClass, b: PriorityClass): PriorityClass {
380
+ return PRIORITY_RANK[b] > PRIORITY_RANK[a] ? b : a
359
381
  }
360
382
 
361
383
  interface MessageEditState {
@@ -806,7 +828,37 @@ export function createSendGate(config: SendGateConfig): SendGate {
806
828
  continue
807
829
  }
808
830
 
809
- await admit(bucketsFor(opts))
831
+ // A `critical` edit must NEVER block unbounded (part3-design §3). Mirror
832
+ // the non-edit critical path in `gate`: admit via the priority-aware loop
833
+ // so a flood-wait ban longer than the fail-fast ceiling rejects with a
834
+ // structured FLOOD_WAIT_ACTIVE — and a window that EXTENDS past the
835
+ // ceiling while we wait out a short window converts to fail-fast too (M2,
836
+ // review PR #3106) — instead of the unbounded `admit` below, which would
837
+ // re-introduce the multi-hour reply wedge the send-gate exists to
838
+ // eliminate (F4, review 2026-07-11). Non-critical edits keep PR 1's
839
+ // unbounded coalescing admit unchanged.
840
+ //
841
+ // Read the CURRENT pending edit's class (`p.priorityClass`), NOT the
842
+ // driver-start `opts`: a `critical` edit that coalesced onto a driver
843
+ // started by a non-critical edit upgraded `p.priorityClass`, and MUST
844
+ // fail-fast here rather than ride the non-critical unbounded admit for
845
+ // the whole ban (F2, review 2026-07-12).
846
+ if (p.priorityClass === 'critical') {
847
+ const outcome = await admitPriority(bucketsFor(opts), 'critical')
848
+ if (outcome.result === 'failfast') {
849
+ counters.failedFast++
850
+ const retryAfterSec = Math.ceil((outcome.untilTs - clock.now()) / 1000)
851
+ openScopedWindowsForOpts(opts, outcome.untilTs)
852
+ // Reject THIS edit's own promise (fail fast) and loop: a distinct
853
+ // newer edit that arrived during the wait owns a fresh state.pending
854
+ // and is handled on the next iteration.
855
+ p.reject(makeFloodWaitActiveError(retryAfterSec, outcome.untilTs, null))
856
+ continue
857
+ }
858
+ // outcome.result === 'ok' → admitPriority already consumed the buckets.
859
+ } else {
860
+ await admit(bucketsFor(opts))
861
+ }
810
862
  // Reserve the send-start time BEFORE awaiting the network so the floor
811
863
  // is measured from send start (matches the per-message serialization).
812
864
  state.lastSentMs = clock.now()
@@ -874,6 +926,17 @@ export function createSendGate(config: SendGateConfig): SendGate {
874
926
  // a coalesced revert so nothing needless hits the API. All callers share the
875
927
  // single pending promise, which resolves with the coalesced send's result.
876
928
  if (state.pending) {
929
+ // Upgrade (never downgrade) the queued edit's effective priority. A
930
+ // `critical` edit coalescing onto a `useful`/`cosmetic` pending edit must
931
+ // ride the driver's critical fail-fast path — otherwise the critical work
932
+ // rides the non-critical unbounded admit and blocks for a whole flood ban
933
+ // (F2, review 2026-07-12). We upgrade even when the hash is unchanged (a
934
+ // no-op payload from a critical caller still deserves fail-fast, not an
935
+ // unbounded block); a lower-priority coalesce leaves the class intact.
936
+ state.pending.priorityClass = maxPriority(
937
+ state.pending.priorityClass,
938
+ opts.priorityClass ?? 'useful',
939
+ )
877
940
  if (state.pending.hash !== hash) {
878
941
  counters.coalesced++
879
942
  state.pending.hash = hash
@@ -905,6 +968,7 @@ export function createSendGate(config: SendGateConfig): SendGate {
905
968
  promise,
906
969
  resolve,
907
970
  reject,
971
+ priorityClass: opts.priorityClass ?? 'useful',
908
972
  }
909
973
  state.pending = pending
910
974
  if (!state.running) void drive(state, opts)
@@ -976,7 +1040,17 @@ export function createSendGate(config: SendGateConfig): SendGate {
976
1040
  return { gate, openFloodWindow, stats }
977
1041
  }
978
1042
 
979
- /** Read the feature flag using the standard `SWITCHROOM_*=== '1'` convention. */
1043
+ /**
1044
+ * The send gate is an ESCAPE HATCH, not an opt-in feature: it is ON BY DEFAULT
1045
+ * in every install and can be disabled as a safety valve. Mirrors the repo's
1046
+ * default-on kill-switch convention (`midTurnFloorEnabled`, `PIN_STATUS_WHILE_
1047
+ * WORKING`, `SWITCHROOM_RATE_LIMIT_OVERAGE`): enabled unless
1048
+ * `SWITCHROOM_TELEGRAM_SEND_GATE` is explicitly set to a falsey/off value
1049
+ * (`0`/`false`/`off`/`no`, case-insensitive, trimmed). Unset → enabled.
1050
+ */
980
1051
  export function sendGateEnabledFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
981
- return env.SWITCHROOM_TELEGRAM_SEND_GATE === '1'
1052
+ const v = env.SWITCHROOM_TELEGRAM_SEND_GATE
1053
+ if (v == null) return true
1054
+ const t = v.trim().toLowerCase()
1055
+ return !(t === '0' || t === 'false' || t === 'off' || t === 'no')
982
1056
  }
@@ -41,7 +41,8 @@ function isMultiAgentEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
41
41
  return env.PROGRESS_CARD_MULTI_AGENT !== '0'
42
42
  }
43
43
  import { classifyClaudeError, type OperatorEventKind } from './operator-events.js'
44
- import { createToolLabelSidecar, type ToolLabelSidecar } from './tool-label-sidecar.js'
44
+ import { isTransientUpstreamSignal } from './model-unavailable.js'
45
+ import { createToolLabelSidecar, type ToolLabelSidecar, type SidecarOptions } from './tool-label-sidecar.js'
45
46
  import { isModelSentinel } from './model-label.js'
46
47
 
47
48
  /** Match Claude Code's cli.js VX() function. */
@@ -668,13 +669,26 @@ export function detectErrorInTranscriptLine(
668
669
  typeof obj.apiErrorStatus === 'number' ? obj.apiErrorStatus : null
669
670
  const errStr = typeof obj.error === 'string' ? obj.error : ''
670
671
  const text = extractAssistantText(obj)
671
- // A 429 in this shape is a subscription usage-limit hit (it carries
672
- // a reset time) — classify it quota-exhausted so the operator event
673
- // resolves to an auto-fallback-eligible kind. Other statuses fall
672
+ // A 429 in this shape is USUALLY a subscription usage-limit wall (it
673
+ // carries a reset time) — classify it quota-exhausted so the operator
674
+ // event resolves to an auto-fallback-eligible kind that always shows the
675
+ // "model unavailable" card. BUT Anthropic also emits a 429 for a TRANSIENT
676
+ // per-account burst / RPM throttle whose wording explicitly negates the
677
+ // account-quota reading ("This request would exceed your account's rate
678
+ // limit … not your usage limit"). That is a self-healing few-second throttle
679
+ // Claude Code retries internally — blanket-labeling it quota-exhausted fired
680
+ // a false scary card on the fleet (carrie incident, 2026-07-12). So a 429 is
681
+ // only quota-exhausted when it LACKS an explicit transient-burst marker;
682
+ // with one, classify it rate-limited so it takes the calm path (no card, no
683
+ // failover). Keyed on the explicit transient NEGATION (canonical list in
684
+ // model-unavailable.ts) — an ambiguous 429 that merely says "limit" stays
685
+ // quota-exhausted, biasing toward surfacing a real wall. Other statuses fall
674
686
  // through to the shared classifier.
675
687
  const kind: OperatorEventKind =
676
688
  status === 429
677
- ? 'quota-exhausted'
689
+ ? isTransientUpstreamSignal(`${text}\n${errStr}`)
690
+ ? 'rate-limited'
691
+ : 'quota-exhausted'
678
692
  : classifyClaudeError({ type: errStr, status, message: text })
679
693
  // An `isApiErrorMessage` line is Claude surfacing the failure to the
680
694
  // user — terminal by construction (Claude writes this shape only
@@ -781,6 +795,13 @@ export interface SessionTailConfig {
781
795
  claudeHome?: string
782
796
  /** How often to re-scan for a new active session file (ms). Default 500. */
783
797
  rescanIntervalMs?: number
798
+ /**
799
+ * Idle window before an inactive sub-agent FSWatcher (and its PreToolUse
800
+ * sidecar) is reaped, in ms. Defaults to 5 minutes — well past the 99th-
801
+ * percentile sub-agent completion time. Exposed only so tests can drive the
802
+ * reap deterministically without a 5-minute wall-clock wait.
803
+ */
804
+ subTailIdleReapMs?: number
784
805
  /** Optional logger. */
785
806
  log?: (msg: string) => void
786
807
  /** Called for each parsed event. */
@@ -791,6 +812,18 @@ export interface SessionTailConfig {
791
812
  * TODO(Phase 4b): wire this to the gateway's emitOperatorEvent pipeline.
792
813
  */
793
814
  onOperatorEvent?: (event: TailOperatorEvent) => void
815
+ /**
816
+ * PreToolUse sidecar factory. Defaults to the real `createToolLabelSidecar`;
817
+ * production never sets this. It exists as a dependency-injection seam so the
818
+ * M1 FD-leak reap test can drive a fake sidecar per session WITHOUT
819
+ * `vi.mock`-ing the shared `tool-label-sidecar` module: bun's `vi.mock` is
820
+ * process-global (not file-scoped like vitest), and the CI bun-test shard
821
+ * runs the whole `tests/` dir in ONE process, so a module-mock here would
822
+ * leak into the real `tool-label-sidecar.test.ts` suite and break it. This
823
+ * mirrors the repo's bun-safe injection precedent (`vault-write-posture`'s
824
+ * optional `deps` param).
825
+ */
826
+ createSidecar?: (opts: SidecarOptions) => ToolLabelSidecar
794
827
  }
795
828
 
796
829
  export interface SessionTailHandle {
@@ -887,6 +920,7 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
887
920
  // $TELEGRAM_STATE_DIR/tool-labels-<session_id>.jsonl. Each sub-agent
888
921
  // has its OWN sessionId (its jsonl filename stem), so we key by that.
889
922
  const sidecars = new Map<string, ToolLabelSidecar>()
923
+ const createSidecar = config.createSidecar ?? createToolLabelSidecar
890
924
  const stateDirForSidecar = process.env.TELEGRAM_STATE_DIR ?? null
891
925
  function sessionIdForFile(file: string | null): string | null {
892
926
  if (!file) return null
@@ -898,7 +932,7 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
898
932
  const existing = sidecars.get(sessionId)
899
933
  if (existing) return existing
900
934
  try {
901
- const s = createToolLabelSidecar({ stateDir: stateDirForSidecar, sessionId })
935
+ const s = createSidecar({ stateDir: stateDirForSidecar, sessionId })
902
936
  sidecars.set(sessionId, s)
903
937
  // Real-time draft-mirror source: emit a `tool_label` event the moment
904
938
  // the hook writes a label (flush-independent), so the gateway can
@@ -913,6 +947,22 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
913
947
  return null
914
948
  }
915
949
  }
950
+ /**
951
+ * M1 FD-leak fix: stop and forget the PreToolUse sidecar for a session that
952
+ * has ended (a rotated-away parent session, or a reaped sub-agent). Each
953
+ * sidecar holds its own stat-poll timer (and, on real fs, a file handle);
954
+ * pre-fix they were only reaped in `stop()`, so every session rotation
955
+ * (`/clear`, compaction → new sessionId) and every finished sub-agent leaked
956
+ * one for the gateway's life. Idempotent — a no-op when the key is absent.
957
+ */
958
+ function stopSidecar(sessionId: string | null): void {
959
+ if (!sessionId) return
960
+ const s = sidecars.get(sessionId)
961
+ if (!s) return
962
+ try { s.stop() } catch { /* ignore */ }
963
+ sidecars.delete(sessionId)
964
+ }
965
+
916
966
  function decorate(ev: SessionEvent, sessionId: string | null): SessionEvent {
917
967
  if (!sessionId) return ev
918
968
  if (ev.kind !== 'tool_use' && ev.kind !== 'sub_agent_tool_use') return ev
@@ -1024,6 +1074,19 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
1024
1074
  try { watcher.close() } catch { /* ignore */ }
1025
1075
  watcher = null
1026
1076
  }
1077
+ // M1 FD-leak fix: we are rotating the PARENT tail off `currentFile`; its
1078
+ // PreToolUse sidecar is no longer needed (its watcher just closed). Reap it
1079
+ // so `/clear`- and compaction-driven session rotations don't accumulate one
1080
+ // idle sidecar poll-timer per rotation. Parent session ids are the JSONL
1081
+ // stem (`<uuid>`); sub-agent sidecars are keyed by `agent-<id>` stems and
1082
+ // owned by their sub-tail (reaped in `reapIdleSubTails`), so this never
1083
+ // stops a sidecar a live sub-tail still depends on. A later re-attach to
1084
+ // this same file transparently recreates the sidecar via `ensureSidecar`.
1085
+ const rotatedAwaySid = sessionIdForFile(currentFile)
1086
+ const nextSid = sessionIdForFile(file)
1087
+ if (rotatedAwaySid != null && rotatedAwaySid !== nextSid) {
1088
+ stopSidecar(rotatedAwaySid)
1089
+ }
1027
1090
  currentFile = file
1028
1091
  const prior = fileCursors.get(file)
1029
1092
  if (prior != null) {
@@ -1113,7 +1176,12 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
1113
1176
  * very-long task (rescanSubagents picks the file back up on the
1114
1177
  * next tick if it grows).
1115
1178
  */
1116
- const IDLE_FSWATCH_TTL_MS = 5 * 60 * 1000
1179
+ // Floor-clamp the reap window: a 0 / negative override would make
1180
+ // `reapIdleSubTails` treat every live sub-tail as instantly idle
1181
+ // (cutoff = Date.now() - 0 ≥ lastActivityAt), reaping every live
1182
+ // sidecar on the first tick. Only tests set this today, but the clamp
1183
+ // makes the footgun unreachable — the smallest sane window is 1s.
1184
+ const IDLE_FSWATCH_TTL_MS = Math.max(1000, config.subTailIdleReapMs ?? 5 * 60 * 1000)
1117
1185
 
1118
1186
  function readSub(t: SubTail): void {
1119
1187
  if (stopped) return
@@ -1254,6 +1322,13 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
1254
1322
  try { t.watcher.close() } catch { /* ignore */ }
1255
1323
  t.watcher = null
1256
1324
  }
1325
+ // M1 FD-leak fix: reap the sub-agent's PreToolUse sidecar alongside its
1326
+ // file watcher. Sub-agent sidecars are keyed by the sub file's stem
1327
+ // (`agent-<id>`), created lazily by `decorate` while reading the sub
1328
+ // JSONL. Pre-fix `reapIdleSubTails` closed the sub-tail watcher but left
1329
+ // the sidecar (and its poll timer) alive until `stop()`, so a long-lived
1330
+ // agent leaked one sidecar per finished sub-agent.
1331
+ stopSidecar(sessionIdForFile(t.file))
1257
1332
  subTails.delete(file)
1258
1333
  log?.(`session-tail: reaped idle sub ${t.agentId} (${file})`)
1259
1334
  }
@@ -1779,21 +1779,39 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1779
1779
  maybySendStateTransition(agentId)
1780
1780
  }
1781
1781
 
1782
- // Set up FSWatcher
1783
- try {
1784
- tail.watcher = fs.watch(filePath, () => {
1785
- if (stopped) return
1786
- const entry = registry.get(agentId)
1787
- const t = tails.get(agentId)
1788
- if (!entry || !t) return
1789
- checkBootPromotionGrowth(entry, t, nowFn())
1790
- readSubTail(entry, t, nowFn(), (desc) => {
1791
- log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
1792
- }, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
1793
- maybySendStateTransition(agentId)
1794
- })
1795
- } catch (err) {
1796
- log?.(`subagent-watcher: fs.watch failed for ${agentId}: ${(err as Error).message}`)
1782
+ // Set up FSWatcher.
1783
+ //
1784
+ // FD-leak fix (H2): only open an inotify watch when it can do real work.
1785
+ // A historical entry that is done-at-boot (already `scheduleTerminalCleanup`d
1786
+ // just above) or a stale `running` entry that will never be promoted (no
1787
+ // `bootPromotionPending`) has NO live transition left to observe —
1788
+ // `checkStalls` skips every historical entry, so such an entry never
1789
+ // reaches a terminal transition and `scheduleTerminalCleanup` never runs
1790
+ // for it. Opening an `fs.watch` for one leaks its inotify FD for the whole
1791
+ // gateway lifetime (the dozens-to-hundreds of dead prior-session
1792
+ // `running` JSONLs a 24/7 agent accumulates). The poll loop still reads
1793
+ // any registered running entry defensively, so dropping the watcher here
1794
+ // costs nothing but the leaked FD. Open the watch only for a live worker
1795
+ // or a historical one still awaiting boot-promotion growth confirmation.
1796
+ const needsWatcher = !entry.historical || entry.bootPromotionPending != null
1797
+ if (needsWatcher) {
1798
+ try {
1799
+ tail.watcher = fs.watch(filePath, () => {
1800
+ if (stopped) return
1801
+ const entry = registry.get(agentId)
1802
+ const t = tails.get(agentId)
1803
+ if (!entry || !t) return
1804
+ checkBootPromotionGrowth(entry, t, nowFn())
1805
+ readSubTail(entry, t, nowFn(), (desc) => {
1806
+ log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
1807
+ }, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
1808
+ maybySendStateTransition(agentId)
1809
+ })
1810
+ } catch (err) {
1811
+ log?.(`subagent-watcher: fs.watch failed for ${agentId}: ${(err as Error).message}`)
1812
+ }
1813
+ } else {
1814
+ log?.(`subagent-watcher: ${agentId} historical/terminal at registration — not opening an FSWatcher (no live transition to observe)`)
1797
1815
  }
1798
1816
  }
1799
1817
 
@@ -1855,7 +1873,17 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1855
1873
  }
1856
1874
  if (n >= pending.deadlineAt) {
1857
1875
  entry.bootPromotionPending = undefined
1858
- log?.(`subagent-watcher: ${entry.agentId} never observed post-boot JSONL growth within the window leaving historical/orphan (not promoting; avoids synthesising a stale 'completed' handback from a worker killed before this restart)`)
1876
+ // FD-leak fix (H2): the boot-promotion window elapsed with no growth,
1877
+ // so this entry is now a permanent historical/orphan that `checkStalls`
1878
+ // skips — it will never reach terminal cleanup. Release the FSWatcher we
1879
+ // opened purely for growth-confirmation now; the poll loop remains the
1880
+ // fallback reader if the file ever resumes (rescan re-registers on a
1881
+ // fresh transition).
1882
+ if (tail.watcher) {
1883
+ try { tail.watcher.close() } catch { /* ignore */ }
1884
+ tail.watcher = null
1885
+ }
1886
+ log?.(`subagent-watcher: ${entry.agentId} never observed post-boot JSONL growth within the window — leaving historical/orphan (not promoting; avoids synthesising a stale 'completed' handback from a worker killed before this restart); released growth-confirmation FSWatcher`)
1859
1887
  }
1860
1888
  }
1861
1889
 
@@ -2386,8 +2414,35 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2386
2414
  * We walk: <agentDir>/.claude/projects/ → each project dir → each session dir
2387
2415
  * → subagents/ → agent-*.jsonl
2388
2416
  */
2417
+ /**
2418
+ * FD-leak fix (H1): close and forget any directory FSWatcher whose watched
2419
+ * directory no longer exists on disk. Every new Claude session mints a fresh
2420
+ * `<sessionId>/subagents/` dir (and `workflows/wf_<id>` sub-dirs); when Claude
2421
+ * Code reaps a prior session's directory, the scan loop below simply skips
2422
+ * the vanished path (`existsSync` guard) WITHOUT closing the watcher it had
2423
+ * opened for it — Node only frees the inotify FD on `.close()`, so each dead
2424
+ * session leaked one dir watcher for the whole gateway lifetime. Sweeping on
2425
+ * every rescan releases them deterministically the tick after the dir goes
2426
+ * away, and also covers a slug that transitions to "foreign" (skipped by the
2427
+ * allow-list filter below). Deleting the current key while iterating a Map's
2428
+ * entries() is safe in JS.
2429
+ */
2430
+ function pruneVanishedDirWatchers(): void {
2431
+ for (const [dirPath, w] of dirWatchers) {
2432
+ if (!fs.existsSync(dirPath)) {
2433
+ try { w.close() } catch { /* ignore */ }
2434
+ dirWatchers.delete(dirPath)
2435
+ log?.(`subagent-watcher: released dir watcher for vanished ${dirPath}`)
2436
+ }
2437
+ }
2438
+ }
2439
+
2389
2440
  function rescanSubagentDirs(): void {
2390
2441
  if (stopped) return
2442
+ // Release watchers for directories reaped since the last tick BEFORE the
2443
+ // early-returns below, so a projectsRoot that vanishes entirely still frees
2444
+ // every child dir watcher.
2445
+ pruneVanishedDirWatchers()
2391
2446
  const claudeHome = join(agentDir, '.claude')
2392
2447
  const projectsRoot = join(claudeHome, 'projects')
2393
2448
  if (!fs.existsSync(projectsRoot)) return
@@ -261,14 +261,27 @@ describe('gateway wiring — the leash', () => {
261
261
  'utf8',
262
262
  )
263
263
 
264
+ /**
265
+ * One anchor + one span for the TTL sweep, shared by every pin below.
266
+ *
267
+ * They drifted apart before: one pin still anchored on the inline `for` loop the
268
+ * shared-sweep extraction had deleted, so it silently matched nothing. A single
269
+ * definition means a future move breaks every pin at once — loudly — instead of
270
+ * quietly disarming one of them.
271
+ *
272
+ * The span must cover the whole `onExpire` body (~4.2k chars today).
273
+ */
274
+ const SWEEP_ANCHOR = 'sweepPermissionTtl({'
275
+ const SWEEP_SPAN = 6000
276
+
264
277
  it('the TTL sweep routes through the SHARED shouldExpirePermission()', () => {
265
278
  // This pin is no longer the safety net — the behavioural tests above are, and
266
279
  // they now drive the same `shouldExpirePermission` the gateway does, so deleting
267
280
  // the leash turns them RED. This only guards the WIRING: that the sweep can't
268
281
  // regrow a private inline check that drifts from what the test exercises.
269
- const sweepStart = GATEWAY_SRC.indexOf('sweepPermissionTtl({')
282
+ const sweepStart = GATEWAY_SRC.indexOf(SWEEP_ANCHOR)
270
283
  expect(sweepStart).toBeGreaterThan(-1)
271
- const sweep = GATEWAY_SRC.slice(sweepStart, sweepStart + 3500)
284
+ const sweep = GATEWAY_SRC.slice(sweepStart, sweepStart + SWEEP_SPAN)
272
285
  expect(GATEWAY_SRC).toContain('sweepPermissionTtl({')
273
286
  // …and must NOT carry its own copy of the leash or the raw TTL comparison.
274
287
  // …and the gateway must not regrow a private inline leash check.
@@ -288,9 +301,27 @@ describe('gateway wiring — the leash', () => {
288
301
 
289
302
  it('nothing in the permission path auto-approves', () => {
290
303
  // Never, under any circumstance. A held approval that "times out" into an
291
- // allow would be the worst possible reading of Ken's call.
292
- const sweepStart = GATEWAY_SRC.indexOf('for (const [k, v] of pendingPermissions) {')
293
- const sweep = GATEWAY_SRC.slice(sweepStart, sweepStart + 2500)
304
+ // ALLOW would be the worst possible reading of Ken's call — the other half of
305
+ // no-self-escalation.
306
+ //
307
+ // This pin was DEAD. It anchored on `for (const [k, v] of pendingPermissions) {`,
308
+ // the inline loop that the shared-sweep extraction deleted. `indexOf` returned
309
+ // -1, `slice(-1, 2499)` returned the EMPTY STRING, and `not.toContain` passes on
310
+ // "" — so the sweep could be changed to dispatch `behavior: 'allow'` and all 13
311
+ // tests stayed green. Exactly the failure class this file exists to prevent,
312
+ // inside the file that exists to prevent it.
313
+ //
314
+ // Two rules now, and they apply to every grep pin here:
315
+ // 1. ASSERT THE ANCHOR RESOLVES. A silent -1 is what turns a pin into a lie.
316
+ // 2. Assert something POSITIVE inside the window, so a window that is too
317
+ // small (or empty) fails loudly instead of vacuously passing a `not`.
318
+ const sweepStart = GATEWAY_SRC.indexOf(SWEEP_ANCHOR)
319
+ expect(sweepStart).toBeGreaterThan(-1)
320
+ const sweep = GATEWAY_SRC.slice(sweepStart, sweepStart + SWEEP_SPAN)
321
+
322
+ // POSITIVE: proves the window actually covers the verdict dispatch.
323
+ expect(sweep).toContain("behavior: 'deny',")
324
+ // …and the sweep may only ever deny. Never allow.
294
325
  expect(sweep).not.toContain("behavior: 'allow'")
295
326
  })
296
327
  })
@@ -41,6 +41,43 @@ import { createSweepableStore } from '../gateway/pending-state-stores.js'
41
41
  import { StagingMap } from '../secret-detect/staging.js'
42
42
  import { InlineKeyboard } from 'grammy'
43
43
 
44
+ // Mock the auth-broker client so the `auth:use:` swap path is observable
45
+ // (spy on setActive) without a live UDS broker. Only handleAuthDashboardCallback
46
+ // touches getAuthBrokerClient, so this is inert for every other family here.
47
+ //
48
+ // The spy is created *inside* the factory and re-exported as a test-only
49
+ // handle (`__setActiveSpy`) rather than closed over from a `vi.hoisted`
50
+ // binding: bun's vitest-compat layer implements `vi.mock`/`vi.fn` but NOT
51
+ // `vi.hoisted`, so a hoisted-closure mock throws `vi.hoisted is not a
52
+ // function` under `bun test` (CI's bun-test-run shard runs this whole dir).
53
+ // A single `setActive` instance is closed over and returned by every
54
+ // `getAuthBrokerClient()` call, so the handler and the tests observe the
55
+ // same spy.
56
+ vi.mock('../gateway/auth-broker-client.js', () => {
57
+ const setActive = vi.fn(async () => ({ active: 'acct-b', fanned: ['a1'] }))
58
+ return {
59
+ getAuthBrokerClient: vi.fn(async () => ({ setActive })),
60
+ }
61
+ })
62
+ import { getAuthBrokerClient } from '../gateway/auth-broker-client.js'
63
+ // The mocked getAuthBrokerClient closes over one shared `setActive` spy and
64
+ // returns it on every call, so resolving the client here yields the SAME spy
65
+ // the handler observes. A getter defers the read to test-run time (after the
66
+ // mock is live under both runners); a beforeEach caches the resolved spy.
67
+ let resolvedSetActiveSpy: ReturnType<typeof vi.fn> | undefined
68
+ const brokerMock = {
69
+ get setActive(): ReturnType<typeof vi.fn> {
70
+ if (!resolvedSetActiveSpy) throw new Error('setActive spy not resolved yet')
71
+ return resolvedSetActiveSpy
72
+ },
73
+ }
74
+ beforeEach(async () => {
75
+ const client = (await getAuthBrokerClient('test-agent')) as {
76
+ setActive: ReturnType<typeof vi.fn>
77
+ }
78
+ resolvedSetActiveSpy = client.setActive
79
+ })
80
+
44
81
  // ── Fakes ────────────────────────────────────────────────────────────────
45
82
 
46
83
  interface FakeCtxOpts {
@@ -568,6 +605,34 @@ describe('handleAuthDashboardCallback', () => {
568
605
  show_alert: false,
569
606
  })
570
607
  })
608
+
609
+ // Security gate (found in an adversarial security review): the auth: family
610
+ // is a mutating callback family (auth:use:<label> → broker.setActive, a
611
+ // fleet-wide OAuth account swap) and MUST enforce the same allowFrom gate as
612
+ // every sibling family. Without the gate, any tapper on an admin forum/
613
+ // supergroup with an empty group allowFrom could swap the active account.
614
+ it('rejects an auth:use swap from a sender not on allowFrom (never reaches setActive)', async () => {
615
+ const { deps } = makeDeps() // allowFrom = ['111','222']
616
+ const h = createCallbackQueryHandlers(deps)
617
+ brokerMock.setActive.mockClear()
618
+ const { ctx, raw } = makeCtx({ senderId: '999', data: 'auth:use:acct-b' })
619
+ await h.handleAuthDashboardCallback(ctx)
620
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
621
+ // The load-bearing assertion: the swap must not fire for an unauthorized tap.
622
+ expect(brokerMock.setActive).not.toHaveBeenCalled()
623
+ })
624
+
625
+ it('lets an allowFrom sender through to the setActive swap path', async () => {
626
+ const { deps } = makeDeps()
627
+ const h = createCallbackQueryHandlers(deps)
628
+ brokerMock.setActive.mockClear()
629
+ const { ctx, raw } = makeCtx({ senderId: '111', data: 'auth:use:acct-b' })
630
+ await h.handleAuthDashboardCallback(ctx)
631
+ expect(brokerMock.setActive).toHaveBeenCalledWith('acct-b')
632
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith(
633
+ expect.objectContaining({ text: expect.stringContaining('Switched fleet → acct-b') }),
634
+ )
635
+ })
571
636
  })
572
637
 
573
638
  // ── vg:* — grant wizard + management ────────────────────────────────────
@@ -77,6 +77,63 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
77
77
  expect(truncIdx).toBeGreaterThan(redactIdx) // mask BEFORE the slice
78
78
  })
79
79
 
80
+ it('ask_user: redacts question + option labels at entry, BEFORE the send', () => {
81
+ // F1 — ask_user sent the question + button labels unredacted. The scrub
82
+ // must run at the top of executeAskUser (via redactAskUserFields), before
83
+ // the keyboard is built and the question is sent to Telegram.
84
+ const start = src.indexOf('async function executeAskUser(')
85
+ const redactIdx = src.indexOf('redactAskUserFields(args.question, args.options', start)
86
+ const keyboardIdx = src.indexOf('const keyboard = new InlineKeyboard()', start)
87
+ const sendIdx = src.indexOf('sendRichMessage(args.chatId, richMessage(args.question)', start)
88
+ expect(start).toBeGreaterThan(0)
89
+ expect(redactIdx).toBeGreaterThan(start)
90
+ expect(keyboardIdx).toBeGreaterThan(redactIdx) // mask BEFORE labels become buttons
91
+ expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the question is sent
92
+ })
93
+
94
+ it('send_checklist: redacts title + task text BEFORE rawSendChecklist', () => {
95
+ // F2 — checklist title + task strings were sent unredacted.
96
+ const start = src.indexOf('async function executeSendChecklist(')
97
+ const redactIdx = src.indexOf('redactChecklistFields(', start)
98
+ const sendIdx = src.indexOf('rawSendChecklist({', start)
99
+ expect(start).toBeGreaterThan(0)
100
+ expect(redactIdx).toBeGreaterThan(start)
101
+ expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the send
102
+ })
103
+
104
+ it('update_checklist: redacts title + task text BEFORE rawEditMessageChecklist', () => {
105
+ // F2 sibling — update_checklist shares the identical leak class.
106
+ const start = src.indexOf('async function executeUpdateChecklist(')
107
+ const redactIdx = src.indexOf('redactChecklistFields(', start)
108
+ const editIdx = src.indexOf('rawEditMessageChecklist({', start)
109
+ expect(start).toBeGreaterThan(0)
110
+ expect(redactIdx).toBeGreaterThan(start)
111
+ expect(editIdx).toBeGreaterThan(redactIdx) // mask BEFORE the edit
112
+ })
113
+
114
+ it('pty draft-preview: redacts at the gateway boundary BEFORE the stream', () => {
115
+ // F3 — the PTY-tail partial (assistant reply text extracted from the TUI)
116
+ // forwards straight to the draft-preview stream. Mask at the top of
117
+ // handlePtyPartial, before it hands off to handlePtyPartialPure.
118
+ const start = src.indexOf('function handlePtyPartial(text: string): void {')
119
+ const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_preview')`, start)
120
+ const pureIdx = src.indexOf('handlePtyPartialPure(text, state', start)
121
+ expect(start).toBeGreaterThan(0)
122
+ expect(redactIdx).toBeGreaterThan(start)
123
+ expect(pureIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream push
124
+ })
125
+
126
+ it('pty activity: redacts at the gateway boundary BEFORE the stream', () => {
127
+ // F3 — the (currently-unwired) PTY-activity lane is masked too so the fix
128
+ // is durable if it is ever re-armed.
129
+ const start = src.indexOf('function handlePtyActivity(text: string): void {')
130
+ const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_activity')`, start)
131
+ const streamIdx = src.indexOf('handleStreamReply(', start)
132
+ expect(start).toBeGreaterThan(0)
133
+ expect(redactIdx).toBeGreaterThan(start)
134
+ expect(streamIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream
135
+ })
136
+
80
137
  it('does not log the secret value when a mask fires', () => {
81
138
  const idx = src.indexOf('function redactOutboundText(')
82
139
  const body = src.slice(idx, idx + 400)