switchroom 0.18.11 → 0.18.12

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 (122) hide show
  1. package/dist/agent-scheduler/index.js +29 -5
  2. package/dist/auth-broker/index.js +53 -13
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/notion-write-pretool.mjs +29 -5
  5. package/dist/cli/switchroom.js +2453 -1293
  6. package/dist/cli/ui/index.html +163 -17
  7. package/dist/host-control/main.js +504 -100
  8. package/dist/vault/approvals/kernel-server.js +53 -13
  9. package/dist/vault/broker/server.js +162 -114
  10. package/package.json +3 -4
  11. package/profiles/_base/start.sh.hbs +65 -0
  12. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  13. package/profiles/coding/CLAUDE.md.hbs +1 -1
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +37 -0
  18. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +73 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
  21. package/telegram-plugin/dist/server.js +74 -2
  22. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  23. package/telegram-plugin/gateway/approval-hold.ts +583 -0
  24. package/telegram-plugin/gateway/auth-command.ts +92 -2
  25. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  26. package/telegram-plugin/gateway/boot-card.ts +12 -5
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
  28. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  30. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  31. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  32. package/telegram-plugin/gateway/gateway.ts +1482 -165
  33. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  34. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  35. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  36. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  37. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  38. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  39. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  40. package/telegram-plugin/gateway/model-command.ts +9 -3
  41. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  42. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  43. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  44. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  45. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  46. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  47. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  48. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  49. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  50. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  51. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  52. package/telegram-plugin/hooks/hooks.json +9 -0
  53. package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
  54. package/telegram-plugin/operator-events.ts +23 -0
  55. package/telegram-plugin/package.json +0 -1
  56. package/telegram-plugin/permission-rule.ts +1 -0
  57. package/telegram-plugin/permission-title.ts +1 -0
  58. package/telegram-plugin/retry-api-call.ts +212 -2
  59. package/telegram-plugin/send-gate-degraded.test.ts +443 -0
  60. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  61. package/telegram-plugin/send-gate-observability.ts +355 -0
  62. package/telegram-plugin/send-gate.test.ts +698 -0
  63. package/telegram-plugin/send-gate.ts +982 -0
  64. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  65. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  66. package/telegram-plugin/status-pin-driver.ts +52 -7
  67. package/telegram-plugin/status-pin.ts +81 -0
  68. package/telegram-plugin/subagent-watcher.ts +102 -2
  69. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  70. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  71. package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
  72. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  73. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  74. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  75. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  76. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  77. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  78. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  79. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  80. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  81. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  82. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  83. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  84. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  85. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  86. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
  87. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  88. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  89. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  90. package/telegram-plugin/tests/model-command.test.ts +14 -0
  91. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  92. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  93. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  94. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  95. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  96. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  97. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  98. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  99. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  100. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  101. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  102. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  103. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  104. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  105. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  106. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  107. package/telegram-plugin/typing-emitter.ts +224 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  109. package/telegram-plugin/welcome-text.ts +42 -0
  110. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  111. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  112. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  113. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  114. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  115. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  116. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  117. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  118. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  119. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  120. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  121. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  122. package/telegram-plugin/channel-envelope-safety.ts +0 -56
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Tests for the #396 dispatch-claim Stop-hook scan logic — the pure
3
+ * `dispatch-claim-scan.mjs` helpers (mirrors the silent-end-scan test
4
+ * style: synthetic JSONL fixtures against a pure function rather than
5
+ * spawning the .mjs subprocess).
6
+ *
7
+ * The defect: a reply that TELLS the user work was dispatched
8
+ * ("Dispatching a worker now") while the turn contains no Agent/Task call
9
+ * and no backgrounded Bash. These assert OUTCOMES (block vs allow), not
10
+ * code paths.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import {
15
+ scanTurnForDispatchClaim,
16
+ replyClaimsDispatch,
17
+ applyRetryBudget,
18
+ turnSignature,
19
+ } from '../hooks/dispatch-claim-scan.mjs'
20
+
21
+ // ── Fixture builders ────────────────────────────────────────────────
22
+
23
+ const REPLY = 'mcp__switchroom-telegram__reply'
24
+
25
+ const ENQUEUE = JSON.stringify({
26
+ type: 'queue-operation',
27
+ operation: 'enqueue',
28
+ content: '<channel source="switchroom-telegram" chat_id="111" message_id="42">fix the bug</channel>',
29
+ })
30
+
31
+ function assistantToolUse(
32
+ name: string,
33
+ input: Record<string, unknown>,
34
+ opts: { isSidechain?: boolean } = {},
35
+ ) {
36
+ const base: Record<string, unknown> = {
37
+ type: 'assistant',
38
+ message: { content: [{ type: 'tool_use', name, input }] },
39
+ }
40
+ if (opts.isSidechain) base.isSidechain = true
41
+ return JSON.stringify(base)
42
+ }
43
+
44
+ function reply(text: string, opts: { isSidechain?: boolean } = {}) {
45
+ return assistantToolUse(REPLY, { text, chat_id: '111' }, opts)
46
+ }
47
+
48
+ function jsonl(...lines: string[]) {
49
+ return lines.join('\n')
50
+ }
51
+
52
+ // ── (a) claim + no dispatch tool_use → block ────────────────────────
53
+
54
+ describe('scanTurnForDispatchClaim — narrate-without-execute', () => {
55
+ it('(a) blocks a dispatch claim with no Agent/Task call in the turn', () => {
56
+ const t = jsonl(ENQUEUE, reply('Dispatching a worker now to fix this bug.'))
57
+ const d = scanTurnForDispatchClaim(t)
58
+ expect(d.decided).toBe('block')
59
+ expect(d.reason).toBe('claim-without-dispatch')
60
+ expect(d.turnSig).toBeTruthy()
61
+ })
62
+
63
+ // ── (b) claim + Agent tool_use in turn → allow ────────────────────
64
+ it('(b) allows when the turn actually dispatched an Agent', () => {
65
+ const t = jsonl(
66
+ ENQUEUE,
67
+ reply('Dispatching a worker now to fix this bug.'),
68
+ assistantToolUse('Agent', { subagent_type: 'worker', description: 'fix bug' }),
69
+ )
70
+ const d = scanTurnForDispatchClaim(t)
71
+ expect(d.decided).toBe('allow')
72
+ expect(d.reason).toBe('dispatched')
73
+ })
74
+
75
+ it('(b2) allows when the dispatch tool is the newer Task name', () => {
76
+ const t = jsonl(
77
+ ENQUEUE,
78
+ reply('Spinning up a researcher to dig into this.'),
79
+ assistantToolUse('Task', { subagent_type: 'researcher' }),
80
+ )
81
+ expect(scanTurnForDispatchClaim(t).decided).toBe('allow')
82
+ })
83
+
84
+ // ── (c) claim + backgrounded Bash → allow ─────────────────────────
85
+ it('(c) allows when the turn launched a backgrounded Bash', () => {
86
+ const t = jsonl(
87
+ ENQUEUE,
88
+ reply('Kicking off the build agent in the background.'),
89
+ assistantToolUse('Bash', { command: 'npm run build', run_in_background: true }),
90
+ )
91
+ const d = scanTurnForDispatchClaim(t)
92
+ expect(d.decided).toBe('allow')
93
+ expect(d.reason).toBe('dispatched')
94
+ })
95
+
96
+ it('(c2) a FOREGROUND Bash does not count as a dispatch', () => {
97
+ const t = jsonl(
98
+ ENQUEUE,
99
+ reply('Dispatching a worker to handle this.'),
100
+ assistantToolUse('Bash', { command: 'ls', run_in_background: false }),
101
+ )
102
+ expect(scanTurnForDispatchClaim(t).decided).toBe('block')
103
+ })
104
+
105
+ // ── (d) conditional offer → allow ─────────────────────────────────
106
+ it('(d) allows a conditional offer to dispatch (question)', () => {
107
+ const t = jsonl(ENQUEUE, reply('Want me to dispatch a worker to fix this?'))
108
+ const d = scanTurnForDispatchClaim(t)
109
+ expect(d.decided).toBe('allow')
110
+ expect(d.reason).toBe('no-claim')
111
+ })
112
+
113
+ it('(d2) allows a conditional "I could spin up a reviewer if you like"', () => {
114
+ const t = jsonl(ENQUEUE, reply('I could spin up a reviewer if you want me to.'))
115
+ expect(scanTurnForDispatchClaim(t).decided).toBe('allow')
116
+ })
117
+
118
+ // ── (e) non-dispatch reply → allow ────────────────────────────────
119
+ it('(e) allows an ordinary non-dispatch reply', () => {
120
+ const t = jsonl(ENQUEUE, reply('Here is the answer: the bug is a null deref on line 12.'))
121
+ const d = scanTurnForDispatchClaim(t)
122
+ expect(d.decided).toBe('allow')
123
+ expect(d.reason).toBe('no-claim')
124
+ })
125
+
126
+ // ── (f) NO_REPLY-only turn → allow ────────────────────────────────
127
+ it('(f) allows a NO_REPLY-only turn', () => {
128
+ const t = jsonl(ENQUEUE, reply('NO_REPLY'))
129
+ expect(scanTurnForDispatchClaim(t).decided).toBe('allow')
130
+ })
131
+
132
+ // ── (g) sidechain lines ───────────────────────────────────────────
133
+ it('(g) sidechain Agent lines do NOT count as the parent dispatching', () => {
134
+ // A sub-agent's own Agent tool_use leaks in with isSidechain — it must
135
+ // not satisfy the PARENT's dispatch obligation. Parent reply claims a
136
+ // dispatch, parent itself dispatched nothing → block.
137
+ const t = jsonl(
138
+ ENQUEUE,
139
+ reply('Dispatching a worker now.'),
140
+ assistantToolUse('Agent', { subagent_type: 'worker' }, { isSidechain: true }),
141
+ )
142
+ expect(scanTurnForDispatchClaim(t).decided).toBe('block')
143
+ })
144
+
145
+ it('(g2) sidechain replies do NOT count as the parent making a claim', () => {
146
+ // A sub-agent's own reply narrating a dispatch leaks in with
147
+ // isSidechain; the parent made no claim → allow.
148
+ const t = jsonl(
149
+ ENQUEUE,
150
+ reply('Working on the fix directly.'),
151
+ reply('Dispatching a worker to handle the subtask.', { isSidechain: true }),
152
+ )
153
+ const d = scanTurnForDispatchClaim(t)
154
+ expect(d.decided).toBe('allow')
155
+ expect(d.reason).toBe('no-claim')
156
+ })
157
+
158
+ // ── (i) malformed / missing transcript → allow (fail-open) ────────
159
+ it('(i) fails open on an empty transcript', () => {
160
+ expect(scanTurnForDispatchClaim('').decided).toBe('unknown')
161
+ })
162
+
163
+ it('(i2) fails open when no turn-start anchor is present', () => {
164
+ const t = jsonl(reply('Dispatching a worker now.'))
165
+ expect(scanTurnForDispatchClaim(t).decided).toBe('unknown')
166
+ })
167
+
168
+ it('(i3) tolerates malformed JSONL lines mixed in', () => {
169
+ const t = jsonl(ENQUEUE, '{ not valid json', reply('Dispatching a worker now.'))
170
+ const d = scanTurnForDispatchClaim(t)
171
+ expect(d.decided).toBe('block')
172
+ })
173
+
174
+ // ── past-tense reference (registry-check substitute) ──────────────
175
+ it('does NOT block a bare past-tense reference to an earlier dispatch', () => {
176
+ const t = jsonl(
177
+ ENQUEUE,
178
+ reply('The worker I dispatched earlier finished; here are its results.'),
179
+ )
180
+ const d = scanTurnForDispatchClaim(t)
181
+ expect(d.decided).toBe('allow')
182
+ expect(d.reason).toBe('no-claim')
183
+ })
184
+ })
185
+
186
+ // ── replyClaimsDispatch unit coverage ───────────────────────────────
187
+
188
+ describe('replyClaimsDispatch', () => {
189
+ it('matches present/progressive commitments', () => {
190
+ expect(replyClaimsDispatch('Dispatching a worker now.')).toBe(true)
191
+ expect(replyClaimsDispatch("I'll dispatch a reviewer to check this.")).toBe(true)
192
+ expect(replyClaimsDispatch('Spinning up a sub-agent for the research.')).toBe(true)
193
+ expect(replyClaimsDispatch('Kicking off a worker on that task.')).toBe(true)
194
+ expect(replyClaimsDispatch('Delegating this to a researcher.')).toBe(true)
195
+ })
196
+
197
+ it('excludes interrogative / conditional sentences', () => {
198
+ expect(replyClaimsDispatch('Want me to dispatch a worker?')).toBe(false)
199
+ expect(replyClaimsDispatch('I could dispatch a reviewer if you like.')).toBe(false)
200
+ expect(replyClaimsDispatch('Should I spin up a worker for this?')).toBe(false)
201
+ })
202
+
203
+ it('excludes pure past-tense references', () => {
204
+ expect(replyClaimsDispatch('The worker I dispatched earlier is done.')).toBe(false)
205
+ expect(replyClaimsDispatch('I launched a reviewer last turn.')).toBe(false)
206
+ })
207
+
208
+ it('ignores unrelated prose and bare silent markers', () => {
209
+ expect(replyClaimsDispatch('The fix is a null-deref guard on line 12.')).toBe(false)
210
+ expect(replyClaimsDispatch('NO_REPLY')).toBe(false)
211
+ expect(replyClaimsDispatch('')).toBe(false)
212
+ })
213
+
214
+ it('scopes conditional exclusion to the sentence carrying the verb', () => {
215
+ // First sentence is a real commitment; a later unrelated question must
216
+ // not amnesty it.
217
+ expect(
218
+ replyClaimsDispatch('Dispatching a worker now. Anything else you need?'),
219
+ ).toBe(true)
220
+ })
221
+ })
222
+
223
+ // ── (h) retry budget: exactly one re-prompt per turn ────────────────
224
+
225
+ describe('applyRetryBudget — exactly one re-prompt per turn', () => {
226
+ const sig = turnSignature(ENQUEUE)
227
+
228
+ it('(h) blocks on the first Stop, then fails open on the second same-turn Stop', () => {
229
+ const first = applyRetryBudget({}, sig, 1)
230
+ expect(first.block).toBe(true)
231
+ expect(first.nextState?.retryCount).toBe(1)
232
+ expect(first.nextState?.turnSig).toBe(sig)
233
+
234
+ // Second Stop fire on the SAME turn reads back the persisted state.
235
+ const second = applyRetryBudget(first.nextState, sig, 1)
236
+ expect(second.block).toBe(false)
237
+ })
238
+
239
+ it('resets the budget for a genuinely new turn (different signature)', () => {
240
+ const spent = { turnSig: sig, retryCount: 1 }
241
+ const newTurn = applyRetryBudget(spent, turnSignature('a different inbound'), 1)
242
+ expect(newTurn.block).toBe(true)
243
+ expect(newTurn.nextState?.retryCount).toBe(1)
244
+ })
245
+
246
+ it('tolerates a null / corrupt prior state', () => {
247
+ expect(applyRetryBudget(null, sig, 1).block).toBe(true)
248
+ expect(applyRetryBudget(undefined, sig, 1).block).toBe(true)
249
+ })
250
+ })
@@ -0,0 +1,213 @@
1
+ /**
2
+ * #3106 — a permissions error must not silently blind the flood breaker.
3
+ *
4
+ * The bug: `readFloodState` swallowed EVERY error and returned `null`, so an
5
+ * unreadable marker (EACCES) was indistinguishable from "no marker" — the
6
+ * probe reported "no flood window" forever and every flood protection we ship
7
+ * (#2923 boot-card suppression, #3084/#3094 pre-call short-circuit, #3097
8
+ * typing suppression) failed open SILENTLY. A restart would then post a boot
9
+ * card straight into an open ban.
10
+ *
11
+ * Two behaviours pull in OPPOSITE directions and both are pinned here:
12
+ *
13
+ * - ABSENT / CORRUPT marker → still fails OPEN everywhere. A junk state file
14
+ * must never become a silent gag (#3094's deliberate posture).
15
+ * - UNREADABLE marker → the breaker is BLIND. Essential sends still proceed
16
+ * (fail-open, so a chmod accident can never mute the agent), but it says so
17
+ * LOUDLY, and non-essential sends (boot card, typing) are held back rather
18
+ * than fired into a ban we cannot rule out.
19
+ *
20
+ * All paths are under `mkdtempSync(join(tmpdir(), …))` — no production state.
21
+ */
22
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
23
+ import {
24
+ mkdtempSync,
25
+ rmSync,
26
+ writeFileSync,
27
+ readFileSync,
28
+ chmodSync,
29
+ mkdirSync,
30
+ statSync,
31
+ } from 'node:fs'
32
+ import { tmpdir } from 'node:os'
33
+ import { join } from 'node:path'
34
+ import {
35
+ computeFloodWait,
36
+ writeFloodState,
37
+ readFloodStateResult,
38
+ makeFloodWaitProbe,
39
+ makeFloodWaitRecorder,
40
+ suppressNonEssentialSendMs,
41
+ nonEssentialSendSuppression,
42
+ resetFloodBlindLogThrottle,
43
+ floodStatePath,
44
+ FLOOD_STATE_MODE,
45
+ } from '../flood-circuit-breaker.js'
46
+
47
+ const NOW = 1_800_000_000_000
48
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0
49
+
50
+ /**
51
+ * Make `path` genuinely unreadable to THIS process and prove it. Returns false
52
+ * when the process is root (root bypasses DAC — EACCES is unreachable), so the
53
+ * caller can skip rather than assert a lie.
54
+ */
55
+ function makeUnreadable(path: string): boolean {
56
+ writeFileSync(path, JSON.stringify({ untilTs: NOW + 60_000, retryAfterSec: 60, recordedTs: NOW }))
57
+ chmodSync(path, 0o000)
58
+ if (isRoot) return false
59
+ try {
60
+ readFileSync(path, 'utf-8')
61
+ return false // somehow still readable — don't pretend we tested EACCES
62
+ } catch {
63
+ return true
64
+ }
65
+ }
66
+
67
+ describe('#3106 flood breaker — blind is not clear', () => {
68
+ let dir: string
69
+ let path: string
70
+ let logs: string[]
71
+ const log = (l: string) => {
72
+ logs.push(l)
73
+ }
74
+
75
+ beforeEach(() => {
76
+ dir = mkdtempSync(join(tmpdir(), 'flood-blind-'))
77
+ path = floodStatePath(dir)
78
+ logs = []
79
+ resetFloodBlindLogThrottle()
80
+ })
81
+ afterEach(() => {
82
+ try {
83
+ chmodSync(path, 0o644)
84
+ } catch {
85
+ /* may not exist */
86
+ }
87
+ rmSync(dir, { recursive: true, force: true })
88
+ })
89
+
90
+ // ---------------------------------------------------------------- classify
91
+
92
+ it('classifies absent / corrupt / unreadable as DIFFERENT things', () => {
93
+ expect(readFloodStateResult(path).status).toBe('absent')
94
+
95
+ writeFileSync(path, 'not json at all{{{')
96
+ expect(readFloodStateResult(path).status).toBe('corrupt')
97
+
98
+ writeFileSync(path, JSON.stringify({ untilTs: 'soon' }))
99
+ expect(readFloodStateResult(path).status).toBe('corrupt')
100
+
101
+ // A directory where the marker should be: readable-check fails with EISDIR,
102
+ // not ENOENT. uid-independent (works even under root) — "cannot read".
103
+ const d = join(dir, 'as-a-dir.json')
104
+ mkdirSync(d)
105
+ expect(readFloodStateResult(d).status).toBe('unreadable')
106
+ })
107
+
108
+ // ------------------------------------------------- UNREADABLE → not silent
109
+
110
+ it.skipIf(isRoot)(
111
+ 'REGRESSION: an unreadable marker does NOT silently report "no flood window"',
112
+ () => {
113
+ expect(makeUnreadable(path)).toBe(true)
114
+
115
+ const res = readFloodStateResult(path)
116
+ // The bug was: status indistinguishable from absent, state null, nobody told.
117
+ expect(res.status).toBe('unreadable')
118
+ expect(res.error).toMatch(/EACCES/)
119
+
120
+ // The probe still FAILS OPEN (0 = proceed) — an essential reply to the
121
+ // user must never be gagged by a permissions error...
122
+ const probe = makeFloodWaitProbe(path, () => NOW, log)
123
+ expect(probe()).toBe(0)
124
+
125
+ // ...but it is no longer SILENT. That is the whole fix.
126
+ expect(logs.join('')).toMatch(/BLIND/)
127
+ expect(logs.join('')).toContain(path)
128
+ },
129
+ )
130
+
131
+ it.skipIf(isRoot)('an unreadable marker SUPPRESSES non-essential sends (boot card, typing)', () => {
132
+ expect(makeUnreadable(path)).toBe(true)
133
+
134
+ // The #2923/#3097 surfaces: we cannot rule out an open ban, so we do not
135
+ // post a restart card / typing ping into one.
136
+ expect(suppressNonEssentialSendMs(path, NOW, log)).toBeGreaterThan(0)
137
+ expect(logs.join('')).toMatch(/BLIND/)
138
+
139
+ const s = nonEssentialSendSuppression(path, NOW)
140
+ expect(s.suppress).toBe(true)
141
+ expect(s.suppress && s.reason).toBe('blind')
142
+ })
143
+
144
+ it.skipIf(isRoot)('the BLIND warning is throttled — a per-call probe cannot spam the log', () => {
145
+ expect(makeUnreadable(path)).toBe(true)
146
+ let t = NOW
147
+ const probe = makeFloodWaitProbe(path, () => t, log)
148
+ for (let i = 0; i < 50; i++) probe()
149
+ expect(logs.length).toBe(1)
150
+ t = NOW + 61_000
151
+ probe()
152
+ expect(logs.length).toBe(2)
153
+ })
154
+
155
+ // --------------------------------------------- ABSENT / CORRUPT → fail OPEN
156
+
157
+ it('an ABSENT marker fails OPEN and stays silent (no ban, nothing to say)', () => {
158
+ const probe = makeFloodWaitProbe(path, () => NOW, log)
159
+ expect(probe()).toBe(0)
160
+ expect(suppressNonEssentialSendMs(path, NOW, log)).toBe(0)
161
+ expect(nonEssentialSendSuppression(path, NOW).suppress).toBe(false)
162
+ expect(logs.join('')).not.toMatch(/BLIND/)
163
+ })
164
+
165
+ it('a CORRUPT marker still fails OPEN — a junk file must never gag the bot', () => {
166
+ for (const junk of ['', 'null', '{}', 'not json{{', JSON.stringify({ untilTs: 'x' })]) {
167
+ writeFileSync(path, junk)
168
+ const probe = makeFloodWaitProbe(path, () => NOW, log)
169
+ expect(probe()).toBe(0)
170
+ // Non-essential sends proceed too: corrupt is a CONTENT problem, and we
171
+ // deliberately do NOT let it suppress. (Contrast: unreadable, above.)
172
+ expect(suppressNonEssentialSendMs(path, NOW, log)).toBe(0)
173
+ expect(nonEssentialSendSuppression(path, NOW).suppress).toBe(false)
174
+ }
175
+ })
176
+
177
+ it('a REAL open window still suppresses (the #2923 behaviour is intact)', () => {
178
+ writeFloodState(path, computeFloodWait(null, 68 * 60, NOW), log)
179
+ const probe = makeFloodWaitProbe(path, () => NOW, log)
180
+ expect(probe()).toBe(68 * 60 * 1000)
181
+ const s = nonEssentialSendSuppression(path, NOW)
182
+ expect(s.suppress && s.reason).toBe('flood_wait')
183
+ // ...and lifts on its own.
184
+ expect(makeFloodWaitProbe(path, () => NOW + 68 * 60_000 + 1, log)()).toBe(0)
185
+ })
186
+
187
+ // ------------------------------------------------------- root cause: 0600
188
+
189
+ it('ROOT CAUSE: the marker is written 0644, readable by a sibling uid', () => {
190
+ writeFloodState(path, computeFloodWait(null, 60, NOW), log)
191
+ expect(statSync(path).mode & 0o777).toBe(FLOOD_STATE_MODE)
192
+ // Group/other read bits set — the whole point. At 0600 a marker created by
193
+ // a root gateway (`compose.ts:1967`, `root: true` agents) is unreadable to
194
+ // the same agent's non-root gateway forever.
195
+ expect(statSync(path).mode & 0o044).toBe(0o044)
196
+ })
197
+
198
+ it('SELF-HEAL: a pre-existing 0600 marker is chmodded back to 0644 on write', () => {
199
+ // Simulate the live overlord marker: created 0600 by an earlier build.
200
+ writeFileSync(path, JSON.stringify({ untilTs: NOW, retryAfterSec: 1, recordedTs: NOW }), {
201
+ mode: 0o600,
202
+ })
203
+ chmodSync(path, 0o600)
204
+ expect(statSync(path).mode & 0o777).toBe(0o600)
205
+
206
+ // The next observed 429 rewrites it — and heals the mode. (writeFileSync's
207
+ // `mode` option is ignored for an EXISTING file, so the explicit chmod is
208
+ // load-bearing.)
209
+ makeFloodWaitRecorder(path, () => NOW, log)(120)
210
+ expect(statSync(path).mode & 0o777).toBe(FLOOD_STATE_MODE)
211
+ expect(readFloodStateResult(path).status).toBe('ok')
212
+ })
213
+ })
@@ -0,0 +1,224 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ readFloodWindows,
7
+ writeFloodWindow,
8
+ makeFloodWindowRecorder,
9
+ loadInitialFloodWindows,
10
+ floodWindowsPath,
11
+ floodStatePath,
12
+ writeFloodState,
13
+ computeFloodWait,
14
+ FLOOD_STATE_MODE,
15
+ FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS,
16
+ } from '../flood-circuit-breaker.js'
17
+ import { createSendGate, type Clock } from '../send-gate.js'
18
+
19
+ /**
20
+ * #3084 PR 2 — restart-proof SCOPED flood windows (part3-design §7). Verifies
21
+ * the write-through/round-trip contract on real temp files (isolated per the
22
+ * repo's vault/shared-state test discipline) and that a gate reconstructed from
23
+ * disk is STILL blocked — the whole point of §7 (a boot mid-ban must not resend
24
+ * into the open window).
25
+ */
26
+ describe('#3084 scoped flood-window persistence', () => {
27
+ let dir: string
28
+ beforeEach(() => {
29
+ dir = mkdtempSync(join(tmpdir(), 'flood-win-'))
30
+ })
31
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
32
+
33
+ it('round-trips a scoped window and prunes expired entries', () => {
34
+ const path = floodWindowsPath(dir)
35
+ const now = 1_000_000
36
+ writeFloodWindow(path, { scopeKey: 'chat:5', untilTs: now + 60_000, retryAfterSrc: '429', observedAt: now }, now)
37
+ writeFloodWindow(path, { scopeKey: 'global', untilTs: now + 10_000, retryAfterSrc: '429', observedAt: now }, now)
38
+
39
+ // Before either expires: both present.
40
+ const live = readFloodWindows(path, now + 5_000)
41
+ expect(live.map((w) => w.scopeKey).sort()).toEqual(['chat:5', 'global'])
42
+
43
+ // After the global one expires: only chat:5 survives (prune-on-read).
44
+ const later = readFloodWindows(path, now + 20_000)
45
+ expect(later.map((w) => w.scopeKey)).toEqual(['chat:5'])
46
+ })
47
+
48
+ it('EXTENDS never shortens a window on a later, shorter re-record (boot-never-shortens)', () => {
49
+ const path = floodWindowsPath(dir)
50
+ const now = 1_000_000
51
+ // A long ban is recorded.
52
+ writeFloodWindow(path, { scopeKey: 'global', untilTs: now + 600_000, retryAfterSrc: '429', observedAt: now }, now)
53
+ // A later, SHORTER 429 (or a restart re-observing a smaller retry_after)
54
+ // must not pull the expiry earlier.
55
+ writeFloodWindow(
56
+ path,
57
+ { scopeKey: 'global', untilTs: now + 1_000 + 5_000, retryAfterSrc: '429', observedAt: now + 1_000 },
58
+ now + 1_000,
59
+ )
60
+ const w = readFloodWindows(path, now + 2_000).find((x) => x.scopeKey === 'global')
61
+ expect(w?.untilTs).toBe(now + 600_000)
62
+ })
63
+
64
+ it('loadInitialFloodWindows combines the global flood-wait.json window with scoped windows', () => {
65
+ const now = 1_000_000
66
+ const statePath = floodStatePath(dir)
67
+ const winPath = floodWindowsPath(dir)
68
+ writeFloodState(statePath, computeFloodWait(null, 300, now)) // global, +300s
69
+ writeFloodWindow(winPath, { scopeKey: 'chat:9', untilTs: now + 120_000, retryAfterSrc: '429', observedAt: now }, now)
70
+
71
+ const initial = loadInitialFloodWindows(statePath, winPath, now)
72
+ const scopes = initial.map((w) => w.scopeKey).sort()
73
+ expect(scopes).toEqual(['chat:9', 'global'])
74
+ expect(initial.find((w) => w.scopeKey === 'global')?.untilTs).toBe(now + 300_000)
75
+ })
76
+
77
+ it('gate opens a window on a 429 → persists → a NEW gate built from disk is still blocked', async () => {
78
+ const winPath = floodWindowsPath(dir)
79
+ const statePath = floodStatePath(dir)
80
+ const recorder = makeFloodWindowRecorder(winPath, () => 0)
81
+
82
+ // Gate 1: a critical send hits a 429 (FLOOD_WAIT_ACTIVE) which opens +
83
+ // persists scope windows via onWindowOpen.
84
+ const clock1: Clock = { now: () => 0, sleep: () => Promise.resolve() }
85
+ const gate1 = createSendGate({
86
+ enabled: true,
87
+ clock: clock1,
88
+ onWindowOpen: (scopeKey, untilTs) => recorder(scopeKey, untilTs),
89
+ })
90
+ const floodErr = Object.assign(new Error('FLOOD_WAIT_ACTIVE'), {
91
+ retryAfterSec: 600,
92
+ untilTs: 600_000,
93
+ error_code: 429 as const,
94
+ parameters: { retry_after: 600 },
95
+ original: null,
96
+ })
97
+ await expect(
98
+ gate1.gate(
99
+ async () => {
100
+ throw floodErr
101
+ },
102
+ { chat_id: '7', priorityClass: 'critical' },
103
+ ),
104
+ ).rejects.toBe(floodErr)
105
+
106
+ // The window is on disk.
107
+ const persisted = readFloodWindows(winPath, 0)
108
+ expect(persisted.map((w) => w.scopeKey).sort()).toEqual(['chat:7', 'global'])
109
+
110
+ // Gate 2 (simulated restart): built BEFORE any outbound call from the
111
+ // persisted windows. A cosmetic send on chat:7 must still shed.
112
+ const clock2: Clock = { now: () => 0, sleep: () => Promise.resolve() }
113
+ const gate2 = createSendGate({
114
+ enabled: true,
115
+ clock: clock2,
116
+ initialWindows: loadInitialFloodWindows(statePath, winPath, 0),
117
+ })
118
+ const res = await gate2.gate(async () => 'should-not-run', {
119
+ chat_id: '7',
120
+ priorityClass: 'cosmetic',
121
+ })
122
+ expect(res).toBeUndefined()
123
+ expect(gate2.stats().global.shed).toBe(1)
124
+
125
+ // And a critical into the still-long window fails fast — restart did not
126
+ // reset the ban.
127
+ await expect(
128
+ gate2.gate(async () => 'nope', { chat_id: '7', priorityClass: 'critical' }),
129
+ ).rejects.toThrow('FLOOD_WAIT_ACTIVE')
130
+ })
131
+
132
+ it('H2: writes flood-windows.json world-READABLE (0o644), not 0o600', () => {
133
+ // A 0o600 marker is unreadable to a gateway running under a DIFFERENT uid
134
+ // (root vs agent uid share the state dir), which silently defeats the
135
+ // restart-proof windows — a restart under the other uid boots blind and
136
+ // resends into the ban. Must match `flood-wait.json` (FLOOD_STATE_MODE).
137
+ const path = floodWindowsPath(dir)
138
+ const now = 2_000_000
139
+ writeFloodWindow(path, { scopeKey: 'global', untilTs: now + 60_000, retryAfterSrc: '429', observedAt: now }, now)
140
+ expect(statSync(path).mode & 0o777).toBe(FLOOD_STATE_MODE)
141
+ expect(FLOOD_STATE_MODE).toBe(0o644)
142
+ })
143
+
144
+ it('M1: a CORRUPT flood-windows.json fails SAFE (conservative global window), not open', () => {
145
+ const path = floodWindowsPath(dir)
146
+ const now = 3_000_000
147
+ writeFileSync(path, '{ this is not valid json ][', 'utf-8')
148
+ const logs: string[] = []
149
+ const windows = readFloodWindows(path, now, (l) => logs.push(l))
150
+ // NOT [] (fail-open) — a bounded conservative global window instead.
151
+ expect(windows).toHaveLength(1)
152
+ expect(windows[0]!.scopeKey).toBe('global')
153
+ expect(windows[0]!.untilTs).toBe(now + FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS)
154
+ // Logged loudly.
155
+ expect(logs.join('')).toMatch(/failing SAFE/)
156
+ })
157
+
158
+ it('M1: a non-array flood-windows.json fails SAFE', () => {
159
+ const path = floodWindowsPath(dir)
160
+ const now = 3_500_000
161
+ writeFileSync(path, '{"scopeKey":"global"}', 'utf-8') // object, not array
162
+ const windows = readFloodWindows(path, now, () => {})
163
+ expect(windows).toEqual([
164
+ {
165
+ scopeKey: 'global',
166
+ untilTs: now + FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS,
167
+ retryAfterSrc: expect.stringContaining('failsafe'),
168
+ observedAt: now,
169
+ },
170
+ ])
171
+ })
172
+
173
+ it('M1: an UNREADABLE flood-windows.json (read error, not ENOENT) fails SAFE', () => {
174
+ // Root bypasses file-mode EACCES, so simulate a non-ENOENT read failure
175
+ // deterministically by making the windows path a DIRECTORY — readFileSync
176
+ // throws EISDIR, the same "file exists but I can't read it" class as EACCES.
177
+ const path = floodWindowsPath(dir)
178
+ const now = 4_000_000
179
+ mkdirSync(path)
180
+ const logs: string[] = []
181
+ const windows = readFloodWindows(path, now, (l) => logs.push(l))
182
+ expect(windows).toHaveLength(1)
183
+ expect(windows[0]!.scopeKey).toBe('global')
184
+ expect(windows[0]!.untilTs).toBe(now + FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS)
185
+ expect(logs.join('')).toMatch(/unreadable/)
186
+ })
187
+
188
+ it('M1: a genuinely ABSENT file is still "no windows" (ENOENT ≠ corrupt)', () => {
189
+ const path = floodWindowsPath(dir) // never written
190
+ expect(readFloodWindows(path, 5_000_000, () => {})).toEqual([])
191
+ })
192
+
193
+ it('M3: flag-OFF gate opens NO window and writes NO flood-windows.json', () => {
194
+ // The gateway wires onFloodWait → openFloodWindow('global', …)
195
+ // unconditionally; with the flag OFF that must be a pure no-op — no in-memory
196
+ // suppression, no fs side effect (a poison-perms file created before the
197
+ // feature is ever enabled).
198
+ const path = floodWindowsPath(dir)
199
+ const recorder = makeFloodWindowRecorder(path, () => 0)
200
+ const clock: Clock = { now: () => 0, sleep: () => Promise.resolve() }
201
+ const gate = createSendGate({
202
+ enabled: false,
203
+ clock,
204
+ onWindowOpen: (scopeKey, untilTs) => recorder(scopeKey, untilTs),
205
+ })
206
+ gate.openFloodWindow('global', 600_000)
207
+ expect(existsSync(path)).toBe(false)
208
+ })
209
+
210
+ it('a boot loading initial windows never shortens an on-disk window', async () => {
211
+ const clock: Clock = { now: () => 0, sleep: () => Promise.resolve() }
212
+ // Boot with a long window applied at construction.
213
+ const gate = createSendGate({
214
+ enabled: true,
215
+ clock,
216
+ initialWindows: [{ scopeKey: 'global', untilTs: 600_000 }],
217
+ })
218
+ // Re-opening the SAME scope with a shorter untilTs must not shorten it.
219
+ gate.openFloodWindow('global', 10_000)
220
+ // A critical still fails fast against the ORIGINAL long window.
221
+ const e = await gate.gate(async () => 'x', { priorityClass: 'critical' }).catch((err) => err)
222
+ expect((e as { untilTs: number }).untilTs).toBe(600_000)
223
+ })
224
+ })