switchroom 0.19.18 → 0.19.19

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 (38) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +3392 -1569
  7. package/dist/host-control/main.js +12209 -11396
  8. package/dist/vault/approvals/kernel-server.js +60 -7
  9. package/dist/vault/broker/server.js +206 -76
  10. package/package.json +4 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/telegram-plugin/bridge/bridge.ts +14 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1644 -1044
  15. package/telegram-plugin/dist/server.js +13 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  17. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  18. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  19. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  20. package/telegram-plugin/gateway/store-file.ts +244 -0
  21. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  22. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  23. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  24. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  25. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  26. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  27. package/telegram-plugin/worker-activity-feed.ts +51 -1
  28. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  29. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  30. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  31. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  32. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  37. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  38. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Atomic-write + corruption contract for the gateway's four small JSON state
3
+ * stores: `pending-card-store`, `scoped-grant-store`, `missed-approvals-store`,
4
+ * `always-allow-persist-queue`.
5
+ *
6
+ * The bug these pin: each store did a non-atomic read-modify-write straight
7
+ * over the destination (`writeFileSync(dest, …)` = open + O_TRUNC + write),
8
+ * and wrapped the read in `catch { return [] }`. A crash between the truncate
9
+ * and the write left a TORN file; on the next boot the parse threw, the catch
10
+ * swallowed it, and every pending approval card / live scoped grant was
11
+ * silently forgotten. Nothing was logged. The operator just saw dead buttons.
12
+ *
13
+ * SCOPE OF THE CLAIM — deliberately narrow. What is asserted here is ATOMIC
14
+ * REPLACEMENT: a reader (or a crash) sees the whole old file or the whole new
15
+ * file, never a half-written one. That is NOT the same as crash-durability
16
+ * against power loss: `atomicWriteFileSync` fsyncs the tempfile but not the
17
+ * PARENT DIRECTORY, so an unjournalled dirent can still revert the rename
18
+ * after a power cut (tracked as a separate follow-up against
19
+ * `src/util/atomic.ts`, which is shared with vault entries and OAuth tokens
20
+ * and needs its own review). Whole-old-or-whole-new holds either way, and
21
+ * that is what the torn-file bug was about.
22
+ */
23
+
24
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
25
+ import {
26
+ mkdtempSync,
27
+ mkdirSync,
28
+ rmSync,
29
+ writeFileSync,
30
+ readFileSync,
31
+ readdirSync,
32
+ statSync,
33
+ utimesSync,
34
+ } from 'node:fs'
35
+ import { join } from 'node:path'
36
+ import { tmpdir } from 'node:os'
37
+
38
+ import { createPendingCardStore, type PersistedApprovalCard } from '../gateway/pending-card-store.js'
39
+ import { createScopedGrantStore } from '../gateway/scoped-grant-store.js'
40
+ import { createMissedApprovalsStore, type MissedApproval } from '../gateway/missed-approvals-store.js'
41
+ import {
42
+ atomicWriteSeam,
43
+ createAlwaysAllowPersistQueue,
44
+ } from '../gateway/always-allow-persist-queue.js'
45
+ import { MAX_QUARANTINED_COPIES, quarantineCorruptStoreFile } from '../gateway/store-file.js'
46
+
47
+ let dir: string
48
+ let logged: string[]
49
+ const log = (line: string) => {
50
+ logged.push(line)
51
+ }
52
+
53
+ beforeEach(() => {
54
+ dir = mkdtempSync(join(tmpdir(), 'store-atomic-write-'))
55
+ logged = []
56
+ })
57
+ afterEach(() => {
58
+ rmSync(dir, { recursive: true, force: true })
59
+ })
60
+
61
+ /** The bytes a crash between truncate and write leaves behind. */
62
+ const TORN = '[{"family":"vault_request_access","stageId":"abc'
63
+
64
+ function quarantinedFiles(base: string): string[] {
65
+ return readdirSync(dir).filter(f => f.startsWith(`${base}.corrupt-`))
66
+ }
67
+
68
+ function sampleCard(stageId: string): PersistedApprovalCard {
69
+ return {
70
+ family: 'vault_request_access',
71
+ stageId,
72
+ agent: 'clerk',
73
+ chatId: '123',
74
+ stagedAt: 1_700_000_000_000,
75
+ key: 'coolify/api-token',
76
+ scope: 'read',
77
+ ttlSeconds: 900,
78
+ }
79
+ }
80
+
81
+ function sampleMiss(requestId: string): MissedApproval {
82
+ return {
83
+ requestId,
84
+ toolName: 'mcp__brevo__post',
85
+ action: 'post to Brevo',
86
+ chatId: '123',
87
+ timedOutAt: 1_700_000_000_000,
88
+ }
89
+ }
90
+
91
+ describe('a torn file must NOT silently yield an empty store', () => {
92
+ it('pending-card-store: quarantines the torn bytes and logs loudly', () => {
93
+ writeFileSync(join(dir, 'pending-approval-cards.json'), TORN)
94
+
95
+ const store = createPendingCardStore(dir, log)
96
+ expect(store.loadAll()).toEqual([])
97
+
98
+ // The loss is OBSERVABLE: corrupt bytes preserved + a loud log line.
99
+ const quarantined = quarantinedFiles('pending-approval-cards.json')
100
+ expect(quarantined).toHaveLength(1)
101
+ expect(readFileSync(join(dir, quarantined[0]!), 'utf-8')).toBe(TORN)
102
+ expect(logged.join('')).toMatch(/pending-card-store CORRUPT/)
103
+ expect(logged.join('')).toMatch(/LOST/)
104
+ })
105
+
106
+ it('scoped-grant-store: quarantines the torn bytes and logs loudly', () => {
107
+ writeFileSync(join(dir, 'scoped-grants.json'), TORN)
108
+
109
+ const store = createScopedGrantStore(dir, {}, log)
110
+ expect(store.load(Date.now()).size).toBe(0)
111
+
112
+ expect(quarantinedFiles('scoped-grants.json')).toHaveLength(1)
113
+ expect(logged.join('')).toMatch(/scoped-grant-store CORRUPT/)
114
+ })
115
+
116
+ it('missed-approvals-store: quarantines the torn bytes and logs loudly', () => {
117
+ writeFileSync(join(dir, 'missed-approvals.json'), '{"pending":[{"requestId":"r1"')
118
+
119
+ const store = createMissedApprovalsStore(dir, log)
120
+ expect(store.listPending()).toEqual([])
121
+
122
+ expect(quarantinedFiles('missed-approvals.json')).toHaveLength(1)
123
+ expect(logged.join('')).toMatch(/missed-approvals-store CORRUPT/)
124
+ })
125
+
126
+ it('always-allow-persist-queue: quarantines the torn bytes and logs loudly', () => {
127
+ writeFileSync(join(dir, 'always-allow-persist-queue.json'), '{"entries":[{"id":"clerk::Ski')
128
+
129
+ const q = createAlwaysAllowPersistQueue(dir, undefined, log)
130
+ expect(q.listAll()).toEqual([])
131
+
132
+ expect(quarantinedFiles('always-allow-persist-queue.json')).toHaveLength(1)
133
+ expect(logged.join('')).toMatch(/always-allow-persist-queue CORRUPT/)
134
+ })
135
+
136
+ it('a MISSING file is the normal cold start — no quarantine, no alarm', () => {
137
+ const store = createPendingCardStore(dir, log)
138
+ expect(store.loadAll()).toEqual([])
139
+ expect(readdirSync(dir)).toEqual([])
140
+ expect(logged).toEqual([])
141
+ })
142
+
143
+ it('quarantine happens once — the next boot starts clean instead of re-alarming', () => {
144
+ writeFileSync(join(dir, 'pending-approval-cards.json'), TORN)
145
+ createPendingCardStore(dir, log).loadAll()
146
+ logged = []
147
+
148
+ createPendingCardStore(dir, log).loadAll()
149
+ expect(logged).toEqual([])
150
+ expect(quarantinedFiles('pending-approval-cards.json')).toHaveLength(1)
151
+ })
152
+ })
153
+
154
+ describe('valid JSON whose LIST FIELD is not a list is corruption, not "empty"', () => {
155
+ // The original silent-loss bug in a second costume: `{"pending": null}` and
156
+ // `{"entries": {}}` parse fine and reach an object at top level, so a naive
157
+ // `Array.isArray(x) ? x : []` coercion swallows them exactly the way the
158
+ // old `catch { return [] }` did — dead buttons, nothing logged.
159
+
160
+ it('missed-approvals-store: `pending` present but null quarantines', () => {
161
+ writeFileSync(join(dir, 'missed-approvals.json'), '{"pending":null,"delivered":[]}')
162
+
163
+ expect(createMissedApprovalsStore(dir, log).listPending()).toEqual([])
164
+ expect(quarantinedFiles('missed-approvals.json')).toHaveLength(1)
165
+ expect(logged.join('')).toMatch(/`pending` is present but not an array/)
166
+ })
167
+
168
+ it('missed-approvals-store: `delivered` present but an object quarantines', () => {
169
+ writeFileSync(join(dir, 'missed-approvals.json'), '{"pending":[],"delivered":{}}')
170
+
171
+ expect(createMissedApprovalsStore(dir, log).getDigest('d1')).toBeUndefined()
172
+ expect(quarantinedFiles('missed-approvals.json')).toHaveLength(1)
173
+ expect(logged.join('')).toMatch(/`delivered` is present but not an array/)
174
+ })
175
+
176
+ it('always-allow-persist-queue: `entries` present but an object quarantines', () => {
177
+ writeFileSync(join(dir, 'always-allow-persist-queue.json'), '{"entries":{}}')
178
+
179
+ expect(createAlwaysAllowPersistQueue(dir, undefined, log).listAll()).toEqual([])
180
+ expect(quarantinedFiles('always-allow-persist-queue.json')).toHaveLength(1)
181
+ expect(logged.join('')).toMatch(/`entries` is present but not an array/)
182
+ })
183
+
184
+ it('an ABSENT list is the legitimate partial shape — silent, no quarantine', () => {
185
+ writeFileSync(join(dir, 'missed-approvals.json'), '{}')
186
+ writeFileSync(join(dir, 'always-allow-persist-queue.json'), '{}')
187
+
188
+ expect(createMissedApprovalsStore(dir, log).listPending()).toEqual([])
189
+ expect(createAlwaysAllowPersistQueue(dir, undefined, log).listAll()).toEqual([])
190
+
191
+ expect(quarantinedFiles('missed-approvals.json')).toEqual([])
192
+ expect(quarantinedFiles('always-allow-persist-queue.json')).toEqual([])
193
+ expect(logged).toEqual([])
194
+ })
195
+ })
196
+
197
+ describe('an update replaces the destination by rename — it is never truncated in place', () => {
198
+ /**
199
+ * rename(2) swaps a NEW inode into the destination name, so a reader (or a
200
+ * crash) only ever sees the whole old file or the whole new file. An
201
+ * in-place `writeFileSync` keeps the destination inode and truncates it
202
+ * first — that truncate window is the torn-file bug. Inode identity is the
203
+ * observable difference.
204
+ */
205
+ it('pending-card-store', () => {
206
+ const store = createPendingCardStore(dir, log)
207
+ store.add(sampleCard('s1'))
208
+ const file = join(dir, 'pending-approval-cards.json')
209
+ const first = statSync(file).ino
210
+
211
+ store.add(sampleCard('s2'))
212
+ expect(statSync(file).ino).not.toBe(first)
213
+ expect(store.loadAll().map(c => c.stageId)).toEqual(['s1', 's2'])
214
+ // No tempfile left behind on a successful write.
215
+ expect(readdirSync(dir)).toEqual(['pending-approval-cards.json'])
216
+ // Perms stay owner-only even across a replace.
217
+ expect(statSync(file).mode & 0o777).toBe(0o600)
218
+ })
219
+
220
+ it('scoped-grant-store', () => {
221
+ const store = createScopedGrantStore(dir, {}, log)
222
+ store.save(new Map())
223
+ const file = join(dir, 'scoped-grants.json')
224
+ const first = statSync(file).ino
225
+
226
+ store.save(new Map())
227
+ expect(statSync(file).ino).not.toBe(first)
228
+ expect(readdirSync(dir)).toEqual(['scoped-grants.json'])
229
+ expect(statSync(file).mode & 0o777).toBe(0o600)
230
+ })
231
+
232
+ it('missed-approvals-store', () => {
233
+ const store = createMissedApprovalsStore(dir, log)
234
+ store.add(sampleMiss('r1'))
235
+ const file = join(dir, 'missed-approvals.json')
236
+ const first = statSync(file).ino
237
+
238
+ store.add(sampleMiss('r2'))
239
+ expect(statSync(file).ino).not.toBe(first)
240
+ expect(store.listPending().map(e => e.requestId)).toEqual(['r1', 'r2'])
241
+ expect(readdirSync(dir)).toEqual(['missed-approvals.json'])
242
+ expect(statSync(file).mode & 0o777).toBe(0o600)
243
+ })
244
+
245
+ it('always-allow-persist-queue', async () => {
246
+ const q = createAlwaysAllowPersistQueue(dir, undefined, log)
247
+ await q.enqueue({ agentName: 'clerk', rule: 'Skill(calendar)', grantPhrase: 'use the calendar skill' })
248
+ const file = join(dir, 'always-allow-persist-queue.json')
249
+ const first = statSync(file).ino
250
+
251
+ await q.enqueue({ agentName: 'clerk', rule: 'Skill(drive)', grantPhrase: 'use drive' })
252
+ expect(statSync(file).ino).not.toBe(first)
253
+ expect(q.listAll()).toHaveLength(2)
254
+ expect(readdirSync(dir)).toEqual(['always-allow-persist-queue.json'])
255
+ expect(statSync(file).mode & 0o777).toBe(0o600)
256
+ })
257
+ })
258
+
259
+ describe('a write failure is surfaced and leaves the previous good content intact', () => {
260
+ // NOTE ON WHAT THIS PROVES: the good write below goes through the REAL
261
+ // atomic writer (`atomicWriteSeam`); only the second call is diverted to a
262
+ // thrower. So this asserts failure PROPAGATION plus "the destination still
263
+ // holds the last good snapshot" — it does NOT by itself prove atomicity
264
+ // (the inode suite above does that).
265
+ it('always-allow-persist-queue: the entry queued before the failure is still readable', async () => {
266
+ let fail = false
267
+ const seam = ((...args: Parameters<typeof atomicWriteSeam>) => {
268
+ if (fail) throw new Error('ENOSPC: no space left on device')
269
+ return atomicWriteSeam(...args)
270
+ }) as typeof atomicWriteSeam
271
+
272
+ const q = createAlwaysAllowPersistQueue(dir, seam, log)
273
+ await q.enqueue({ agentName: 'clerk', rule: 'Skill(calendar)', grantPhrase: 'use the calendar skill' })
274
+ const file = join(dir, 'always-allow-persist-queue.json')
275
+ const before = readFileSync(file, 'utf-8')
276
+ const beforeIno = statSync(file).ino
277
+
278
+ fail = true
279
+ await expect(
280
+ q.enqueue({ agentName: 'clerk', rule: 'Skill(drive)', grantPhrase: 'use drive' }),
281
+ ).rejects.toThrow(/ENOSPC/)
282
+
283
+ // Byte-identical AND the same inode — the failed write never even touched
284
+ // the destination, let alone truncated it.
285
+ expect(readFileSync(file, 'utf-8')).toBe(before)
286
+ expect(statSync(file).ino).toBe(beforeIno)
287
+ fail = false
288
+ expect(q.listAll().map(e => e.rule)).toEqual(['Skill(calendar)'])
289
+ })
290
+ })
291
+
292
+ describe('an UNREADABLE file (not ENOENT) is loud, is never quarantined on read, and fails closed on write', () => {
293
+ // A directory at the store path makes `readFileSync` fail EISDIR — a
294
+ // deterministic stand-in for the flaky-mount EIO / transient EACCES class
295
+ // (chmod tricks don't work: CI and the containers run as root).
296
+ it('pending-card-store: logs loudly and does NOT quarantine on the read', () => {
297
+ mkdirSync(join(dir, 'pending-approval-cards.json'))
298
+
299
+ expect(createPendingCardStore(dir, log).loadAll()).toEqual([])
300
+
301
+ expect(logged.join('')).toMatch(/pending-card-store read FAILED/)
302
+ expect(logged.join('')).toMatch(/EISDIR/)
303
+ // Crucially NOT quarantined — a file we merely failed to read may still
304
+ // hold perfectly good state, and a transient fault must not destroy it.
305
+ expect(quarantinedFiles('pending-approval-cards.json')).toEqual([])
306
+ })
307
+
308
+ it('pending-card-store: the next write PRESERVES the unreadable file instead of clobbering it', () => {
309
+ mkdirSync(join(dir, 'pending-approval-cards.json'))
310
+ const store = createPendingCardStore(dir, log)
311
+
312
+ store.add(sampleCard('s1'))
313
+
314
+ // The bytes we could not read were moved aside, not overwritten...
315
+ expect(quarantinedFiles('pending-approval-cards.json')).toHaveLength(1)
316
+ expect(logged.join('')).toMatch(/Preserved the previous \(unreadable\) bytes/)
317
+ // ...and the store carried on with a fresh, valid file.
318
+ expect(store.loadAll().map(c => c.stageId)).toEqual(['s1'])
319
+ })
320
+
321
+ it('always-allow-persist-queue: the preserve step runs before the write', async () => {
322
+ mkdirSync(join(dir, 'always-allow-persist-queue.json'))
323
+ const q = createAlwaysAllowPersistQueue(dir, undefined, log)
324
+
325
+ await q.enqueue({ agentName: 'clerk', rule: 'Skill(calendar)', grantPhrase: 'use the calendar skill' })
326
+
327
+ expect(quarantinedFiles('always-allow-persist-queue.json')).toHaveLength(1)
328
+ expect(q.listAll().map(e => e.rule)).toEqual(['Skill(calendar)'])
329
+ })
330
+ })
331
+
332
+ describe('kill switch: a disabled scoped-grant store never touches the file', () => {
333
+ it('does not read, quarantine, or write when SWITCHROOM_SCOPED_GRANT_PERSIST=0', () => {
334
+ writeFileSync(join(dir, 'scoped-grants.json'), TORN)
335
+ const store = createScopedGrantStore(dir, { SWITCHROOM_SCOPED_GRANT_PERSIST: '0' }, log)
336
+
337
+ expect(store.enabled).toBe(false)
338
+ expect(store.load(Date.now()).size).toBe(0)
339
+ store.save(new Map())
340
+
341
+ // The pre-existing (corrupt) file is left exactly as found — a disabled
342
+ // store must not be the thing that moves the operator's data around.
343
+ expect(readFileSync(join(dir, 'scoped-grants.json'), 'utf-8')).toBe(TORN)
344
+ expect(quarantinedFiles('scoped-grants.json')).toEqual([])
345
+ expect(logged).toEqual([])
346
+ })
347
+ })
348
+
349
+ describe('quarantine copies do not collide and do not accumulate without bound', () => {
350
+ it('two quarantines in the same millisecond both survive (random suffix)', () => {
351
+ const file = join(dir, 'x.json')
352
+ for (const bytes of ['first', 'second']) {
353
+ writeFileSync(file, bytes)
354
+ quarantineCorruptStoreFile(file, 'test-store', 'forced', log)
355
+ }
356
+ const copies = quarantinedFiles('x.json')
357
+ expect(copies).toHaveLength(2)
358
+ expect(copies.map(f => readFileSync(join(dir, f), 'utf-8')).sort()).toEqual(['first', 'second'])
359
+ })
360
+
361
+ it(`keeps at most ${MAX_QUARANTINED_COPIES} forensic copies, newest wins`, () => {
362
+ const file = join(dir, 'x.json')
363
+ const total = MAX_QUARANTINED_COPIES + 4
364
+ for (let i = 0; i < total; i++) {
365
+ writeFileSync(file, `copy-${i}`)
366
+ quarantineCorruptStoreFile(file, 'test-store', 'forced', log)
367
+ }
368
+ const copies = quarantinedFiles('x.json')
369
+ expect(copies).toHaveLength(MAX_QUARANTINED_COPIES)
370
+ // The reap drops the OLDEST — exactly the newest MAX are kept, even
371
+ // though these all land in the same millisecond (so the ordering cannot
372
+ // be coming from the embedded timestamp alone).
373
+ expect(copies.map(f => readFileSync(join(dir, f), 'utf-8')).sort()).toEqual(
374
+ Array.from({ length: MAX_QUARANTINED_COPIES }, (_, i) => `copy-${total - MAX_QUARANTINED_COPIES + i}`),
375
+ )
376
+ })
377
+
378
+ it('a RESTARTED process does not get its fresh copy reaped as "oldest"', () => {
379
+ // Regression: ordering used to come from the `<epoch-ms>-<seq>` embedded
380
+ // in the filename, but `seq` restarts at 0 on every process boot. A prior
381
+ // process that burned seq 000001..000005 within millisecond T, followed
382
+ // by a restart quarantining in that same T, produced a NEW copy named
383
+ // `T-000000-…` — lexicographically the OLDEST of the set, so the reaper
384
+ // deleted precisely the forensic bytes the operator needed. Ordering is
385
+ // by mtime now, which no process restart can rewind.
386
+ const file = join(dir, 'x.json')
387
+ const stamp = Date.now()
388
+ for (let i = 1; i <= MAX_QUARANTINED_COPIES; i++) {
389
+ // The prior process's sequence numbers — deliberately HIGHER than the
390
+ // restarted process's (whose counter is back near 0), which is the
391
+ // whole point: a name-ordered reaper would rank the fresh copy oldest.
392
+ const seeded = `${file}.corrupt-${stamp}-${String(900_000 + i).padStart(6, '0')}-aaaaaaaa`
393
+ writeFileSync(seeded, `old-${i}`)
394
+ // Genuinely older on disk (the prior process wrote them seconds ago),
395
+ // while still carrying the same embedded millisecond in the NAME — so
396
+ // a name-ordered reaper and an mtime-ordered one disagree, which is
397
+ // exactly the discriminating case.
398
+ const past = new Date(Date.now() - (MAX_QUARANTINED_COPIES + 1 - i) * 1000)
399
+ utimesSync(seeded, past, past)
400
+ }
401
+
402
+ // The restarted process quarantines fresh bytes; its seq is back at 0.
403
+ writeFileSync(file, 'NEWEST-FORENSIC-BYTES')
404
+ quarantineCorruptStoreFile(file, 'test-store', 'forced', log)
405
+
406
+ const kept = quarantinedFiles('x.json').map(f => readFileSync(join(dir, f), 'utf-8'))
407
+ expect(kept).toHaveLength(MAX_QUARANTINED_COPIES)
408
+ expect(kept).toContain('NEWEST-FORENSIC-BYTES')
409
+ expect(kept).not.toContain('old-1') // the genuinely oldest went instead
410
+ })
411
+ })
@@ -21,8 +21,15 @@ describe("describeToolUse — friendly per-tool rendering (draft-mirror)", () =>
21
21
  expect(
22
22
  describeToolUse("Bash", { command: "ls -la /tmp", description: "List workspace" }),
23
23
  ).toBe("List workspace");
24
- // No description → safe generic, still never the raw command.
25
- expect(describeToolUse("Bash", { command: "grep -r foo ." })).toBe("Running a command");
24
+ // No description → sanitised PROGRAM-ONLY derivation, never the raw
25
+ // command (its args routinely carry tokens and private paths). The old
26
+ // behavior was a constant "Running a command" for every Bash call, which
27
+ // froze the sub-agent step feed on one line for a whole job.
28
+ expect(describeToolUse("Bash", { command: "grep -r foo ." })).toBe("Running grep");
29
+ // Nothing safe to derive → the generic label is still the floor.
30
+ expect(describeToolUse("Bash", { command: "$(cat /run/secrets/token) --x" })).toBe(
31
+ "Running a command",
32
+ );
26
33
  });
27
34
 
28
35
  it("Read/Edit/Write render the file basename, not the full path", () => {
@@ -68,3 +68,97 @@ describe('computeLabel — surface-tool suppression is key-agnostic', () => {
68
68
  expect(computeLabel('mcp__clerk-telegram__get_recent_messages', {})).toBe('Reading chat history')
69
69
  })
70
70
  })
71
+
72
+ // ─── Bash label without a model-authored description ─────────────────────
73
+ //
74
+ // `description` is OPTIONAL, so a sub-agent that never writes one used to
75
+ // produce the constant "Running a command" for every Bash call — the worker
76
+ // feed then deduped every repeat away and the card froze on one line for the
77
+ // whole job. The fallback derives a label from the command, but ONLY through
78
+ // an allowlist: program basename, plus one bare subcommand for known
79
+ // multiplexers. Command lines carry tokens, passwords, credential URLs and
80
+ // private paths, and this string is rendered into a Telegram message.
81
+ describe('computeLabel — Bash fallback when description is omitted', () => {
82
+ it('prefers the model-authored description when present', () => {
83
+ expect(computeLabel('Bash', { command: 'git push origin main', description: 'Push branch' }))
84
+ .toBe('Push branch')
85
+ })
86
+
87
+ it('derives the program name when description is missing', () => {
88
+ expect(computeLabel('Bash', { command: 'grep -r foo .' })).toBe('Running grep')
89
+ expect(computeLabel('Bash', { command: 'ls -la /var/log' })).toBe('Running ls')
90
+ })
91
+
92
+ it('adds a bare subcommand for known multiplexers', () => {
93
+ expect(computeLabel('Bash', { command: 'git status --short' })).toBe('Running git status')
94
+ expect(computeLabel('Bash', { command: 'docker ps -a' })).toBe('Running docker ps')
95
+ expect(computeLabel('Bash', { command: 'npm run lint' })).toBe('Running npm run')
96
+ })
97
+
98
+ it('produces DISTINCT labels for distinct commands (the frozen-card fix)', () => {
99
+ const labels = [
100
+ 'git status', 'npm test', 'ls /tmp', 'grep -n x y', 'docker logs c',
101
+ ].map((c) => computeLabel('Bash', { command: c }))
102
+ expect(new Set(labels).size).toBe(labels.length)
103
+ })
104
+
105
+ it('skips navigation prefixes and wrappers to the real program', () => {
106
+ expect(computeLabel('Bash', { command: 'cd /srv/app && git pull' })).toBe('Running git pull')
107
+ expect(computeLabel('Bash', { command: 'sudo systemctl restart nginx' }))
108
+ .toBe('Running systemctl restart')
109
+ expect(computeLabel('Bash', { command: 'FOO=bar deploy.sh' })).toBe('Running deploy.sh')
110
+ })
111
+
112
+ it('NEVER echoes arguments, flag values, URLs, or env assignments', () => {
113
+ // Token-shaped literals are assembled at runtime: GitHub Push Protection
114
+ // and check-no-pii-secrets both reject contiguous ones, fake or not
115
+ // (pattern from secret-detect-secretlint.test.ts).
116
+ const fakeAnthropic = 'sk-' + 'ant-oat01-FAKEVALUE'
117
+ const cases = [
118
+ 'curl -H "Authorization: Bearer ' + 'sk-' + 'live-abcdef123456" https://api.example.com/v1/x',
119
+ 'psql postgres://user:hunter2@db.internal:5432/prod -c "select 1"',
120
+ 'aws s3 cp s3://private-bucket/key.pem . --profile prod',
121
+ 'export TOKEN=ghp_AAAABBBBCCCCDDDD && ./run',
122
+ `echo "${fakeAnthropic}" > /root/.creds`,
123
+ 'ssh deploy@10.0.0.7 -i /root/.ssh/id_ed25519',
124
+ 'git clone https://x-access-token:ghs_SECRET@github.com/o/r.git',
125
+ ]
126
+ const forbidden = /sk-|ghp_|ghs_|hunter2|Bearer|SECRET|10\.0\.0\.7|@|:\/\/|=/
127
+ for (const command of cases) {
128
+ const label = computeLabel('Bash', { command })
129
+ expect(label).not.toBeNull()
130
+ expect(label).not.toMatch(forbidden)
131
+ // Whatever it says, it must be short and made of the allowlisted shape.
132
+ expect(label).toMatch(/^Running (a command|[A-Za-z][A-Za-z0-9._+-]{0,19}( [a-z][a-z-]{1,15})?)$/)
133
+ }
134
+ })
135
+
136
+ it('never inspects a heredoc / multiline body', () => {
137
+ const label = computeLabel('Bash', {
138
+ command: 'cat <<EOF > /tmp/x\nAPI_KEY=sk-live-shouldnotleak\nEOF',
139
+ })
140
+ expect(label).toBe('Running cat')
141
+ })
142
+
143
+ it('falls back to the generic label when nothing safe can be derived', () => {
144
+ expect(computeLabel('Bash', { command: '$(cat /run/secrets/token) --x' })).toBe('Running a command')
145
+ expect(computeLabel('Bash', {})).toBe('Running a command')
146
+ expect(computeLabel('Bash', { command: ' ' })).toBe('Running a command')
147
+ })
148
+ })
149
+
150
+ describe('computeLabel — Bash subcommand is never a flag value', () => {
151
+ it('only accepts the token IMMEDIATELY after a multiplexer', () => {
152
+ // A flag value (a private context/host/profile name) must never surface.
153
+ expect(computeLabel('Bash', { command: 'docker --context prod-internal ps' }))
154
+ .toBe('Running docker')
155
+ expect(computeLabel('Bash', { command: 'git -C /srv/private-app status' }))
156
+ .toBe('Running git')
157
+ expect(computeLabel('Bash', { command: 'aws --profile customer-acme s3 ls' }))
158
+ .toBe('Running aws')
159
+ })
160
+
161
+ it('still reads the plain subcommand form', () => {
162
+ expect(computeLabel('Bash', { command: 'git commit -m "wip"' })).toBe('Running git commit')
163
+ })
164
+ })
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ createWorkerActivityFeed,
4
+ stripRepeatSuffix,
5
+ repeatCountOf,
6
+ type WorkerActivityView,
7
+ type BotApiForWorkerFeed,
8
+ } from '../worker-activity-feed.js'
9
+
10
+ /**
11
+ * A worker whose steps all carry the SAME label must still visibly advance.
12
+ *
13
+ * The bug (observed live 2026-07-25, klanker): `description` is optional on
14
+ * Bash, so a sub-agent that never wrote one produced the constant label
15
+ * "Running a command" for every call. `accumulateNarrative` dropped every
16
+ * repeat as a duplicate, and the card held ONE line at a constant byte length
17
+ * for the whole job — editMessageText kept succeeding, so the surface looked
18
+ * healthy while conveying nothing. A wedged worker and a busy worker rendered
19
+ * identically.
20
+ *
21
+ * The fix counts repeats (`·×N`) instead of discarding them, gated on
22
+ * `view.toolCount` so that a re-emitted UNCHANGED view (the watcher does this
23
+ * every tick) never inflates the count.
24
+ */
25
+
26
+ function view(partial: Partial<WorkerActivityView> = {}): WorkerActivityView {
27
+ return {
28
+ description: 'fix the thing',
29
+ lastTool: { name: 'Bash', sanitisedArg: 'x' },
30
+ toolCount: 1,
31
+ latestSummary: 'Running a command',
32
+ elapsedMs: 10_000,
33
+ state: 'running',
34
+ ...partial,
35
+ }
36
+ }
37
+
38
+ interface FakeBot extends BotApiForWorkerFeed {
39
+ sent: Array<{ text: string }>
40
+ edits: Array<{ text: string }>
41
+ }
42
+
43
+ function makeFakeBot(): FakeBot {
44
+ let nextId = 1000
45
+ const fb: FakeBot = {
46
+ sent: [],
47
+ edits: [],
48
+ sendMessage: async (_chatId, text) => {
49
+ fb.sent.push({ text })
50
+ return { message_id: nextId++ }
51
+ },
52
+ editMessageText: async (_chatId, _messageId, text) => {
53
+ fb.edits.push({ text })
54
+ return {}
55
+ },
56
+ }
57
+ return fb
58
+ }
59
+
60
+ /** Latest rendered body for the group (last edit, else the first paint). */
61
+ function latestBody(bot: FakeBot): string {
62
+ return bot.edits.length > 0 ? bot.edits[bot.edits.length - 1].text : bot.sent[0].text
63
+ }
64
+
65
+ describe('worker feed — repeated step labels', () => {
66
+ it('renders a climbing ·×N counter instead of freezing on one line', async () => {
67
+ const bot = makeFakeBot()
68
+ let clock = 0
69
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
70
+
71
+ for (let i = 1; i <= 5; i++) {
72
+ clock += 10_000
73
+ await feed.update('w1', 'chat', view({ toolCount: i, elapsedMs: clock }))
74
+ }
75
+
76
+ const body = latestBody(bot)
77
+ expect(body).toContain('Running a command ·×5')
78
+ // …and the frozen single-count line is gone from the newest render.
79
+ expect(body).not.toMatch(/Running a command\*{0,2}\s*$/m)
80
+ })
81
+
82
+ it('does NOT inflate the count when the watcher re-emits an unchanged view', async () => {
83
+ const bot = makeFakeBot()
84
+ let clock = 0
85
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
86
+
87
+ // Same toolCount across many ticks = the SAME tool call, re-observed.
88
+ for (let i = 0; i < 6; i++) {
89
+ clock += 5_000
90
+ await feed.update('w1', 'chat', view({ toolCount: 2, elapsedMs: clock }))
91
+ }
92
+
93
+ const body = latestBody(bot)
94
+ expect(body).toContain('Running a command')
95
+ expect(body).not.toContain('·×')
96
+ })
97
+
98
+ it('starts a NEW step (no counter) when the label actually changes', async () => {
99
+ const bot = makeFakeBot()
100
+ let clock = 0
101
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
102
+
103
+ clock += 10_000
104
+ await feed.update('w1', 'chat', view({ toolCount: 1, elapsedMs: clock }))
105
+ clock += 10_000
106
+ await feed.update('w1', 'chat', view({ toolCount: 2, elapsedMs: clock }))
107
+ clock += 10_000
108
+ await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'Running git status', elapsedMs: clock }))
109
+
110
+ const body = latestBody(bot)
111
+ expect(body).toContain('Running a command ·×2')
112
+ expect(body).toContain('Running git status')
113
+ expect(body).not.toContain('Running git status ·×')
114
+ })
115
+
116
+ it('keeps the non-adjacent A,B,A dedup (no duplicate step lines)', async () => {
117
+ const bot = makeFakeBot()
118
+ let clock = 0
119
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
120
+
121
+ const steps = ['Reading alpha.ts', 'Running grep', 'Reading alpha.ts']
122
+ for (const [i, summary] of steps.entries()) {
123
+ clock += 10_000
124
+ await feed.update('w1', 'chat', view({ toolCount: i + 1, latestSummary: summary, elapsedMs: clock }))
125
+ }
126
+
127
+ const body = latestBody(bot)
128
+ expect((body.match(/Reading alpha\.ts/g) ?? []).length).toBe(1)
129
+ // Non-adjacent repeats are dropped, not counted — the A,B,A duplication
130
+ // guard predates this change and stays intact.
131
+ expect(body).not.toContain('Reading alpha.ts ·×')
132
+ })
133
+ })
134
+
135
+ describe('repeat-suffix helpers', () => {
136
+ it('round-trips the marker', () => {
137
+ expect(stripRepeatSuffix('Running a command ·×12')).toBe('Running a command')
138
+ expect(stripRepeatSuffix('Running a command')).toBe('Running a command')
139
+ expect(repeatCountOf('Running a command')).toBe(1)
140
+ expect(repeatCountOf('Running a command ·×12')).toBe(12)
141
+ })
142
+
143
+ it('does not mistake ordinary text for a marker', () => {
144
+ expect(stripRepeatSuffix('Comparing 3 ×2 grids')).toBe('Comparing 3 ×2 grids')
145
+ expect(repeatCountOf('Comparing 3 ×2 grids')).toBe(1)
146
+ })
147
+ })