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.
- package/dist/agent-scheduler/index.js +29 -5
- package/dist/auth-broker/index.js +53 -13
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/notion-write-pretool.mjs +29 -5
- package/dist/cli/switchroom.js +2453 -1293
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +504 -100
- package/dist/vault/approvals/kernel-server.js +53 -13
- package/dist/vault/broker/server.js +162 -114
- 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 +73 -1
- package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
- package/telegram-plugin/dist/server.js +74 -2
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/gateway/approval-hold.ts +583 -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 +76 -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 +1482 -165
- 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/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 +209 -2
- package/telegram-plugin/operator-events.ts +23 -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/retry-api-call.ts +212 -2
- package/telegram-plugin/send-gate-degraded.test.ts +443 -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 +698 -0
- package/telegram-plugin/send-gate.ts +982 -0
- 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 +102 -2
- 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 +296 -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/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/gw-trace-gate.test.ts +105 -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/inline-keyboard-callbacks.test.ts +284 -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/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/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/session-model-file.test.ts +50 -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/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/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/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
|
@@ -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
|
+
})
|