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
|
@@ -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
|
|
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
|
|
39
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
}
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
}
|