switchroom 0.19.18 → 0.19.22
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 +56 -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 +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- 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 +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- 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
|
+
})
|