switchroom 0.18.11 → 0.18.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -41,6 +41,43 @@ import { createSweepableStore } from '../gateway/pending-state-stores.js'
41
41
  import { StagingMap } from '../secret-detect/staging.js'
42
42
  import { InlineKeyboard } from 'grammy'
43
43
 
44
+ // Mock the auth-broker client so the `auth:use:` swap path is observable
45
+ // (spy on setActive) without a live UDS broker. Only handleAuthDashboardCallback
46
+ // touches getAuthBrokerClient, so this is inert for every other family here.
47
+ //
48
+ // The spy is created *inside* the factory and re-exported as a test-only
49
+ // handle (`__setActiveSpy`) rather than closed over from a `vi.hoisted`
50
+ // binding: bun's vitest-compat layer implements `vi.mock`/`vi.fn` but NOT
51
+ // `vi.hoisted`, so a hoisted-closure mock throws `vi.hoisted is not a
52
+ // function` under `bun test` (CI's bun-test-run shard runs this whole dir).
53
+ // A single `setActive` instance is closed over and returned by every
54
+ // `getAuthBrokerClient()` call, so the handler and the tests observe the
55
+ // same spy.
56
+ vi.mock('../gateway/auth-broker-client.js', () => {
57
+ const setActive = vi.fn(async () => ({ active: 'acct-b', fanned: ['a1'] }))
58
+ return {
59
+ getAuthBrokerClient: vi.fn(async () => ({ setActive })),
60
+ }
61
+ })
62
+ import { getAuthBrokerClient } from '../gateway/auth-broker-client.js'
63
+ // The mocked getAuthBrokerClient closes over one shared `setActive` spy and
64
+ // returns it on every call, so resolving the client here yields the SAME spy
65
+ // the handler observes. A getter defers the read to test-run time (after the
66
+ // mock is live under both runners); a beforeEach caches the resolved spy.
67
+ let resolvedSetActiveSpy: ReturnType<typeof vi.fn> | undefined
68
+ const brokerMock = {
69
+ get setActive(): ReturnType<typeof vi.fn> {
70
+ if (!resolvedSetActiveSpy) throw new Error('setActive spy not resolved yet')
71
+ return resolvedSetActiveSpy
72
+ },
73
+ }
74
+ beforeEach(async () => {
75
+ const client = (await getAuthBrokerClient('test-agent')) as {
76
+ setActive: ReturnType<typeof vi.fn>
77
+ }
78
+ resolvedSetActiveSpy = client.setActive
79
+ })
80
+
44
81
  // ── Fakes ────────────────────────────────────────────────────────────────
45
82
 
46
83
  interface FakeCtxOpts {
@@ -568,6 +605,34 @@ describe('handleAuthDashboardCallback', () => {
568
605
  show_alert: false,
569
606
  })
570
607
  })
608
+
609
+ // Security gate (found in an adversarial security review): the auth: family
610
+ // is a mutating callback family (auth:use:<label> → broker.setActive, a
611
+ // fleet-wide OAuth account swap) and MUST enforce the same allowFrom gate as
612
+ // every sibling family. Without the gate, any tapper on an admin forum/
613
+ // supergroup with an empty group allowFrom could swap the active account.
614
+ it('rejects an auth:use swap from a sender not on allowFrom (never reaches setActive)', async () => {
615
+ const { deps } = makeDeps() // allowFrom = ['111','222']
616
+ const h = createCallbackQueryHandlers(deps)
617
+ brokerMock.setActive.mockClear()
618
+ const { ctx, raw } = makeCtx({ senderId: '999', data: 'auth:use:acct-b' })
619
+ await h.handleAuthDashboardCallback(ctx)
620
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
621
+ // The load-bearing assertion: the swap must not fire for an unauthorized tap.
622
+ expect(brokerMock.setActive).not.toHaveBeenCalled()
623
+ })
624
+
625
+ it('lets an allowFrom sender through to the setActive swap path', async () => {
626
+ const { deps } = makeDeps()
627
+ const h = createCallbackQueryHandlers(deps)
628
+ brokerMock.setActive.mockClear()
629
+ const { ctx, raw } = makeCtx({ senderId: '111', data: 'auth:use:acct-b' })
630
+ await h.handleAuthDashboardCallback(ctx)
631
+ expect(brokerMock.setActive).toHaveBeenCalledWith('acct-b')
632
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith(
633
+ expect.objectContaining({ text: expect.stringContaining('Switched fleet → acct-b') }),
634
+ )
635
+ })
571
636
  })
572
637
 
573
638
  // ── vg:* — grant wizard + management ────────────────────────────────────
@@ -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
+ })