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.
- package/dist/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- 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
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
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
|
+
})
|