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
@@ -0,0 +1,531 @@
1
+ /**
2
+ * #3084 follow-up, PR 1 — an undeliverable permission card must be RECORDED,
3
+ * not discarded.
4
+ *
5
+ * The incident: overlord was flood-banned for 4.6h, a permission prompt fired
6
+ * during the ban, the card send threw, and the catch handler wrote the error to
7
+ * stderr and dropped it. The operator never learned an agent was blocked.
8
+ *
9
+ * These tests pin the record itself: its schema (a contract with the
10
+ * switchroom-web reader), its file mode (0644 — web runs as uid 1000 and cannot
11
+ * read the agent's 0600 state files), and the hard rule that it carries
12
+ * METADATA ONLY. A world-readable file that could hold raw tool input is a
13
+ * credential leak — the failure `approve-what-my-agent-can-touch.md` names.
14
+ *
15
+ * No fake timers anywhere: this file must pass under BOTH vitest and bun, and
16
+ * bun's shim lacks `vi.useFakeTimers()` (the trap that made 5 tests silently
17
+ * ERROR on #3097).
18
+ */
19
+
20
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
21
+ import { mkdtempSync, mkdirSync, chmodSync, rmSync, statSync, lstatSync, existsSync, readFileSync, writeFileSync, symlinkSync } from 'node:fs'
22
+ import { tmpdir } from 'node:os'
23
+ import { join } from 'node:path'
24
+ import {
25
+ createBlockedApprovalStore,
26
+ blockedApprovalsDir,
27
+ isHeldUndeliverable,
28
+ safeActionForRecord,
29
+ selectOldestHeld,
30
+ BLOCKED_APPROVAL_DIR_MODE,
31
+ BLOCKED_APPROVAL_FILE_MODE,
32
+ type BlockedApprovalRecord,
33
+ } from '../gateway/approval-hold.js'
34
+ import { naturalAction } from '../permission-title.js'
35
+ import { isFloodWaitActiveError, FLOOD_WAIT_ACTIVE } from '../retry-api-call.js'
36
+
37
+ // Every path is a fresh tmpdir — never ~/.switchroom (the production state
38
+ // tree; a test that writes there corrupts a running fleet).
39
+ let root: string
40
+
41
+ beforeEach(() => {
42
+ root = mkdtempSync(join(tmpdir(), 'blocked-approvals-'))
43
+ })
44
+ afterEach(() => {
45
+ rmSync(root, { recursive: true, force: true })
46
+ })
47
+
48
+ const REC: BlockedApprovalRecord = {
49
+ agent: 'overlord',
50
+ requestId: 'req-abc123',
51
+ toolName: 'Bash',
52
+ action: 'run: npm test',
53
+ blockedSince: 1783753731607,
54
+ undeliverableSince: 1783753731607,
55
+ retryableAt: 1783767801049,
56
+ reason: 'flood_wait',
57
+ }
58
+
59
+ describe('blocked-approval record — the off-Telegram surface', () => {
60
+ it('writes the exact schema switchroom-web reads', () => {
61
+ const dir = blockedApprovalsDir(root)
62
+ const store = createBlockedApprovalStore(dir, 'overlord')
63
+ store.write(REC)
64
+
65
+ const onDisk = JSON.parse(readFileSync(join(dir, 'overlord.json'), 'utf-8'))
66
+ expect(onDisk).toEqual({
67
+ agent: 'overlord',
68
+ requestId: 'req-abc123',
69
+ toolName: 'Bash',
70
+ action: 'run: npm test',
71
+ blockedSince: 1783753731607,
72
+ undeliverableSince: 1783753731607,
73
+ retryableAt: 1783767801049,
74
+ reason: 'flood_wait',
75
+ })
76
+ })
77
+
78
+ it('lands at <dir>/<agent>.json so web can find every agent in one place', () => {
79
+ const dir = blockedApprovalsDir(root)
80
+ const store = createBlockedApprovalStore(dir, 'marko')
81
+ expect(store.path).toBe(join(root, 'blocked-approvals', 'marko.json'))
82
+ })
83
+
84
+ // THE MODE IS LOAD-BEARING. switchroom-web runs as uid 1000; the agent's
85
+ // telegram state (pending-perm-cards.json) is 0600 and it CANNOT read it —
86
+ // verified on the live box. A 0600 record renders a silently empty dashboard.
87
+ it('is world-readable (0644 file, 0755 dir) — a non-owner uid can read it', () => {
88
+ const dir = blockedApprovalsDir(root)
89
+ const store = createBlockedApprovalStore(dir, 'overlord')
90
+ store.write(REC)
91
+
92
+ const fileMode = statSync(store.path).mode & 0o777
93
+ const dirMode = statSync(dir).mode & 0o777
94
+
95
+ expect(fileMode).toBe(BLOCKED_APPROVAL_FILE_MODE)
96
+ // Spelled out as the property that actually matters: OTHER can read.
97
+ expect(fileMode & 0o004).toBe(0o004) // o+r on the file
98
+ expect(dirMode & 0o001).toBe(0o001) // o+x on the dir (traversable)
99
+ expect(dirMode & 0o004).toBe(0o004) // o+r on the dir
100
+ })
101
+
102
+ // Agents run as PER-AGENT non-root uids (AGENT_UID_MIN = 10001). A shared dir
103
+ // that is operator-owned — or root-owned, which is what Docker does to any
104
+ // bind source that doesn't exist — is NOT writable by uid 10001, so the write
105
+ // EACCESes and the record silently never appears. The dir must be
106
+ // world-writable, and sticky so no agent can delete another's record.
107
+ it('the shared dir is WORLD-WRITABLE — every agent uid can write its own record', () => {
108
+ const dir = blockedApprovalsDir(root)
109
+ createBlockedApprovalStore(dir, 'overlord').write(REC)
110
+
111
+ const dirMode = statSync(dir).mode & 0o7777
112
+ // THE load-bearing property. Agents are per-agent non-root uids (10001+); a
113
+ // dir they cannot write is a feature that silently never records anything.
114
+ expect(dirMode & 0o002).toBe(0o002)
115
+
116
+ // The sticky bit (0o1000) is defense-in-depth on top — it stops one agent
117
+ // deleting another's record. It is NOT relied on for correctness, and it
118
+ // cannot be: bun's chmodSync silently drops it (verified against `stat` —
119
+ // node yields 1777, bun yields 777), and the gateway runs under bun. In
120
+ // production the host CLI (node) sets it via compose.ts. Cross-agent
121
+ // tampering is out of threat model anyway — `single-tenant` says every agent
122
+ // the operator wires in is implicitly trusted. So: assert the constant we
123
+ // ASK for, and assert only the writability we actually depend on.
124
+ expect(BLOCKED_APPROVAL_DIR_MODE).toBe(0o1777)
125
+ })
126
+
127
+ // THE BUG THE REVIEWER CAUGHT: without a fallback, a non-writable shared dir
128
+ // meant the whole feature was a silent no-op in production.
129
+ it('falls back to the agent\'s own state dir when the shared dir is not writable', () => {
130
+ // Simulate the production failure: Docker auto-created the bind source as
131
+ // root:root, so this agent's uid can neither create the dir nor chmod it.
132
+ // Modelled as a read-only PARENT — the store can't mkdir the shared dir
133
+ // inside it, and can't chmod its way out either.
134
+ const readOnlyParent = join(root, 'root-owned')
135
+ mkdirSync(readOnlyParent, { recursive: true })
136
+ chmodSync(readOnlyParent, 0o555) // r-xr-xr-x
137
+ const unwritable = join(readOnlyParent, 'blocked-approvals') // cannot be created
138
+ const ownDir = join(root, 'agent-state')
139
+ mkdirSync(ownDir, { recursive: true })
140
+
141
+ const store = createBlockedApprovalStore(unwritable, 'overlord', ownDir)
142
+ store.write(REC)
143
+
144
+ // The record MUST exist somewhere — never silently lost.
145
+ expect(store.read()?.requestId).toBe('req-abc123')
146
+ expect(store.path).toBe(join(ownDir, 'blocked-approval.json'))
147
+ expect(statSync(store.path).mode & 0o777).toBe(BLOCKED_APPROVAL_FILE_MODE)
148
+
149
+ // …and clear() reaches the fallback location too.
150
+ store.clear()
151
+ expect(store.read()).toBeNull()
152
+ })
153
+
154
+ it('never carries raw tool input — the record is metadata only', () => {
155
+ const dir = blockedApprovalsDir(root)
156
+ const store = createBlockedApprovalStore(dir, 'overlord')
157
+ store.write(REC)
158
+
159
+ const raw = readFileSync(store.path, 'utf-8')
160
+ // The file is 0644. Anything derived from raw tool input could be a
161
+ // credential (`Bash(export TOKEN=...)`), so none of these may ever appear.
162
+ expect(raw).not.toContain('inputPreview')
163
+ expect(raw).not.toContain('input_preview')
164
+ expect(raw).not.toContain('cardText')
165
+ expect(raw).not.toContain('card_text')
166
+ expect(Object.keys(JSON.parse(raw)).sort()).toEqual([
167
+ 'action',
168
+ 'agent',
169
+ 'blockedSince',
170
+ 'reason',
171
+ 'requestId',
172
+ 'retryableAt',
173
+ 'toolName',
174
+ 'undeliverableSince',
175
+ ])
176
+ })
177
+
178
+ it('THROWS if a caller ever tries to widen the record with tool input', () => {
179
+ const store = createBlockedApprovalStore(blockedApprovalsDir(root), 'overlord')
180
+ const leaky = {
181
+ ...REC,
182
+ inputPreview: 'export ANTHROPIC_' + 'TOKEN=sk-ant-' + 'oat01-secret',
183
+ } as unknown as BlockedApprovalRecord
184
+
185
+ expect(() => store.write(leaky)).toThrow(/must not carry raw tool input/)
186
+ // …and nothing was written, so the secret never touched a 0644 file.
187
+ expect(store.read()).toBeNull()
188
+ })
189
+
190
+ it('read() returns null when nothing is blocked, and after clear()', () => {
191
+ const store = createBlockedApprovalStore(blockedApprovalsDir(root), 'overlord')
192
+ expect(store.read()).toBeNull()
193
+
194
+ store.write(REC)
195
+ expect(store.read()?.requestId).toBe('req-abc123')
196
+
197
+ store.clear()
198
+ expect(store.read()).toBeNull()
199
+ // clear() on an absent record is a no-op, not a throw.
200
+ expect(() => store.clear()).not.toThrow()
201
+ })
202
+
203
+ it('survives a corrupt file rather than crashing the gateway', () => {
204
+ const dir = blockedApprovalsDir(root)
205
+ const store = createBlockedApprovalStore(dir, 'overlord')
206
+ store.write(REC)
207
+ writeFileSync(store.path, '{ not json', 'utf-8')
208
+
209
+ expect(store.read()).toBeNull()
210
+ // …and a fresh write repairs it.
211
+ store.write(REC)
212
+ expect(store.read()?.requestId).toBe('req-abc123')
213
+ })
214
+
215
+ // The shared dir is 1777 (the /tmp model): ANY local uid can pre-create
216
+ // `<agent>.json` as a symlink pointing at a file the agent uid can write,
217
+ // redirecting our 0644 write onto an arbitrary target. The sticky bit stops
218
+ // us unlinking a symlink we don't own, so the store must REFUSE the location
219
+ // and fall back — never write through the link.
220
+ it('refuses to write through a planted symlink (1777-dir hardening)', () => {
221
+ const dir = blockedApprovalsDir(root)
222
+ mkdirSync(dir, { recursive: true })
223
+ const victim = join(root, 'victim.txt')
224
+ writeFileSync(victim, 'do not clobber', 'utf-8')
225
+ symlinkSync(victim, join(dir, 'overlord.json'))
226
+ const ownDir = join(root, 'agent-state')
227
+ mkdirSync(ownDir, { recursive: true })
228
+
229
+ const store = createBlockedApprovalStore(dir, 'overlord', ownDir)
230
+ store.write(REC)
231
+
232
+ // The planted target is untouched, the symlink was not followed…
233
+ expect(readFileSync(victim, 'utf-8')).toBe('do not clobber')
234
+ expect(lstatSync(join(dir, 'overlord.json')).isSymbolicLink()).toBe(true)
235
+ // …and the record still landed (fallback), never silently lost.
236
+ expect(store.path).toBe(join(ownDir, 'blocked-approval.json'))
237
+ expect(store.read()?.requestId).toBe('req-abc123')
238
+ })
239
+
240
+ it('a symlink planted at the FALLBACK path is refused too', () => {
241
+ const readOnlyParent = join(root, 'root-owned')
242
+ mkdirSync(readOnlyParent, { recursive: true })
243
+ chmodSync(readOnlyParent, 0o555)
244
+ const unwritable = join(readOnlyParent, 'blocked-approvals')
245
+ const ownDir = join(root, 'agent-state')
246
+ mkdirSync(ownDir, { recursive: true })
247
+ const victim = join(root, 'victim.txt')
248
+ writeFileSync(victim, 'do not clobber', 'utf-8')
249
+ symlinkSync(victim, join(ownDir, 'blocked-approval.json'))
250
+
251
+ const store = createBlockedApprovalStore(unwritable, 'overlord', ownDir)
252
+ // Both locations refuse — degrade quietly, never write through the link.
253
+ expect(() => store.write(REC)).not.toThrow()
254
+ expect(readFileSync(victim, 'utf-8')).toBe('do not clobber')
255
+ })
256
+
257
+ // A host that recovers its shared dir must not DOUBLE-REPORT: an earlier
258
+ // write that landed in the fallback would otherwise linger there forever
259
+ // (web reads both locations).
260
+ it('a successful primary write removes a stale fallback record', () => {
261
+ const dir = blockedApprovalsDir(root)
262
+ const ownDir = join(root, 'agent-state')
263
+ mkdirSync(ownDir, { recursive: true })
264
+ const staleFallback = join(ownDir, 'blocked-approval.json')
265
+ // Simulate the earlier degraded write.
266
+ writeFileSync(staleFallback, JSON.stringify({ ...REC, requestId: 'req-stale' }), 'utf-8')
267
+
268
+ const store = createBlockedApprovalStore(dir, 'overlord', ownDir)
269
+ store.write(REC)
270
+
271
+ expect(store.path).toBe(join(dir, 'overlord.json'))
272
+ expect(existsSync(staleFallback)).toBe(false)
273
+ expect(store.read()?.requestId).toBe('req-abc123')
274
+ })
275
+
276
+ it('a write failure never throws into the gateway (best-effort surface)', () => {
277
+ // Point the store at a path that cannot be created (a FILE, not a dir).
278
+ const notADir = join(root, 'blocker')
279
+ writeFileSync(notADir, 'x', 'utf-8')
280
+ const store = createBlockedApprovalStore(join(notADir, 'nested'), 'overlord')
281
+
282
+ // Must degrade quietly: the SURFACE is best-effort, and a surface failure
283
+ // must never be able to fall the hold back into an auto-deny.
284
+ expect(() => store.write(REC)).not.toThrow()
285
+ })
286
+ })
287
+
288
+ /**
289
+ * The `action` field is the one free-text field in a WORLD-READABLE file, so it
290
+ * is the one place a credential could plausibly land. `naturalAction()` is NOT
291
+ * uniformly safe to put there — for Bash it interpolates the raw command:
292
+ *
293
+ * naturalAction('Bash', '{"command":"curl -H \'Authorization: Bearer sk-…\'"}')
294
+ * → "run: curl -H 'Authorization: Bearer sk-…"
295
+ *
296
+ * safeActionForRecord() is the guard. It fails CLOSED: only tools whose action
297
+ * text is basename-derived keep their specifics; everything else degrades to
298
+ * input-free phrasing.
299
+ */
300
+ describe('safeActionForRecord — no raw tool input in a 0644 file', () => {
301
+ it('REDACTS a Bash command — the leak naturalAction would have written', () => {
302
+ const secret = 'curl -H "Authorization: Bearer sk-ant-' + 'oat01-SECRET" https://x'
303
+ const input = JSON.stringify({ command: secret })
304
+
305
+ // What the naive implementation would have put on disk:
306
+ expect(naturalAction('Bash', input)).toContain('Authorization')
307
+
308
+ // What we actually write:
309
+ const safe = safeActionForRecord(naturalAction, 'Bash', input)
310
+ expect(safe).toBe('run shell commands')
311
+ expect(safe).not.toContain('Authorization')
312
+ expect(safe).not.toContain('sk-ant-')
313
+ expect(safe).not.toContain('curl')
314
+ })
315
+
316
+ it('redacts the other free-form-input tools too', () => {
317
+ expect(safeActionForRecord(naturalAction, 'Grep', '{"pattern":"SECRET_KEY"}'))
318
+ .toBe('search files')
319
+ expect(safeActionForRecord(naturalAction, 'Glob', '{"pattern":"/etc/**"}'))
320
+ .toBe('search files')
321
+ // A URL routinely carries a token in its query string.
322
+ expect(safeActionForRecord(naturalAction, 'WebFetch', '{"url":"https://x?token=abc123"}'))
323
+ .toBe('fetch a web page')
324
+ expect(
325
+ safeActionForRecord(naturalAction, 'WebFetch', '{"url":"https://x?token=abc123"}'),
326
+ ).not.toContain('abc123')
327
+ })
328
+
329
+ it('fails CLOSED for unknown and MCP tools (arbitrary args)', () => {
330
+ expect(safeActionForRecord(naturalAction, 'mcp__brevo__send', '{"to":"a@b.com"}'))
331
+ .not.toContain('a@b.com')
332
+ expect(safeActionForRecord(naturalAction, 'SomeFutureTool', '{"secret":"xyz789"}'))
333
+ .not.toContain('xyz789')
334
+ })
335
+
336
+ it('KEEPS the useful specifics for basename-derived tools', () => {
337
+ // The record still has to tell the operator what is blocked, or it is a
338
+ // useless surface. A file basename is the right trade.
339
+ expect(safeActionForRecord(naturalAction, 'Edit', '{"file_path":"/home/k/supplement-log.md"}'))
340
+ .toBe('edit: supplement-log.md')
341
+ expect(safeActionForRecord(naturalAction, 'Write', '{"file_path":"/tmp/out.txt"}'))
342
+ .toBe('write: out.txt')
343
+ })
344
+ })
345
+
346
+ describe('the undeliverable mark', () => {
347
+ it('is what the flood-wait failure produces — and only flood-wait', () => {
348
+ // The real marker `retryApiCall` throws when it refuses to send into an
349
+ // open ban (retry-api-call.ts:192). This is its first production consumer.
350
+ const floodErr = Object.assign(new Error(FLOOD_WAIT_ACTIVE), {
351
+ retryAfterSec: 16739,
352
+ untilTs: 1783767801049,
353
+ })
354
+ expect(isFloodWaitActiveError(floodErr)).toBe(true)
355
+
356
+ // A 400 (malformed card) is NOT a hold — that send will never succeed, and
357
+ // holding on it would park the agent forever.
358
+ expect(isFloodWaitActiveError(new Error('Bad Request: can\'t parse entities'))).toBe(false)
359
+ expect(isFloodWaitActiveError(undefined)).toBe(false)
360
+ })
361
+
362
+ it('isHeldUndeliverable is the TTL-freeze predicate', () => {
363
+ expect(isHeldUndeliverable({ undeliverable: null })).toBe(false)
364
+ expect(isHeldUndeliverable({})).toBe(false)
365
+ expect(
366
+ isHeldUndeliverable({
367
+ undeliverable: { since: 1, retryableAt: 2, reason: 'flood_wait' },
368
+ }),
369
+ ).toBe(true)
370
+ })
371
+ })
372
+
373
+ /**
374
+ * The reconcile SELECTION — extracted from gateway.ts's
375
+ * reconcileBlockedApprovals() so its two load-bearing rules are unit-testable:
376
+ * oldest hold wins, and clear only when nothing at all is held.
377
+ */
378
+ describe('selectOldestHeld — the reconcile selection', () => {
379
+ const mark = (since: number) =>
380
+ ({ since, retryableAt: since + 1000, reason: 'flood_wait' as const })
381
+
382
+ it('returns null only when NO entry is held (⇒ caller may clear)', () => {
383
+ expect(selectOldestHeld([])).toBeNull()
384
+ expect(
385
+ selectOldestHeld([
386
+ ['a', { undeliverable: null }],
387
+ ['b', {}],
388
+ ]),
389
+ ).toBeNull()
390
+ })
391
+
392
+ it('oldest hold wins among multiple concurrent holds', () => {
393
+ const sel = selectOldestHeld([
394
+ ['newer', { undeliverable: mark(200) }],
395
+ ['oldest', { undeliverable: mark(100) }],
396
+ ['newest', { undeliverable: mark(300) }],
397
+ ])
398
+ expect(sel?.requestId).toBe('oldest')
399
+ expect(sel?.mark.since).toBe(100)
400
+ })
401
+
402
+ it('resolving one of several holds surfaces the NEXT-oldest, not a clear', () => {
403
+ // The bug a naive clear() would have: one resolution blanks the file while
404
+ // another request is still genuinely blocked.
405
+ const entries: Array<[string, { undeliverable?: ReturnType<typeof mark> | null }]> = [
406
+ ['first', { undeliverable: null }], // just resolved
407
+ ['second', { undeliverable: mark(150) }], // still held
408
+ ]
409
+ const sel = selectOldestHeld(entries)
410
+ expect(sel).not.toBeNull()
411
+ expect(sel?.requestId).toBe('second')
412
+ })
413
+
414
+ it('skips unheld entries entirely regardless of position', () => {
415
+ const sel = selectOldestHeld([
416
+ ['unheld', {}],
417
+ ['held', { undeliverable: mark(500) }],
418
+ ])
419
+ expect(sel?.requestId).toBe('held')
420
+ })
421
+ })
422
+
423
+ /**
424
+ * gateway.ts has top-level side effects and is NOT unit-importable (the same
425
+ * constraint `permission-card-routing.test.ts` documents), so the WIRING is
426
+ * pinned as source text. The logic itself is unit-tested above; these guard
427
+ * that the gateway actually calls it, and can't silently revert to discarding
428
+ * the error.
429
+ */
430
+ describe('gateway wiring — the failure handler records instead of discarding', () => {
431
+ const GATEWAY_SRC = readFileSync(
432
+ new URL('../gateway/gateway.ts', import.meta.url),
433
+ 'utf8',
434
+ )
435
+
436
+ /**
437
+ * Body of `postPermissionCard` — the card emitter, and the owner of the
438
+ * send-failure handler. (The send was extracted out of `onPermissionRequest`
439
+ * so the reaper can re-drive it; the failure handler moved with it.)
440
+ */
441
+ function onPermissionRequestBody(): string {
442
+ const start = GATEWAY_SRC.indexOf('function postPermissionCard(')
443
+ expect(start).toBeGreaterThan(-1)
444
+ const rest = GATEWAY_SRC.slice(start)
445
+ const end = rest.indexOf('\nfunction ', 1)
446
+ expect(end).toBeGreaterThan(-1)
447
+ return rest.slice(0, end)
448
+ }
449
+
450
+ it('discriminates the send failure with isFloodWaitActiveError', () => {
451
+ // Before this change the catch was a bare stderr write — the error, which
452
+ // already carried the FLOOD_WAIT_ACTIVE marker post-#3094, was thrown away.
453
+ expect(onPermissionRequestBody()).toContain('isFloodWaitActiveError(e)')
454
+ })
455
+
456
+ it('marks the pending entry undeliverable with the window end', () => {
457
+ const body = onPermissionRequestBody()
458
+ expect(body).toMatch(/\.undeliverable = \{/)
459
+ expect(body).toContain('retryableAt, reason')
460
+ // A flood-wait carries Telegram's own window end; other transient faults
461
+ // (network, 5xx) have no such signal and back off instead.
462
+ // A flood-wait carries Telegram's own window end; every other transient
463
+ // fault has no such signal and backs off instead (1m, 2m, 4m … capped).
464
+ expect(body).toContain('isFloodWaitActiveError(e) ? e.untilTs : now + heldRetryBackoffMs(failures)')
465
+ })
466
+
467
+ it('does NOT mark undeliverable when a card already LANDED on another target', () => {
468
+ // The send fans out to several chats/topics; one flood-walled leg must not
469
+ // write a blocked record while the operator can see a live card.
470
+ // (#3108 renamed the handler's `pend` to `live` when the send moved into
471
+ // postPermissionCard — the pin follows the merged code.)
472
+ expect(onPermissionRequestBody()).toContain('if (live.cards.length > 0) return')
473
+ })
474
+
475
+ it('a re-failure preserves the ORIGINAL since (the "first failed" contract)', () => {
476
+ expect(onPermissionRequestBody()).toContain('since: live.undeliverable?.since ?? now')
477
+ })
478
+
479
+ it('reconcile delegates selection to the unit-tested selectOldestHeld', () => {
480
+ const at = GATEWAY_SRC.indexOf('function reconcileBlockedApprovals(')
481
+ expect(at).toBeGreaterThan(-1)
482
+ const fn = GATEWAY_SRC.slice(at, GATEWAY_SRC.indexOf('\n}', at))
483
+ expect(fn).toContain('selectOldestHeld(pendingPermissions)')
484
+ })
485
+
486
+ it('writes the shared record IN the failure handler (live in <1s, not on a 60s tick)', () => {
487
+ // reconcileBlockedApprovals() derives the record from live pending state and
488
+ // writes it — called synchronously here, not deferred to the reaper tick.
489
+ expect(onPermissionRequestBody()).toContain('reconcileBlockedApprovals()')
490
+ })
491
+
492
+ it('the record it writes uses safeActionForRecord, NOT bare naturalAction', () => {
493
+ const at = GATEWAY_SRC.indexOf('function reconcileBlockedApprovals(')
494
+ expect(at).toBeGreaterThan(-1)
495
+ const fn = GATEWAY_SRC.slice(at, GATEWAY_SRC.indexOf('\n}', at))
496
+ // Bare naturalAction() would interpolate the raw Bash command into a
497
+ // world-readable file. The guard must be on the write path itself.
498
+ expect(fn).toContain('action: safeActionForRecord(')
499
+ expect(fn).not.toMatch(/action: naturalAction\(/)
500
+ expect(fn).not.toContain('input_preview:')
501
+ expect(fn).not.toContain('inputPreview')
502
+ })
503
+
504
+ it('the surface RECONCILES from live pending state (several holds at once are real)', () => {
505
+ // Claude Code issues parallel tool calls; cancelPendingPermissionsForHalt
506
+ // iterates multiple pendings. A naive clear() on one resolution would delete
507
+ // the file while ANOTHER request was still held.
508
+ expect(GATEWAY_SRC).toContain('function reconcileBlockedApprovals(')
509
+ expect(GATEWAY_SRC).not.toContain('function clearBlockedApproval(')
510
+ })
511
+
512
+ it('boot reconciles, so a restart mid-hold cannot orphan a record forever', () => {
513
+ const at = GATEWAY_SRC.indexOf('blocked-approval boot reconcile failed')
514
+ expect(at).toBeGreaterThan(-1)
515
+ })
516
+
517
+ it('logs a distinct, greppable HELD signature that says NOT denied', () => {
518
+ const body = onPermissionRequestBody()
519
+ expect(body).toContain('permission-card HELD (undeliverable)')
520
+ expect(body).toContain('held, NOT denied')
521
+ })
522
+
523
+ it('does NOT hold on PERMANENT errors (a 400 would park the agent forever)', () => {
524
+ // holdReasonFor() is the whole guard: transient causes (flood-wait, give-up,
525
+ // local-resource) are HELD; a permanent 400/403 returns null and falls
526
+ // through to the TTL, which PR 3 makes safe with a missed-approvals fallback.
527
+ const body = onPermissionRequestBody()
528
+ expect(body).toContain('const reason = holdReasonFor(e)')
529
+ expect(body).toContain('if (reason == null) return')
530
+ })
531
+ })