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
@@ -24767,6 +24767,19 @@ var init_bridge = __esm(async () => {
24767
24767
  required: ["chat_id", "text"]
24768
24768
  }
24769
24769
  },
24770
+ {
24771
+ name: "progress_update",
24772
+ description: 'Post a short interim progress line to Telegram mid-task ("still working through X"). Sends a NEW plain message to the chat \u2014 it is not an edit and not a card row, so use it sparingly and only when the user genuinely benefits from knowing where a long task stands. The gateway enforces its own limits: text is truncated at 300 chars, at most one update per 20s per chat+thread, and at most 5 per turn; over-limit calls return {ok:false, reason:"too_soon"|"turn_limit"} instead of sending. Prefer edit_message when you already own a message to update, and always deliver the actual answer with reply.',
24773
+ inputSchema: {
24774
+ type: "object",
24775
+ properties: {
24776
+ chat_id: { type: "string", description: "Chat to post the progress line in \u2014 pass chat_id from the inbound message." },
24777
+ text: { type: "string", description: "The progress line. One short sentence; truncated at 300 chars by the gateway." },
24778
+ message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified." }
24779
+ },
24780
+ required: ["chat_id", "text"]
24781
+ }
24782
+ },
24770
24783
  {
24771
24784
  name: "react",
24772
24785
  description: "Add an emoji reaction to a Telegram message. Telegram only accepts a fixed whitelist (\uD83D\uDC4D \uD83D\uDC4E \u2764 \uD83D\uDD25 \uD83D\uDC40 \uD83C\uDF89 etc) \u2014 non-whitelisted emoji will be rejected.",
@@ -32,11 +32,18 @@
32
32
  * {@link MAX_QUEUE_SIZE} below, and the tests that pin them.
33
33
  *
34
34
  * File format mirrors `missed-approvals-store.ts`: a single bounded JSON
35
- * array, written synchronously, mode 0o600.
35
+ * array, written synchronously and ATOMICALLY (tmp + fsync + rename),
36
+ * mode 0o600 — a crash mid-persist leaves the previous queue intact
37
+ * instead of a torn file. NOTE: atomic REPLACEMENT only —
38
+ * whole-old-or-whole-new, not power-loss durability; the missing
39
+ * parent-directory fsync is tracked in #3603.
36
40
  *
37
41
  * Failure semantics (hardened post-#2973 adversarial review): a failed
38
- * READ degrades to an empty list a corrupt/missing queue file is not
39
- * fatal, it just means "nothing queued yet". A failed WRITE is a
42
+ * READ degrades to an empty list so the gateway still boots but a
43
+ * CORRUPT file is no longer silent: the bytes are quarantined to
44
+ * `<file>.corrupt-<ts>` and a loud line goes to the log (see
45
+ * `store-file.ts`), because "queue silently came up empty" is exactly
46
+ * how queued retries disappeared unnoticed. A failed WRITE is a
40
47
  * different story: silently swallowing it would mean `enqueue()` tells
41
48
  * its caller "queued for retry" when nothing was actually persisted to
42
49
  * disk, and a concurrent `recordAttempt()`/`remove()` would silently
@@ -51,8 +58,14 @@
51
58
  * success.
52
59
  */
53
60
 
54
- import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
61
+ import { writeFileSync, unlinkSync } from 'node:fs'
55
62
  import { join } from 'node:path'
63
+ import { atomicWriteFileSync } from '../../src/util/atomic.js'
64
+ import {
65
+ preserveUnreadableStoreFile,
66
+ quarantineCorruptStoreFile,
67
+ readStoreJsonSync,
68
+ } from './store-file.js'
56
69
 
57
70
  /** Hard cap on retry attempts per entry — never retry indefinitely. */
58
71
  export const MAX_ATTEMPTS = 5
@@ -161,13 +174,33 @@ export function computeBackoffMs(attempts: number, retryAfterMs?: number): numbe
161
174
  return exp
162
175
  }
163
176
 
177
+ /**
178
+ * The real writer: `atomicWriteFileSync` (tmp + fsync + rename) behind a
179
+ * `writeFileSync`-shaped signature, so the injectable seam below keeps its
180
+ * existing type and the fault-injection tests are unaffected. Only `mode`
181
+ * from the options bag is meaningful here; the store always passes 0o600.
182
+ */
183
+ export const atomicWriteSeam = ((path, data, opts) => {
184
+ const mode = typeof opts === 'object' && opts !== null && typeof opts.mode === 'number' ? opts.mode : 0o600
185
+ atomicWriteFileSync(path as string, data as string, mode)
186
+ }) as typeof writeFileSync
187
+
164
188
  export function createAlwaysAllowPersistQueue(
165
189
  stateDir: string,
166
190
  /** Injectable for tests to force a write failure (disk full / permissions /
167
191
  * read-only fs) without real filesystem faults — we run as root in CI/
168
192
  * containers, so chmod-based permission tricks don't reliably fail, and
169
- * bun's test runner doesn't support mocking node:fs built-ins. */
170
- writeFileSyncFn: typeof writeFileSync = writeFileSync,
193
+ * bun's test runner doesn't support mocking node:fs built-ins.
194
+ *
195
+ * CAUTION: a test that injects a seam replaces the ATOMIC writer. Such a
196
+ * test proves failure PROPAGATION, never atomicity — the injected function
197
+ * is whatever the test supplies (typically a plain `writeFileSync`, which
198
+ * is exactly the non-atomic writer this store moved off). Tests that mean
199
+ * to exercise the real write path must either leave this defaulted or wrap
200
+ * the exported {@link atomicWriteSeam}. */
201
+ writeFileSyncFn: typeof writeFileSync = atomicWriteSeam,
202
+ /** Log sink — defaults to stderr (the gateway's runtime log). */
203
+ log: (line: string) => void = l => process.stderr.write(l),
171
204
  ): AlwaysAllowPersistQueue {
172
205
  const filePath = join(stateDir, 'always-allow-persist-queue.json')
173
206
 
@@ -183,6 +216,26 @@ export function createAlwaysAllowPersistQueue(
183
216
  // single promise chain, so at most one is ever in flight at a time,
184
217
  // regardless of how many callers invoke enqueue/recordAttempt/remove
185
218
  // "concurrently".
219
+ //
220
+ // SCOPE (verified, not assumed): this is an IN-PROCESS promise chain, not
221
+ // an OS file lock. It serializes callers inside ONE gateway process only.
222
+ // Two gateway processes sharing a STATE_DIR WOULD still lose updates to
223
+ // each other, and that is not impossible — only rare. `startup-mutex.ts`
224
+ // makes concurrent gateways UNLIKELY, not unreachable: its bootMismatch
225
+ // path steals the lock with NO liveness check when the holder's bootId
226
+ // differs from the current one (exactly the restart-overlap case on a
227
+ // shared STATE_DIR — see the `boot.lock_stale_recovered_boot_mismatch`
228
+ // revert referenced at gateway.ts), `readCurrentBootId()` returns null
229
+ // off-Linux which disables the gate entirely, the lock is taken once at
230
+ // boot and never revalidated, and `isGatewayMain` lets harnesses bypass
231
+ // it. So: rare, not guaranteed.
232
+ //
233
+ // Cross-process mutual exclusion is deliberately OUT OF SCOPE for this
234
+ // change (which is about torn writes, not lost updates), and every write
235
+ // here is now atomic so an overlap can lose an update but can never
236
+ // corrupt the file. A real `flock` (cf. src/vault/flock-concurrent.test.ts)
237
+ // is the durable fix — tracked as follow-up. Do not read the startup mutex
238
+ // as a hard singleton invariant.
186
239
  let lock: Promise<unknown> = Promise.resolve()
187
240
  function withLock<T>(fn: () => T): Promise<T> {
188
241
  const result = lock.then(fn, fn) // run fn even if the previous link rejected
@@ -192,14 +245,39 @@ export function createAlwaysAllowPersistQueue(
192
245
  return result
193
246
  }
194
247
 
248
+ /** Set when the last read failed for a non-ENOENT reason — the next write
249
+ * must preserve the file it could not read instead of clobbering it. */
250
+ let unreadable = false
251
+
195
252
  function read(): FileShape {
196
- try {
197
- const raw = readFileSync(filePath, 'utf-8')
198
- const parsed = JSON.parse(raw) as Partial<FileShape>
199
- return { entries: Array.isArray(parsed?.entries) ? parsed.entries : [] }
200
- } catch {
253
+ const result = readStoreJsonSync(filePath, 'always-allow-persist-queue', log)
254
+ unreadable = result.status === 'unreadable'
255
+ if (result.status !== 'ok') return { entries: [] }
256
+ const parsed = result.value as Partial<FileShape>
257
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
258
+ quarantineCorruptStoreFile(
259
+ filePath,
260
+ 'always-allow-persist-queue',
261
+ 'parsed to a non-object — not a persist-queue file',
262
+ log,
263
+ )
201
264
  return { entries: [] }
202
265
  }
266
+ // A PRESENT-but-non-array `entries` is corruption, not "empty queue".
267
+ // Coercing it to [] would resurrect the silent-loss bug: a half-written
268
+ // `{"entries": {}}` parses fine, so quarantine would never fire and the
269
+ // queued retries would vanish unnoticed. An ABSENT `entries` is the
270
+ // legitimate cold-start/partial-shape case and stays silent.
271
+ if (parsed.entries !== undefined && !Array.isArray(parsed.entries)) {
272
+ quarantineCorruptStoreFile(
273
+ filePath,
274
+ 'always-allow-persist-queue',
275
+ '`entries` is present but not an array — truncated or malformed write',
276
+ log,
277
+ )
278
+ return { entries: [] }
279
+ }
280
+ return { entries: parsed.entries ?? [] }
203
281
  }
204
282
 
205
283
  /** Unlike `read()`, a write failure is NOT swallowed — it propagates so
@@ -207,6 +285,14 @@ export function createAlwaysAllowPersistQueue(
207
285
  * not actually land on disk (disk full, permissions, etc.) instead of
208
286
  * silently proceeding as if it had. */
209
287
  function write(f: FileShape): void {
288
+ // Fail closed: never let an overwrite be what destroys a queue we merely
289
+ // failed to READ (flaky mount, transient EACCES) — this throws if the
290
+ // previous bytes can't be preserved, and that throw is exactly the
291
+ // propagate-don't-swallow contract above.
292
+ if (unreadable) {
293
+ preserveUnreadableStoreFile(filePath, 'always-allow-persist-queue', log)
294
+ unreadable = false
295
+ }
210
296
  writeFileSyncFn(filePath, JSON.stringify(f), { encoding: 'utf-8', mode: 0o600 })
211
297
  }
212
298
 
@@ -19,13 +19,24 @@
19
19
  * record and edits the card closed.
20
20
  *
21
21
  * File format: a single JSON object `{ pending, delivered }`, written
22
- * synchronously to avoid interleaving on concurrent auto-denies, mode 0o600.
23
- * Mirrors the `permission-card-store.ts` pattern. Both lists are hard-capped
24
- * so the file stays tiny under a runaway loop.
22
+ * synchronously to avoid interleaving on concurrent auto-denies, mode 0o600,
23
+ * ATOMICALLY (tmp + fsync + rename) so a crash mid-persist can't leave a torn
24
+ * file. Mirrors the `permission-card-store.ts` pattern. Both lists are
25
+ * hard-capped so the file stays tiny under a runaway loop. A corrupt file is
26
+ * quarantined and logged loudly rather than silently read as "nothing was
27
+ * missed" — see store-file.ts. NOTE: atomic REPLACEMENT only —
28
+ * whole-old-or-whole-new, not power-loss durability; the missing
29
+ * parent-directory fsync is tracked in #3603.
25
30
  */
26
31
 
27
- import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
32
+ import { unlinkSync } from 'node:fs'
28
33
  import { join } from 'node:path'
34
+ import { atomicWriteFileSync } from '../../src/util/atomic.js'
35
+ import {
36
+ preserveUnreadableStoreFile,
37
+ quarantineCorruptStoreFile,
38
+ readStoreJsonSync,
39
+ } from './store-file.js'
29
40
 
30
41
  export interface MissedApproval {
31
42
  /** The permission request_id that timed out (dedup key). */
@@ -80,29 +91,67 @@ export interface MissedApprovalsStore {
80
91
  export const MAX_PENDING = 50
81
92
  export const MAX_DELIVERED = 20
82
93
 
83
- export function createMissedApprovalsStore(stateDir: string): MissedApprovalsStore {
94
+ export function createMissedApprovalsStore(
95
+ stateDir: string,
96
+ /** Log sink — defaults to stderr (the gateway's runtime log). */
97
+ log: (line: string) => void = l => process.stderr.write(l),
98
+ ): MissedApprovalsStore {
84
99
  const filePath = join(stateDir, 'missed-approvals.json')
85
100
 
101
+ const EMPTY = (): FileShape => ({ pending: [], delivered: [] })
102
+
103
+ /** Set when the last read failed for a non-ENOENT reason — the next write
104
+ * must preserve the file it could not read instead of clobbering it. */
105
+ let unreadable = false
106
+
86
107
  function read(): FileShape {
87
- try {
88
- const raw = readFileSync(filePath, 'utf-8')
89
- const parsed = JSON.parse(raw) as Partial<FileShape>
90
- return {
91
- pending: Array.isArray(parsed?.pending) ? parsed.pending : [],
92
- delivered: Array.isArray(parsed?.delivered) ? parsed.delivered : [],
108
+ const result = readStoreJsonSync(filePath, 'missed-approvals-store', log)
109
+ unreadable = result.status === 'unreadable'
110
+ if (result.status !== 'ok') return EMPTY()
111
+ const parsed = result.value as Partial<FileShape>
112
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
113
+ quarantineCorruptStoreFile(
114
+ filePath,
115
+ 'missed-approvals-store',
116
+ 'parsed to a non-object — not a missed-approvals file',
117
+ log,
118
+ )
119
+ return EMPTY()
120
+ }
121
+ // A PRESENT-but-non-array list is corruption, not "empty". Coercing it to
122
+ // [] would resurrect the exact bug this store was hardened against: a
123
+ // half-written `{"pending": null}` parses fine, so quarantine would never
124
+ // fire and the digest would silently come up empty. An ABSENT list is the
125
+ // legitimate cold-start/partial-shape case and stays silent.
126
+ for (const field of ['pending', 'delivered'] as const) {
127
+ if (parsed[field] !== undefined && !Array.isArray(parsed[field])) {
128
+ quarantineCorruptStoreFile(
129
+ filePath,
130
+ 'missed-approvals-store',
131
+ `\`${field}\` is present but not an array — truncated or malformed write`,
132
+ log,
133
+ )
134
+ return EMPTY()
93
135
  }
94
- } catch {
95
- return { pending: [], delivered: [] }
136
+ }
137
+ return {
138
+ pending: parsed.pending ?? [],
139
+ delivered: parsed.delivered ?? [],
96
140
  }
97
141
  }
98
142
 
99
143
  function write(f: FileShape): void {
100
144
  try {
101
- writeFileSync(filePath, JSON.stringify(f), { encoding: 'utf-8', mode: 0o600 })
145
+ // Fail closed: never let an overwrite be what destroys state we merely
146
+ // failed to READ (flaky mount, transient EACCES).
147
+ if (unreadable) {
148
+ preserveUnreadableStoreFile(filePath, 'missed-approvals-store', log)
149
+ unreadable = false
150
+ }
151
+ // tmp + fsync + rename — never truncate the destination in place.
152
+ atomicWriteFileSync(filePath, JSON.stringify(f), 0o600)
102
153
  } catch (err) {
103
- process.stderr.write(
104
- `telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`,
105
- )
154
+ log(`telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`)
106
155
  }
107
156
  }
108
157
 
@@ -32,11 +32,24 @@
32
32
  *
33
33
  * File format: JSON array of PersistedApprovalCard objects. Written
34
34
  * synchronously (mode 0o600) to avoid interleaving on concurrent card posts;
35
- * production rate is a handful of cards, so the file stays tiny.
35
+ * production rate is a handful of cards, so the file stays tiny. The write
36
+ * goes through `atomicWriteFileSync` (tmp + fsync + rename) so a crash
37
+ * mid-persist can never leave a torn file behind — the destination holds
38
+ * either the whole previous array or the whole new one. A file that IS
39
+ * corrupt (from a pre-fix write, or anything else) is quarantined and logged
40
+ * loudly rather than silently read as "no pending cards" — see
41
+ * `store-file.ts`. NOTE: atomic REPLACEMENT only — whole-old-or-whole-new, not
42
+ * power-loss durability; the missing parent-directory fsync is tracked in #3603.
36
43
  */
37
44
 
38
- import { readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs'
45
+ import { unlinkSync } from 'node:fs'
39
46
  import { join } from 'node:path'
47
+ import { atomicWriteFileSync } from '../../src/util/atomic.js'
48
+ import {
49
+ preserveUnreadableStoreFile,
50
+ quarantineCorruptStoreFile,
51
+ readStoreJsonSync,
52
+ } from './store-file.js'
40
53
 
41
54
  /** The four agent-initiated approval-card families we persist. */
42
55
  export type ApprovalCardFamily =
@@ -111,30 +124,47 @@ export interface PendingCardStore {
111
124
  clear(): void
112
125
  }
113
126
 
114
- export function createPendingCardStore(stateDir: string): PendingCardStore {
127
+ export function createPendingCardStore(
128
+ stateDir: string,
129
+ /** Log sink — defaults to stderr (the gateway's runtime log). */
130
+ log: (line: string) => void = l => process.stderr.write(l),
131
+ ): PendingCardStore {
115
132
  const filePath = join(stateDir, 'pending-approval-cards.json')
116
133
 
134
+ /** Set when the last read failed for a non-ENOENT reason — the next write
135
+ * must preserve the file it could not read instead of clobbering it. */
136
+ let unreadable = false
137
+
117
138
  function read(): PersistedApprovalCard[] {
118
- try {
119
- const raw = readFileSync(filePath, 'utf-8')
120
- const parsed = JSON.parse(raw)
121
- return Array.isArray(parsed) ? (parsed as PersistedApprovalCard[]) : []
122
- } catch {
139
+ const result = readStoreJsonSync(filePath, 'pending-card-store', log)
140
+ unreadable = result.status === 'unreadable'
141
+ if (result.status !== 'ok') return []
142
+ if (!Array.isArray(result.value)) {
143
+ quarantineCorruptStoreFile(
144
+ filePath,
145
+ 'pending-card-store',
146
+ 'parsed to a non-array — not a pending-card file',
147
+ log,
148
+ )
123
149
  return []
124
150
  }
151
+ return result.value as PersistedApprovalCard[]
125
152
  }
126
153
 
127
154
  function write(entries: PersistedApprovalCard[]): void {
128
155
  try {
129
- writeFileSync(filePath, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 })
130
- // `mode` only applies when writeFileSync CREATES the file; an existing
131
- // file keeps its prior perms. Re-assert 0600 on every write so the file
132
- // can never stay laxer than intended.
133
- chmodSync(filePath, 0o600)
156
+ // Fail closed: never let an overwrite be what destroys state we merely
157
+ // failed to READ (flaky mount, transient EACCES) throws if it can't.
158
+ if (unreadable) {
159
+ preserveUnreadableStoreFile(filePath, 'pending-card-store', log)
160
+ unreadable = false
161
+ }
162
+ // tmp + fsync + rename, mode pinned to 0600 on the tempfile fd (so an
163
+ // existing file can't keep laxer perms, and a crash mid-write leaves
164
+ // the previous good file untouched).
165
+ atomicWriteFileSync(filePath, JSON.stringify(entries), 0o600)
134
166
  } catch (err) {
135
- process.stderr.write(
136
- `telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`,
137
- )
167
+ log(`telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`)
138
168
  }
139
169
  }
140
170
 
@@ -8,7 +8,11 @@
8
8
  * immediately. It reads as "my approval didn't stick."
9
9
  *
10
10
  * Fix: mirror the store to a tiny JSON file in STATE_DIR (same shape as
11
- * permission-card-store.ts — synchronous writes, mode 0o600, one small file).
11
+ * permission-card-store.ts — synchronous ATOMIC writes (tmp + fsync + rename),
12
+ * mode 0o600, one small file; a corrupt file is quarantined and logged loudly
13
+ * rather than silently read as "no grants" — see store-file.ts). NOTE: atomic
14
+ * REPLACEMENT only — whole-old-or-whole-new, not power-loss durability; the
15
+ * missing parent-directory fsync is tracked in #3603.
12
16
  * Write-through on every grant and on sweep-expiry removal; reload at boot,
13
17
  * dropping entries already past their ABSOLUTE expiry.
14
18
  *
@@ -23,8 +27,13 @@
23
27
  * and never write (any pre-existing file is ignored).
24
28
  */
25
29
 
26
- import { readFileSync, writeFileSync } from 'node:fs'
27
30
  import { join } from 'node:path'
31
+ import { atomicWriteFileSync } from '../../src/util/atomic.js'
32
+ import {
33
+ preserveUnreadableStoreFile,
34
+ quarantineCorruptStoreFile,
35
+ readStoreJsonSync,
36
+ } from './store-file.js'
28
37
  import {
29
38
  serializeScopedGrants,
30
39
  deserializeScopedGrants,
@@ -50,18 +59,31 @@ export function scopedGrantPersistEnabled(
50
59
  export function createScopedGrantStore(
51
60
  stateDir: string,
52
61
  env: Record<string, string | undefined> = process.env,
62
+ /** Log sink — defaults to stderr (the gateway's runtime log). */
63
+ log: (line: string) => void = l => process.stderr.write(l),
53
64
  ): ScopedGrantPersistence {
54
65
  const filePath = join(stateDir, 'scoped-grants.json')
55
66
  const enabled = scopedGrantPersistEnabled(env)
56
67
 
68
+ /** Set when the last read failed for a non-ENOENT reason — the next write
69
+ * must preserve the file it could not read instead of clobbering it. */
70
+ let unreadable = false
71
+
57
72
  function read(): unknown[] {
58
- try {
59
- const raw = readFileSync(filePath, 'utf-8')
60
- const parsed = JSON.parse(raw)
61
- return Array.isArray(parsed) ? parsed : []
62
- } catch {
73
+ const result = readStoreJsonSync(filePath, 'scoped-grant-store', log)
74
+ unreadable = result.status === 'unreadable'
75
+ if (result.status !== 'ok') return []
76
+ const parsed = result.value
77
+ if (!Array.isArray(parsed)) {
78
+ quarantineCorruptStoreFile(
79
+ filePath,
80
+ 'scoped-grant-store',
81
+ 'parsed to a non-array — not a scoped-grants file',
82
+ log,
83
+ )
63
84
  return []
64
85
  }
86
+ return parsed
65
87
  }
66
88
 
67
89
  return {
@@ -75,14 +97,17 @@ export function createScopedGrantStore(
75
97
  save(store) {
76
98
  if (!enabled) return
77
99
  try {
78
- writeFileSync(filePath, JSON.stringify(serializeScopedGrants(store)), {
79
- encoding: 'utf-8',
80
- mode: 0o600,
81
- })
100
+ // Fail closed: never let an overwrite be what destroys grants we
101
+ // merely failed to READ (flaky mount, transient EACCES).
102
+ if (unreadable) {
103
+ preserveUnreadableStoreFile(filePath, 'scoped-grant-store', log)
104
+ unreadable = false
105
+ }
106
+ // tmp + fsync + rename — a crash mid-persist leaves the previous
107
+ // grant set intact rather than a torn file that reads as "no grants".
108
+ atomicWriteFileSync(filePath, JSON.stringify(serializeScopedGrants(store)), 0o600)
82
109
  } catch (err) {
83
- process.stderr.write(
84
- `telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`,
85
- )
110
+ log(`telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`)
86
111
  }
87
112
  },
88
113
  }