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.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +3392 -1569
- package/dist/host-control/main.js +12209 -11396
- package/dist/vault/approvals/kernel-server.js +60 -7
- package/dist/vault/broker/server.js +206 -76
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +1644 -1044
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/worker-activity-feed.ts +51 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
- 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 →
|
|
25
|
-
|
|
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
|
+
})
|