switchroom 0.18.11 → 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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -15,8 +15,14 @@ import {
15
15
  createSwallowingRetryApiCall,
16
16
  retryWithThreadFallback,
17
17
  isHtmlParseRejectError,
18
+ isMessageTooLongError,
19
+ isPhotoDimensionRejectError,
18
20
  isLocalResourceError,
21
+ isFloodWaitActiveError,
19
22
  LOCAL_RESOURCE_EXHAUSTED,
23
+ FLOOD_WAIT_ACTIVE,
24
+ DEFAULT_MAX_FLOOD_SLEEP_MS,
25
+ type FloodWaitActiveError,
20
26
  type RetryObserver,
21
27
  } from '../retry-api-call.js'
22
28
  import { errors, makeGrammyError } from './fake-bot-api.js'
@@ -571,3 +577,395 @@ describe('#2923 — LOCAL resource exhaustion is NOT retried (avoids flood ban)'
571
577
  expect(seen).toEqual([42])
572
578
  })
573
579
  })
580
+
581
+ describe('#3084 — a long flood ban fails FAST instead of sleeping for hours', () => {
582
+ /**
583
+ * Deterministic fake clock: `sleep` never waits, it just advances a counter.
584
+ * Nothing here touches a real timer, so "did we try to sleep 4.6 hours?" is
585
+ * an assertion on the fake clock, not a stopwatch.
586
+ */
587
+ function fakeClock() {
588
+ let elapsedMs = 0
589
+ const slept: number[] = []
590
+ return {
591
+ get elapsedMs() {
592
+ return elapsedMs
593
+ },
594
+ slept,
595
+ sleep: async (ms: number) => {
596
+ slept.push(ms)
597
+ elapsedMs += ms
598
+ },
599
+ }
600
+ }
601
+
602
+ // The real value overlord's gateway logged on 2026-07-11.
603
+ const REAL_BAN_SEC = 16_739
604
+
605
+ it('does NOT sleep a 4.6-hour retry_after (clock never advances past the ceiling)', async () => {
606
+ const clock = fakeClock()
607
+ let calls = 0
608
+ const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
609
+
610
+ await expect(
611
+ retry(async () => {
612
+ calls++
613
+ throw errors.floodWait(REAL_BAN_SEC)
614
+ }),
615
+ ).rejects.toThrow(FLOOD_WAIT_ACTIVE)
616
+
617
+ expect(calls).toBe(1) // failed fast on the first 429 — no retry storm
618
+ expect(clock.slept).toEqual([]) // never even attempted the sleep
619
+ expect(clock.elapsedMs).toBe(0)
620
+ expect(clock.elapsedMs).toBeLessThanOrEqual(DEFAULT_MAX_FLOOD_SLEEP_MS)
621
+ })
622
+
623
+ it('carries retry_after / untilTs / original on the thrown marker', async () => {
624
+ const clock = fakeClock()
625
+ const before = Date.now()
626
+ const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
627
+
628
+ const err = await retry(async () => {
629
+ throw errors.floodWait(REAL_BAN_SEC)
630
+ }).catch((e: unknown) => e)
631
+
632
+ expect(isFloodWaitActiveError(err)).toBe(true)
633
+ const flood = err as FloodWaitActiveError
634
+ expect(flood.retryAfterSec).toBe(REAL_BAN_SEC)
635
+ expect(flood.untilTs).toBeGreaterThanOrEqual(before + REAL_BAN_SEC * 1000)
636
+ expect(flood.original).toBeInstanceOf(GrammyError)
637
+ })
638
+
639
+ it('STILL records the flood window via onFloodWait before throwing (#2923 breaker)', async () => {
640
+ const clock = fakeClock()
641
+ const seen: number[] = []
642
+ const order: string[] = []
643
+ const retry = createRetryApiCall({
644
+ maxRetries: 3,
645
+ sleep: clock.sleep,
646
+ onFloodWait: (s) => {
647
+ seen.push(s)
648
+ order.push('recorded')
649
+ },
650
+ observer: {
651
+ onGiveUp: () => {
652
+ order.push('gave_up')
653
+ },
654
+ },
655
+ })
656
+
657
+ await expect(
658
+ retry(async () => {
659
+ throw errors.floodWait(REAL_BAN_SEC)
660
+ }),
661
+ ).rejects.toThrow(FLOOD_WAIT_ACTIVE)
662
+
663
+ // The breaker MUST learn about the window — that is what suppresses the
664
+ // restart boot-card that would otherwise extend the ban.
665
+ expect(seen).toEqual([REAL_BAN_SEC])
666
+ expect(order).toEqual(['recorded', 'gave_up'])
667
+ })
668
+
669
+ it('a throwing onFloodWait hook still yields the marker, not the hook error', async () => {
670
+ const clock = fakeClock()
671
+ const retry = createRetryApiCall({
672
+ maxRetries: 3,
673
+ sleep: clock.sleep,
674
+ onFloodWait: () => {
675
+ throw new Error('disk full while persisting flood state')
676
+ },
677
+ })
678
+ await expect(
679
+ retry(async () => {
680
+ throw errors.floodWait(REAL_BAN_SEC)
681
+ }),
682
+ ).rejects.toThrow(FLOOD_WAIT_ACTIVE)
683
+ })
684
+
685
+ it('a SHORT retry_after under the ceiling still sleeps-and-retries and succeeds', async () => {
686
+ const clock = fakeClock()
687
+ let n = 0
688
+ const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
689
+
690
+ // 30s — the everyday rate-limit blip. Behaviour must be unchanged.
691
+ const out = await retry(async () => {
692
+ if (n++ === 0) throw errors.floodWait(30)
693
+ return 'ok'
694
+ })
695
+
696
+ expect(out).toBe('ok')
697
+ expect(n).toBe(2)
698
+ expect(clock.slept).toEqual([30_000])
699
+ })
700
+
701
+ it('the historic 28s and 75s waits are still slept through (no regression)', async () => {
702
+ for (const sec of [28, 75]) {
703
+ const clock = fakeClock()
704
+ let n = 0
705
+ const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
706
+ const out = await retry(async () => {
707
+ if (n++ === 0) throw errors.floodWait(sec)
708
+ return 'ok'
709
+ })
710
+ expect(out).toBe('ok')
711
+ expect(clock.slept).toEqual([sec * 1000])
712
+ }
713
+ })
714
+
715
+ it('sleeps exactly AT the ceiling, throws just above it', async () => {
716
+ const atCeiling = DEFAULT_MAX_FLOOD_SLEEP_MS / 1000
717
+
718
+ const clockAt = fakeClock()
719
+ let n = 0
720
+ const retryAt = createRetryApiCall({ maxRetries: 3, sleep: clockAt.sleep })
721
+ expect(
722
+ await retryAt(async () => {
723
+ if (n++ === 0) throw errors.floodWait(atCeiling)
724
+ return 'ok'
725
+ }),
726
+ ).toBe('ok')
727
+ expect(clockAt.slept).toEqual([DEFAULT_MAX_FLOOD_SLEEP_MS])
728
+
729
+ const clockOver = fakeClock()
730
+ const retryOver = createRetryApiCall({ maxRetries: 3, sleep: clockOver.sleep })
731
+ await expect(
732
+ retryOver(async () => {
733
+ throw errors.floodWait(atCeiling + 1)
734
+ }),
735
+ ).rejects.toThrow(FLOOD_WAIT_ACTIVE)
736
+ expect(clockOver.slept).toEqual([])
737
+ })
738
+
739
+ it('honours a caller-supplied maxFloodSleepMs ceiling', async () => {
740
+ const clock = fakeClock()
741
+ const retry = createRetryApiCall({
742
+ maxRetries: 3,
743
+ sleep: clock.sleep,
744
+ maxFloodSleepMs: 5_000,
745
+ })
746
+ // 30s would sleep under the DEFAULT ceiling; with a 5s ceiling it throws.
747
+ await expect(
748
+ retry(async () => {
749
+ throw errors.floodWait(30)
750
+ }),
751
+ ).rejects.toThrow(FLOOD_WAIT_ACTIVE)
752
+ expect(clock.slept).toEqual([])
753
+ })
754
+
755
+ it('maxRetries semantics unchanged for sustained SHORT floods', async () => {
756
+ const clock = fakeClock()
757
+ let calls = 0
758
+ const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
759
+
760
+ await expect(
761
+ retry(async () => {
762
+ calls++
763
+ throw errors.floodWait(10)
764
+ }),
765
+ ).rejects.toThrow('retryApiCall: max retries exceeded')
766
+
767
+ expect(calls).toBe(3)
768
+ expect(clock.slept).toEqual([10_000, 10_000, 10_000])
769
+ })
770
+
771
+ it('the swallowing wrapper absorbs the marker (no unhandled rejection)', async () => {
772
+ const clock = fakeClock()
773
+ const lines: string[] = []
774
+ const retry = createRetryApiCall({ maxRetries: 3, sleep: clock.sleep })
775
+ const swallow = createSwallowingRetryApiCall(retry, (l) => lines.push(l))
776
+
777
+ const out = await swallow(
778
+ async () => {
779
+ throw errors.floodWait(REAL_BAN_SEC)
780
+ },
781
+ { verb: 'sendMessage' },
782
+ )
783
+
784
+ expect(out).toBeUndefined()
785
+ expect(lines.join('')).toContain(FLOOD_WAIT_ACTIVE)
786
+ })
787
+ })
788
+
789
+ describe('#3084 — the fast throw must not become a re-drive amplifier', () => {
790
+ const REAL_BAN_SEC = 16_739
791
+
792
+ /**
793
+ * EXACT duck-type predicate used by the card surfaces to arm their 429
794
+ * cooldown — copied verbatim from `worker-activity-feed.ts`
795
+ * (`extractRetryAfterSecs`) and `issues-card.ts`. They do NOT use
796
+ * `instanceof GrammyError`. If the marker doesn't satisfy this, they
797
+ * classify it 'transient', arm no cooldown, and their 5-6s heartbeats
798
+ * re-drive the send into the open ban.
799
+ */
800
+ function extractRetryAfterSecs(err: unknown): number | null {
801
+ if (err == null || typeof err !== 'object') return null
802
+ const e = err as { error_code?: unknown; parameters?: { retry_after?: unknown } }
803
+ if (e.error_code !== 429) return null
804
+ const ra = e.parameters?.retry_after
805
+ if (typeof ra === 'number' && Number.isFinite(ra) && ra > 0) return ra
806
+ return null
807
+ }
808
+
809
+ it('the marker satisfies the card surfaces\' 429 cooldown duck-type', async () => {
810
+ const retry = createRetryApiCall({ maxRetries: 3, sleep: async () => {} })
811
+ const err = await retry(async () => {
812
+ throw errors.floodWait(REAL_BAN_SEC)
813
+ }).catch((e: unknown) => e)
814
+
815
+ // worker-activity-feed / issues-card would now arm a FULL-window cooldown
816
+ // instead of treating this as a generic 'transient' error.
817
+ expect(extractRetryAfterSecs(err)).toBe(REAL_BAN_SEC)
818
+ })
819
+
820
+ it('the marker is still NOT a GrammyError (resend-fallback probes must not match)', async () => {
821
+ const retry = createRetryApiCall({ maxRetries: 3, sleep: async () => {} })
822
+ const err = await retry(async () => {
823
+ throw errors.floodWait(REAL_BAN_SEC)
824
+ }).catch((e: unknown) => e)
825
+
826
+ expect(err).not.toBeInstanceOf(GrammyError)
827
+ expect(isHtmlParseRejectError(err)).toBe(false)
828
+ expect(isMessageTooLongError(err)).toBe(false)
829
+ expect(isPhotoDimensionRejectError(err)).toBe(false)
830
+ })
831
+
832
+ it('a card heartbeat ticking across the whole 4.6h ban issues ONE Bot API call', async () => {
833
+ // The regression that must be impossible. Before the pre-call gate: the
834
+ // marker throws in ~1ms, the 6s heartbeat re-drives, and we fire ~2,760
835
+ // fresh requests into the still-open per-bot ban — feeding the exact flood
836
+ // counter #2923 exists to starve. Strictly worse than the original hang.
837
+ let now = 1_000_000
838
+ let state: { untilTs: number } | null = null
839
+ let apiCalls = 0
840
+
841
+ const retry = createRetryApiCall({
842
+ maxRetries: 3,
843
+ sleep: async () => {},
844
+ // The #2923 breaker: record the window...
845
+ onFloodWait: (sec) => {
846
+ state = { untilTs: now + sec * 1000 }
847
+ },
848
+ // ...and the #3084 gate: refuse to send into it.
849
+ floodWaitRemainingMs: () => (state ? Math.max(0, state.untilTs - now) : 0),
850
+ })
851
+
852
+ const HEARTBEAT_MS = 6_000
853
+ const ticks = Math.ceil((REAL_BAN_SEC * 1000) / HEARTBEAT_MS) // ~2,790
854
+ let markerThrows = 0
855
+
856
+ for (let i = 0; i < ticks; i++) {
857
+ // A worker-feed style heartbeat: best-effort edit, swallow failures,
858
+ // and (deliberately, to model the WORST case) arm no cooldown of its own.
859
+ await retry(async () => {
860
+ apiCalls++
861
+ throw errors.floodWait(REAL_BAN_SEC)
862
+ }).catch((e: unknown) => {
863
+ if (isFloodWaitActiveError(e)) markerThrows++
864
+ })
865
+ now += HEARTBEAT_MS
866
+ }
867
+
868
+ expect(ticks).toBeGreaterThan(2_700) // we really did simulate the full ban
869
+ expect(markerThrows).toBe(ticks) // every tick got a clean degraded state
870
+ // …and the Bot API was touched a HANDFUL of times, not once per tick.
871
+ // Without the pre-call gate this is ~2,790 requests fired into an open
872
+ // per-bot ban. With it: the first tick (which discovers the ban) plus the
873
+ // few tail ticks that run after the simulated window has elapsed and
874
+ // legitimately re-probe. Bounded and small is the whole contract.
875
+ expect(apiCalls).toBeLessThanOrEqual(5)
876
+ expect(apiCalls).toBeGreaterThanOrEqual(1)
877
+ expect(apiCalls / ticks).toBeLessThan(0.01) // <1% of ticks reach the API
878
+ })
879
+
880
+ it('resumes issuing calls once the window expires (the gate is not a latch)', async () => {
881
+ let now = 1_000_000
882
+ let state: { untilTs: number } | null = null
883
+ let apiCalls = 0
884
+
885
+ const retry = createRetryApiCall({
886
+ maxRetries: 3,
887
+ sleep: async () => {},
888
+ onFloodWait: (sec) => {
889
+ state = { untilTs: now + sec * 1000 }
890
+ },
891
+ floodWaitRemainingMs: () => (state ? Math.max(0, state.untilTs - now) : 0),
892
+ })
893
+
894
+ await expect(
895
+ retry(async () => {
896
+ apiCalls++
897
+ throw errors.floodWait(300)
898
+ }),
899
+ ).rejects.toThrow(FLOOD_WAIT_ACTIVE)
900
+ expect(apiCalls).toBe(1)
901
+
902
+ // Mid-window: gated, no call.
903
+ now += 100_000
904
+ await expect(retry(async () => 'ok')).rejects.toThrow(FLOOD_WAIT_ACTIVE)
905
+ expect(apiCalls).toBe(1)
906
+
907
+ // Window expired: the very next call goes through and succeeds.
908
+ now += 300_000
909
+ expect(
910
+ await retry(async () => {
911
+ apiCalls++
912
+ return 'ok'
913
+ }),
914
+ ).toBe('ok')
915
+ expect(apiCalls).toBe(2)
916
+ })
917
+
918
+ it('a SHORT open window is not short-circuited (sleep-and-retry still rides it out)', async () => {
919
+ // A 30s window is under the ceiling: the normal path may still succeed
920
+ // (the server may have cleared it), so we must NOT gate it.
921
+ let apiCalls = 0
922
+ let n = 0
923
+ const retry = createRetryApiCall({
924
+ maxRetries: 3,
925
+ sleep: async () => {},
926
+ floodWaitRemainingMs: () => 30_000,
927
+ })
928
+ const out = await retry(async () => {
929
+ apiCalls++
930
+ if (n++ === 0) throw errors.floodWait(30)
931
+ return 'ok'
932
+ })
933
+ expect(out).toBe('ok')
934
+ expect(apiCalls).toBe(2)
935
+ })
936
+
937
+ it('FAILS OPEN — a throwing/garbage flood probe never silences the bot', async () => {
938
+ const throwing = createRetryApiCall({
939
+ maxRetries: 3,
940
+ sleep: async () => {},
941
+ floodWaitRemainingMs: () => {
942
+ throw new Error('EACCES: unreadable flood-wait.json')
943
+ },
944
+ })
945
+ expect(await throwing(async () => 'ok')).toBe('ok')
946
+
947
+ // Garbage from a corrupt marker file must be treated as "no window".
948
+ // Infinity in particular would otherwise mute the bot forever.
949
+ for (const garbage of [NaN, -1, 0, Infinity]) {
950
+ const retry = createRetryApiCall({
951
+ maxRetries: 3,
952
+ sleep: async () => {},
953
+ floodWaitRemainingMs: () => garbage,
954
+ })
955
+ expect(await retry(async () => 'ok')).toBe('ok')
956
+ }
957
+ })
958
+
959
+ it('with no flood probe wired at all, behaviour is exactly as before', async () => {
960
+ let apiCalls = 0
961
+ let n = 0
962
+ const retry = createRetryApiCall({ maxRetries: 3, sleep: async () => {} })
963
+ const out = await retry(async () => {
964
+ apiCalls++
965
+ if (n++ === 0) throw errors.floodWait(30)
966
+ return 'ok'
967
+ })
968
+ expect(out).toBe('ok')
969
+ expect(apiCalls).toBe(2)
970
+ })
971
+ })
@@ -270,6 +270,33 @@ describe('isDestructiveBashCommand — fail-closed denylist', () => {
270
270
  expect(timeBoxRule('Bash', bashInput('git status `rm -rf x`'))).toBeNull()
271
271
  })
272
272
 
273
+ it('flags destructive git checkout / stash forms that discard work (fail-closed)', () => {
274
+ for (const cmd of [
275
+ // checkout that discards uncommitted working-tree changes
276
+ 'git checkout .', 'git checkout -f', 'git checkout -f main', 'git checkout --force',
277
+ 'git checkout -- file.ts', 'git checkout HEAD -- .', 'git checkout HEAD~1 -- src/x.ts',
278
+ // `./` and `./<path>` are common spellings of `.` — same whole-tree /
279
+ // subtree discard; git refnames forbid a branch starting with `./`,
280
+ // so gating these carries no false-positive risk.
281
+ 'git checkout ./', 'git checkout ./src',
282
+ // stash forms that irreversibly remove stash state
283
+ 'git stash drop', 'git stash drop stash@{2}', 'git stash clear', 'git stash pop',
284
+ ]) {
285
+ expect(isDestructiveBashCommand(cmd), cmd).toBe(true)
286
+ }
287
+ })
288
+
289
+ it('does NOT flag safe git checkout / stash forms (no over-broadening)', () => {
290
+ for (const cmd of [
291
+ // branch switches / creation are reversible
292
+ 'git checkout main', 'git checkout -b feature', 'git checkout feature.branch',
293
+ // stash inspection / non-removing forms keep the stash
294
+ 'git stash', 'git stash list', 'git stash show', 'git stash apply',
295
+ ]) {
296
+ expect(isDestructiveBashCommand(cmd), cmd).toBe(false)
297
+ }
298
+ })
299
+
273
300
  it('does NOT flag ordinary safe commands', () => {
274
301
  for (const cmd of [
275
302
  'git status', 'git log --oneline -5', 'git diff', 'npm test', 'npm run build',
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { detectSecrets } from '../secret-detect/index.js'
3
+ import { chunk, CHUNK_THRESHOLD, WINDOW_SIZE, OVERLAP } from '../secret-detect/chunker.js'
4
+
5
+ /**
6
+ * OUTCOME test for tp-support F2 — the sliding-window overlap must exceed a
7
+ * real PEM private key so a boundary-straddling key in a >32 KB payload is
8
+ * still fully contained in (and therefore detected by) at least one window.
9
+ *
10
+ * The old 1 KB overlap was smaller than a 4096-bit RSA PEM (~3.2 KB), so a
11
+ * ~3.2 KB key straddling a 16 KB window boundary fell into the gap between
12
+ * consecutive windows and was scanned by NEITHER — it slipped past the
13
+ * scrubber unmasked. This pins the fix behaviorally: the key is detected.
14
+ *
15
+ * Proof-of-bite: reverting OVERLAP to 1024 (its pre-fix value) makes the
16
+ * boundary-straddling assertion below fail — the key lands in the gap.
17
+ */
18
+
19
+ // A representative ~3.2 KB PEM private key (4096-bit RSA armor size). Body is
20
+ // obviously-fake filler; only the BEGIN/END markers + non-empty body matter
21
+ // for the `pem_private_key` regex. Assembled at runtime per CLAUDE.md.
22
+ const PEM_BEGIN = '-----BEGIN RSA PRIVATE KEY-----'
23
+ const PEM_END = '-----END RSA PRIVATE KEY-----'
24
+ const PEM_BODY = 'A'.repeat(3100)
25
+ const PEM = `${PEM_BEGIN}\n${PEM_BODY}\n${PEM_END}`
26
+
27
+ describe('chunker overlap ≥ real PEM key size (tp-support F2)', () => {
28
+ it('overlap safely exceeds a 4096-bit RSA PEM (~3.2 KB)', () => {
29
+ // Guarantee: a secret is only missed if its length EXCEEDS OVERLAP.
30
+ expect(OVERLAP).toBeGreaterThanOrEqual(4 * 1024)
31
+ expect(PEM.length).toBeGreaterThan(1024) // the finding's ">1KB" threshold
32
+ expect(OVERLAP).toBeGreaterThan(PEM.length) // key fits inside the overlap
33
+ })
34
+
35
+ it('detects a >1KB PEM key straddling a window boundary in a >32KB payload', () => {
36
+ // Place the PEM so it straddles the first window boundary (16 KB): it
37
+ // starts before the boundary and ends after it. With the fixed 8 KB
38
+ // overlap the whole key sits inside the second window [8K, 24K]; with the
39
+ // old 1 KB overlap it fell into the gap between [0,16K] and [15.36K,31.74K].
40
+ const pemStart = WINDOW_SIZE - 1384 // 15000: before the 16384 boundary
41
+ const leading = 'a'.repeat(pemStart)
42
+ const trailing = 'a'.repeat(20000)
43
+ const text = leading + PEM + trailing
44
+
45
+ // Sanity: the payload is over the chunk threshold (so chunking is active)
46
+ // and the PEM genuinely straddles a real window boundary.
47
+ expect(text.length).toBeGreaterThan(CHUNK_THRESHOLD)
48
+ const windows = chunk(text)
49
+ expect(windows.length).toBeGreaterThan(1)
50
+ const pemEnd = pemStart + PEM.length
51
+ // Straddles the first boundary: starts before it, ends after it.
52
+ expect(pemStart).toBeLessThan(WINDOW_SIZE)
53
+ expect(pemEnd).toBeGreaterThan(WINDOW_SIZE)
54
+ // No single window before the fix (1 KB overlap) would have contained it,
55
+ // yet with the current overlap at least one window does.
56
+ const containing = windows.filter(
57
+ (w) => pemStart >= w.offset && pemEnd <= w.offset + w.text.length,
58
+ )
59
+ expect(containing.length).toBeGreaterThan(0)
60
+
61
+ // The actual outcome: the detector flags the straddling PEM.
62
+ const hits = detectSecrets(text)
63
+ expect(hits.some((h) => h.rule_id === 'pem_private_key')).toBe(true)
64
+ })
65
+ })
@@ -210,10 +210,11 @@ describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)'
210
210
  const window = src.slice(interceptIdx, interceptIdx + 2000)
211
211
  // Allow the optional 4th `log` argument added in #561 (diagnostic
212
212
  // sink for redaction failures) — required is the first three args.
213
- // `bot.api` may be cast (e.g. `bot.api as never`) for the local
214
- // BotApi-vs-grammy-Api type mismatch cleanup in #623; `msgId` may
215
- // be narrowed (`msgId ?? null`).
216
- expect(window).toMatch(/redactAuthCodeMessage\(bot\.api(?:\s+as\s+\w+)?,\s*chat_id,\s*msgId(?:\s*\?\?\s*null)?(?:,\s*[^)]+)?\)/)
213
+ // The first arg is the injected BotApi surface: historically `bot.api`
214
+ // (optionally cast, #623), now the gated `redactAuthCodeApi` adapter whose
215
+ // reaction routes through the send gate + flood breaker (#3155). `msgId`
216
+ // may be narrowed (`msgId ?? null`).
217
+ expect(window).toMatch(/redactAuthCodeMessage\((?:bot\.api|redactAuthCodeApi)(?:\s+as\s+\w+)?,\s*chat_id,\s*msgId(?:\s*\?\?\s*null)?(?:,\s*[^)]+)?\)/)
217
218
  })
218
219
 
219
220
  it('redaction lands AFTER the success/error reply renders', () => {
@@ -15,6 +15,8 @@ import {
15
15
  readSessionModelFileRaw,
16
16
  restoreSessionModelFileRaw,
17
17
  clearSessionModelFile,
18
+ clearSessionModelBootAttempts,
19
+ SESSION_MODEL_BOOT_ATTEMPTS_FILE,
18
20
  writeRelaunchModelIntent,
19
21
  clearRelaunchModelIntent,
20
22
  readConfiguredDefaultModel,
@@ -229,3 +231,51 @@ describe('intentForRestartReason is keep-for-everything (#3039)', () => {
229
231
  }
230
232
  })
231
233
  })
234
+
235
+ describe('clearSessionModelBootAttempts (#3043 item 2: bridge-register clears the crashloop counter)', () => {
236
+ const bootAttemptsPath = () => join(dir, SESSION_MODEL_BOOT_ATTEMPTS_FILE)
237
+
238
+ // Faithful model of start.sh.hbs "Override crashloop self-heal": each boot
239
+ // within 150s of the previous stamp bumps the count; a stale stamp resets to
240
+ // 1. Reproduced here so the accumulate-vs-reset OUTCOME is asserted, not just
241
+ // the delete call.
242
+ function simulateBootStamp(nowSec: number): number {
243
+ let count = 0
244
+ let prev = 0
245
+ if (existsSync(bootAttemptsPath())) {
246
+ const [c, p] = readFileSync(bootAttemptsPath(), 'utf8').trim().split(/\s+/)
247
+ count = Number(c) || 0
248
+ prev = Number(p) || 0
249
+ }
250
+ count = nowSec - prev < 150 ? count + 1 : 1
251
+ writeFileSync(bootAttemptsPath(), `${count} ${nowSec}\n`)
252
+ return count
253
+ }
254
+
255
+ it('WITHOUT a bridge register, three fast boots accumulate toward the 3-strike clear', () => {
256
+ // Three operator hand-bounces, each <150s apart, with no register between.
257
+ expect(simulateBootStamp(1000)).toBe(1)
258
+ expect(simulateBootStamp(1010)).toBe(2)
259
+ expect(simulateBootStamp(1020)).toBe(3) // start.sh would now clear a HEALTHY override — the false positive
260
+ })
261
+
262
+ it('a bridge register between boots resets the counter, so a healthy agent never reaches 3', () => {
263
+ expect(simulateBootStamp(1000)).toBe(1)
264
+ // Boot 1 came all the way up and the bridge registered → gateway clears it.
265
+ clearSessionModelBootAttempts(dir)
266
+ expect(existsSync(bootAttemptsPath())).toBe(false)
267
+
268
+ // Next fast boot starts fresh at 1 (no accumulation), and register clears again.
269
+ expect(simulateBootStamp(1010)).toBe(1)
270
+ clearSessionModelBootAttempts(dir)
271
+ expect(simulateBootStamp(1020)).toBe(1)
272
+ // The healthy agent's counter never climbs to the 3-strike clear.
273
+ const [count] = readFileSync(bootAttemptsPath(), 'utf8').trim().split(/\s+/)
274
+ expect(Number(count)).toBeLessThan(3)
275
+ })
276
+
277
+ it('is best-effort — no throw when the counter file is absent', () => {
278
+ expect(existsSync(bootAttemptsPath())).toBe(false)
279
+ expect(() => clearSessionModelBootAttempts(dir)).not.toThrow()
280
+ })
281
+ })