@brunobracaioli/aibrain 0.1.0-beta.1 → 0.3.0-beta.13
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/README.md +87 -6
- package/dist/assets/claude/.claude-plugin/plugin.json +103 -1
- package/dist/assets/claude/hooks/context-compaction.ts +6263 -677
- package/dist/assets/claude/hooks/provider-window.js +263 -0
- package/dist/assets/claude/hooks/state-fitting.d.mts +47 -0
- package/dist/assets/claude/hooks/state-fitting.mjs +324 -0
- package/dist/assets/codex-context-compaction.mjs +2874 -262
- package/dist/assets/context-selection-fitting.mjs +194 -0
- package/dist/assets/context-state-fitting.mjs +324 -0
- package/dist/assets/context-telemetry-reporter.mjs +305 -0
- package/dist/assets/jev-paid-pilot-terminal.mjs +591 -125
- package/dist/claude-checkpoint.mjs +1338 -0
- package/dist/claude-observation.mjs +1336 -0
- package/dist/cli.mjs +396 -29
- package/dist/codex-compatibility.mjs +467 -0
- package/dist/codex-diagnostics.mjs +426 -0
- package/dist/codex-permissions.mjs +590 -0
- package/dist/codex-protocol-fingerprint.json +131 -0
- package/dist/context-telemetry.mjs +257 -0
- package/dist/launch.mjs +369 -23
- package/dist/mcp.mjs +577 -5
- package/dist/monitor.mjs +61 -1
- package/dist/package.json +1 -1
- package/dist/platform.mjs +33 -3
- package/dist/policy.mjs +218 -0
- package/dist/state.mjs +132 -25
- package/dist/telemetry-report.mjs +133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, rename, unlink } from 'node:fs/promises';
|
|
6
|
+
import { constants } from 'node:fs';
|
|
7
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
export const PROTOCOL_VERSION = 1;
|
|
11
|
+
export const MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024;
|
|
12
|
+
export const MAX_STDIN_BYTES = 40 * 1024 * 1024;
|
|
13
|
+
export const MAX_STORED_BYTES = 64 * 1024 * 1024;
|
|
14
|
+
export const MAX_COMPLETED_CHECKPOINTS = 4;
|
|
15
|
+
export const MAX_SESSION_REF_BYTES = 256;
|
|
16
|
+
export const MAX_RECORD_FILES = 16;
|
|
17
|
+
export const NOTES_PROTOCOL_VERSION = 2;
|
|
18
|
+
export const MAX_NOTE_TEXT_BYTES = 96 * 1024;
|
|
19
|
+
export const MAX_NOTE_ENVELOPE_BYTES = 128 * 1024;
|
|
20
|
+
export const MAX_NOTE_RECORDS_PER_BINDING = 8;
|
|
21
|
+
export const MAX_NOTE_AGGREGATE_BYTES = 2 * 1024 * 1024;
|
|
22
|
+
export const NOTE_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
|
|
23
|
+
export const NOTE_MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
24
|
+
const STALE_ARTIFACT_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
const FILE_PREFIX = 'aibrain-claude-checkpoint-v1-';
|
|
27
|
+
const FILE_SUFFIX = '.json';
|
|
28
|
+
const LOCK_NAME = `${FILE_PREFIX}lock`;
|
|
29
|
+
const NOTE_FILE_PREFIX = 'aibrain-claude-note-v2-';
|
|
30
|
+
const NOTE_TARGET_NAME = new RegExp(`^${NOTE_FILE_PREFIX}([a-f0-9]{64})\\${FILE_SUFFIX}$`, 'u');
|
|
31
|
+
const NOTE_TEMP_NAME = new RegExp(
|
|
32
|
+
`^${NOTE_FILE_PREFIX}[a-f0-9]{64}\\${FILE_SUFFIX}\\.tmp-\\d+-[0-9a-f-]{36}$`,
|
|
33
|
+
'u',
|
|
34
|
+
);
|
|
35
|
+
const NOTE_BACKUP_NAME = new RegExp(
|
|
36
|
+
`^${NOTE_FILE_PREFIX}([a-f0-9]{64})${FILE_SUFFIX}\\.bak-\\d+-[0-9a-f-]{36}$`,
|
|
37
|
+
'u',
|
|
38
|
+
);
|
|
39
|
+
const HEX64 = /^[a-f0-9]{64}$/u;
|
|
40
|
+
const SESSION_REF = /^[A-Za-z0-9._:-]{1,128}$/u;
|
|
41
|
+
const REVISION = /^[a-f0-9]{64}$/u;
|
|
42
|
+
const OPERATION_ID = /^[A-Za-z0-9._:-]{1,128}$/u;
|
|
43
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
|
44
|
+
const TARGET_NAME = new RegExp(`^${FILE_PREFIX}([a-f0-9]{64})\\${FILE_SUFFIX}$`, 'u');
|
|
45
|
+
const TEMP_NAME = new RegExp(`^${FILE_PREFIX}[a-f0-9]{64}\\.tmp-\\d+-[0-9a-f-]{36}$`, 'u');
|
|
46
|
+
const BACKUP_NAME = new RegExp(
|
|
47
|
+
`^${FILE_PREFIX}([a-f0-9]{64})${FILE_SUFFIX}\\.bak-\\d+-[0-9a-f-]{36}$`,
|
|
48
|
+
'u',
|
|
49
|
+
);
|
|
50
|
+
const ERROR_CODES = new Set([
|
|
51
|
+
'invalid_arguments',
|
|
52
|
+
'input_too_large',
|
|
53
|
+
'invalid_input',
|
|
54
|
+
'state_dir_invalid',
|
|
55
|
+
'state_owner_mismatch',
|
|
56
|
+
'state_permissions_invalid',
|
|
57
|
+
'state_symlink_rejected',
|
|
58
|
+
'checkpoint_not_found',
|
|
59
|
+
'checkpoint_invalid',
|
|
60
|
+
'checkpoint_corrupt',
|
|
61
|
+
'checkpoint_oversized',
|
|
62
|
+
'checkpoint_write_failed',
|
|
63
|
+
'checkpoint_read_failed',
|
|
64
|
+
'checkpoint_lock_failed',
|
|
65
|
+
'checkpoint_lock_timeout',
|
|
66
|
+
'checkpoint_binding_mismatch',
|
|
67
|
+
'checkpoint_retention_failed',
|
|
68
|
+
'checkpoint_ack_failed',
|
|
69
|
+
'note_not_found',
|
|
70
|
+
'note_invalid',
|
|
71
|
+
'note_corrupt',
|
|
72
|
+
'note_expired',
|
|
73
|
+
'note_oversized',
|
|
74
|
+
'note_write_failed',
|
|
75
|
+
'note_read_failed',
|
|
76
|
+
'note_lock_failed',
|
|
77
|
+
'note_lock_timeout',
|
|
78
|
+
'note_binding_mismatch',
|
|
79
|
+
'note_retention_failed',
|
|
80
|
+
'note_ack_failed',
|
|
81
|
+
'note_metadata_mismatch',
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
85
|
+
function object(value) {
|
|
86
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {unknown} value @returns {string} */
|
|
90
|
+
function canonical(value) {
|
|
91
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
92
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
93
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
94
|
+
return `{${Object.keys(record)
|
|
95
|
+
.sort()
|
|
96
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`)
|
|
97
|
+
.join(',')}}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @param {unknown} value */
|
|
101
|
+
function digest(value) {
|
|
102
|
+
return createHash('sha256').update(canonical(value), 'utf8').digest('hex');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @param {string} value */
|
|
106
|
+
function textDigest(value) {
|
|
107
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {unknown} value */
|
|
111
|
+
function normalizedJson(value) {
|
|
112
|
+
let text;
|
|
113
|
+
try {
|
|
114
|
+
text = JSON.stringify(value);
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error('invalid_input');
|
|
117
|
+
}
|
|
118
|
+
if (typeof text !== 'string') throw new Error('invalid_input');
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(text);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new Error('invalid_input');
|
|
124
|
+
}
|
|
125
|
+
const output = JSON.stringify(parsed);
|
|
126
|
+
if (typeof output !== 'string') throw new Error('invalid_input');
|
|
127
|
+
return output;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {string} path @param {boolean} allowMissing */
|
|
131
|
+
async function assertPath(path, allowMissing) {
|
|
132
|
+
const target = resolve(path);
|
|
133
|
+
if (!isAbsolute(path)) throw new Error('state_dir_invalid');
|
|
134
|
+
const parts = target.split('/').filter(Boolean);
|
|
135
|
+
let current = target.startsWith('/') ? '/' : '';
|
|
136
|
+
for (const part of parts) {
|
|
137
|
+
current = current === '/' ? `/${part}` : join(current, part);
|
|
138
|
+
try {
|
|
139
|
+
const entry = await lstat(current);
|
|
140
|
+
if (entry.isSymbolicLink()) throw new Error('state_symlink_rejected');
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error instanceof Error && error.message === 'state_symlink_rejected') throw error;
|
|
143
|
+
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'))
|
|
144
|
+
throw error;
|
|
145
|
+
if (!allowMissing) throw new Error('state_dir_invalid');
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @param {string} stateDir */
|
|
152
|
+
async function ownedDirectory(stateDir) {
|
|
153
|
+
if (!isAbsolute(stateDir)) throw new Error('state_dir_invalid');
|
|
154
|
+
const target = resolve(stateDir);
|
|
155
|
+
await assertPath(target, true);
|
|
156
|
+
await mkdir(target, { recursive: true, mode: 0o700 });
|
|
157
|
+
await assertPath(target, false);
|
|
158
|
+
const entry = await lstat(target);
|
|
159
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error('state_dir_invalid');
|
|
160
|
+
if (typeof process.getuid === 'function' && entry.uid !== process.getuid())
|
|
161
|
+
throw new Error('state_owner_mismatch');
|
|
162
|
+
if ((entry.mode & 0o077) !== 0) throw new Error('state_permissions_invalid');
|
|
163
|
+
await chmod(target, 0o700);
|
|
164
|
+
return target;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** @param {string} path */
|
|
168
|
+
async function ownedFile(path) {
|
|
169
|
+
const entry = await lstat(path);
|
|
170
|
+
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error('checkpoint_corrupt');
|
|
171
|
+
if (typeof process.getuid === 'function' && entry.uid !== process.getuid())
|
|
172
|
+
throw new Error('state_owner_mismatch');
|
|
173
|
+
if ((entry.mode & 0o077) !== 0) throw new Error('state_permissions_invalid');
|
|
174
|
+
if (entry.size > MAX_SNAPSHOT_BYTES + 16 * 1024) throw new Error('checkpoint_oversized');
|
|
175
|
+
return entry;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** @param {string} sessionRef @param {string} binding */
|
|
179
|
+
function fileName(sessionRef, binding) {
|
|
180
|
+
return `${FILE_PREFIX}${digest({ binding, sessionRef })}${FILE_SUFFIX}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** @param {string[]} raw */
|
|
184
|
+
function parseArgs(raw) {
|
|
185
|
+
if (
|
|
186
|
+
raw.length !== 6 ||
|
|
187
|
+
raw[0] !== '--state-dir' ||
|
|
188
|
+
raw[2] !== '--config-digest' ||
|
|
189
|
+
typeof raw[1] !== 'string' ||
|
|
190
|
+
!isAbsolute(raw[1]) ||
|
|
191
|
+
typeof raw[3] !== 'string' ||
|
|
192
|
+
!HEX64.test(raw[3]) ||
|
|
193
|
+
raw[4] !== '--binding' ||
|
|
194
|
+
typeof raw[5] !== 'string' ||
|
|
195
|
+
!HEX64.test(raw[5]) ||
|
|
196
|
+
raw[5] !== raw[3]
|
|
197
|
+
)
|
|
198
|
+
throw new Error('invalid_arguments');
|
|
199
|
+
return { stateDir: resolve(raw[1]), configDigest: raw[3], binding: raw[5] };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @param {string} capturedAt @param {string} errorCode */
|
|
203
|
+
function validateNoteTimestamp(capturedAt, errorCode) {
|
|
204
|
+
if (!ISO.test(capturedAt)) throw new Error(errorCode);
|
|
205
|
+
let timestamp;
|
|
206
|
+
try {
|
|
207
|
+
timestamp = Date.parse(capturedAt);
|
|
208
|
+
} catch {
|
|
209
|
+
throw new Error(errorCode);
|
|
210
|
+
}
|
|
211
|
+
if (!Number.isFinite(timestamp)) throw new Error(errorCode);
|
|
212
|
+
if (timestamp > Date.now() + NOTE_MAX_CLOCK_SKEW_MS) throw new Error(errorCode);
|
|
213
|
+
if (Date.now() - timestamp > NOTE_EXPIRY_MS) throw new Error('note_expired');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {unknown} value */
|
|
217
|
+
function parseNotesInput(value) {
|
|
218
|
+
if (!object(value) || value.schema_version !== NOTES_PROTOCOL_VERSION)
|
|
219
|
+
throw new Error('note_invalid');
|
|
220
|
+
const operation = value.operation;
|
|
221
|
+
const writeKeys = new Set([
|
|
222
|
+
'schema_version',
|
|
223
|
+
'operation',
|
|
224
|
+
'binding',
|
|
225
|
+
'session_ref',
|
|
226
|
+
'operation_id',
|
|
227
|
+
'request_id',
|
|
228
|
+
'revision',
|
|
229
|
+
'projection_hash',
|
|
230
|
+
'captured_at',
|
|
231
|
+
'note_text',
|
|
232
|
+
'note_sha256',
|
|
233
|
+
]);
|
|
234
|
+
const readKeys = new Set([
|
|
235
|
+
'schema_version',
|
|
236
|
+
'operation',
|
|
237
|
+
'binding',
|
|
238
|
+
'session_ref',
|
|
239
|
+
'operation_id',
|
|
240
|
+
'note_sha256',
|
|
241
|
+
]);
|
|
242
|
+
const allowed =
|
|
243
|
+
operation === 'notes.write' ? writeKeys : operation === 'notes.read' ? readKeys : null;
|
|
244
|
+
if (allowed === null || Object.keys(value).some((key) => !allowed.has(key)))
|
|
245
|
+
throw new Error('note_invalid');
|
|
246
|
+
if (
|
|
247
|
+
typeof value.binding !== 'string' ||
|
|
248
|
+
!HEX64.test(value.binding) ||
|
|
249
|
+
typeof value.session_ref !== 'string' ||
|
|
250
|
+
!SESSION_REF.test(value.session_ref) ||
|
|
251
|
+
Buffer.byteLength(value.session_ref, 'utf8') > MAX_SESSION_REF_BYTES ||
|
|
252
|
+
typeof value.operation_id !== 'string' ||
|
|
253
|
+
!OPERATION_ID.test(value.operation_id) ||
|
|
254
|
+
typeof value.note_sha256 !== 'string' ||
|
|
255
|
+
!HEX64.test(value.note_sha256)
|
|
256
|
+
)
|
|
257
|
+
throw new Error('note_invalid');
|
|
258
|
+
if (operation === 'notes.read')
|
|
259
|
+
return /** @type {Record<string, unknown> & {schema_version:2, operation:'notes.read', binding:string, session_ref:string, operation_id:string, note_sha256:string}} */ (
|
|
260
|
+
value
|
|
261
|
+
);
|
|
262
|
+
if (
|
|
263
|
+
typeof value.request_id !== 'string' ||
|
|
264
|
+
!OPERATION_ID.test(value.request_id) ||
|
|
265
|
+
typeof value.revision !== 'string' ||
|
|
266
|
+
!REVISION.test(value.revision) ||
|
|
267
|
+
typeof value.projection_hash !== 'string' ||
|
|
268
|
+
!HEX64.test(value.projection_hash) ||
|
|
269
|
+
typeof value.captured_at !== 'string' ||
|
|
270
|
+
!ISO.test(value.captured_at) ||
|
|
271
|
+
typeof value.note_text !== 'string'
|
|
272
|
+
)
|
|
273
|
+
throw new Error('note_invalid');
|
|
274
|
+
try {
|
|
275
|
+
if (new Date(value.captured_at).toISOString() !== value.captured_at)
|
|
276
|
+
throw new Error('note_invalid');
|
|
277
|
+
} catch {
|
|
278
|
+
throw new Error('note_invalid');
|
|
279
|
+
}
|
|
280
|
+
validateNoteTimestamp(value.captured_at, 'note_invalid');
|
|
281
|
+
const noteBytes = Buffer.byteLength(value.note_text, 'utf8');
|
|
282
|
+
if (noteBytes > MAX_NOTE_TEXT_BYTES) throw new Error('note_oversized');
|
|
283
|
+
if (textDigest(value.note_text) !== value.note_sha256) throw new Error('note_ack_failed');
|
|
284
|
+
return /** @type {Record<string, unknown> & {schema_version:2, operation:'notes.write', binding:string, session_ref:string, operation_id:string, request_id:string, revision:string, projection_hash:string, captured_at:string, note_text:string, note_sha256:string}} */ (
|
|
285
|
+
value
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** @param {string} text */
|
|
290
|
+
function parseInput(text) {
|
|
291
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_STDIN_BYTES) throw new Error('input_too_large');
|
|
292
|
+
let value;
|
|
293
|
+
try {
|
|
294
|
+
value = JSON.parse(text);
|
|
295
|
+
} catch {
|
|
296
|
+
throw new Error('invalid_input');
|
|
297
|
+
}
|
|
298
|
+
if (object(value) && value.schema_version === NOTES_PROTOCOL_VERSION)
|
|
299
|
+
return parseNotesInput(value);
|
|
300
|
+
if (!object(value) || value.schema_version !== PROTOCOL_VERSION) throw new Error('invalid_input');
|
|
301
|
+
if (value.operation !== 'checkpoint.write') throw new Error('invalid_input');
|
|
302
|
+
const allowed = new Set([
|
|
303
|
+
'schema_version',
|
|
304
|
+
'operation',
|
|
305
|
+
'operation_id',
|
|
306
|
+
'binding',
|
|
307
|
+
'session_ref',
|
|
308
|
+
'revision',
|
|
309
|
+
'preservation_mode',
|
|
310
|
+
'checkpoint',
|
|
311
|
+
'byte_count',
|
|
312
|
+
'payload_sha256',
|
|
313
|
+
]);
|
|
314
|
+
if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error('invalid_input');
|
|
315
|
+
if (typeof value.session_ref !== 'string' || !SESSION_REF.test(value.session_ref))
|
|
316
|
+
throw new Error('invalid_input');
|
|
317
|
+
if (Buffer.byteLength(value.session_ref, 'utf8') > MAX_SESSION_REF_BYTES)
|
|
318
|
+
throw new Error('invalid_input');
|
|
319
|
+
if (typeof value.revision !== 'string' || !REVISION.test(value.revision))
|
|
320
|
+
throw new Error('invalid_input');
|
|
321
|
+
if (!Object.hasOwn(value, 'checkpoint')) throw new Error('invalid_input');
|
|
322
|
+
if (typeof value.binding !== 'string' || !HEX64.test(value.binding))
|
|
323
|
+
throw new Error('invalid_input');
|
|
324
|
+
if (typeof value.operation_id !== 'string' || !OPERATION_ID.test(value.operation_id))
|
|
325
|
+
throw new Error('invalid_input');
|
|
326
|
+
if (
|
|
327
|
+
value.preservation_mode !== 'preserve_internal' &&
|
|
328
|
+
value.preservation_mode !== 'upstream_compatible'
|
|
329
|
+
)
|
|
330
|
+
throw new Error('invalid_input');
|
|
331
|
+
const checkpoint = value.checkpoint;
|
|
332
|
+
if (!object(checkpoint)) throw new Error('invalid_input');
|
|
333
|
+
const checkpointKeys = new Set([
|
|
334
|
+
'version',
|
|
335
|
+
'sessionRef',
|
|
336
|
+
'revision',
|
|
337
|
+
'preservationMode',
|
|
338
|
+
'capturedAt',
|
|
339
|
+
'messages',
|
|
340
|
+
]);
|
|
341
|
+
if (Object.keys(checkpoint).some((key) => !checkpointKeys.has(key)))
|
|
342
|
+
throw new Error('invalid_input');
|
|
343
|
+
if (
|
|
344
|
+
checkpoint.version !== 1 ||
|
|
345
|
+
checkpoint.sessionRef !== value.session_ref ||
|
|
346
|
+
checkpoint.revision !== value.revision ||
|
|
347
|
+
checkpoint.preservationMode !== value.preservation_mode ||
|
|
348
|
+
typeof checkpoint.capturedAt !== 'string' ||
|
|
349
|
+
!ISO.test(checkpoint.capturedAt) ||
|
|
350
|
+
!Array.isArray(checkpoint.messages)
|
|
351
|
+
)
|
|
352
|
+
throw new Error('invalid_input');
|
|
353
|
+
const payload = normalizedJson(checkpoint);
|
|
354
|
+
const bytes = Buffer.byteLength(payload, 'utf8');
|
|
355
|
+
if (bytes > MAX_SNAPSHOT_BYTES) throw new Error('checkpoint_oversized');
|
|
356
|
+
if (
|
|
357
|
+
value.byte_count !== bytes ||
|
|
358
|
+
typeof value.payload_sha256 !== 'string' ||
|
|
359
|
+
!HEX64.test(value.payload_sha256) ||
|
|
360
|
+
textDigest(payload) !== value.payload_sha256
|
|
361
|
+
)
|
|
362
|
+
throw new Error('checkpoint_ack_failed');
|
|
363
|
+
return /** @type {Record<string, unknown> & {schema_version:1, operation:'checkpoint.write', session_ref:string, revision:string, preservation_mode:string, checkpoint:Record<string, unknown>, byte_count:number, payload_sha256:string}} */ (
|
|
364
|
+
value
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function readStdin() {
|
|
369
|
+
const chunks = [];
|
|
370
|
+
let total = 0;
|
|
371
|
+
for await (const chunk of process.stdin) {
|
|
372
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
373
|
+
total += bytes.byteLength;
|
|
374
|
+
if (total > MAX_STDIN_BYTES) throw new Error('input_too_large');
|
|
375
|
+
chunks.push(bytes);
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
|
379
|
+
} catch {
|
|
380
|
+
throw new Error('invalid_input');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** @param {string} stateDir */
|
|
385
|
+
async function acquireLock(stateDir) {
|
|
386
|
+
const lock = join(stateDir, LOCK_NAME);
|
|
387
|
+
let handle;
|
|
388
|
+
const nonce = randomUUID();
|
|
389
|
+
/** @type {import('node:fs').Stats | undefined} */
|
|
390
|
+
let identity;
|
|
391
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
392
|
+
try {
|
|
393
|
+
handle = await open(
|
|
394
|
+
lock,
|
|
395
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
396
|
+
0o600,
|
|
397
|
+
);
|
|
398
|
+
await handle.writeFile(`${process.pid}:${nonce}\n`, 'utf8');
|
|
399
|
+
await handle.close();
|
|
400
|
+
handle = undefined;
|
|
401
|
+
identity = await lstat(lock);
|
|
402
|
+
break;
|
|
403
|
+
} catch (error) {
|
|
404
|
+
await handle?.close().catch(() => undefined);
|
|
405
|
+
handle = undefined;
|
|
406
|
+
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST'))
|
|
407
|
+
throw new Error('checkpoint_lock_failed');
|
|
408
|
+
if (attempt > 0) throw new Error('checkpoint_lock_timeout');
|
|
409
|
+
try {
|
|
410
|
+
const entry = await lstat(lock);
|
|
411
|
+
if (
|
|
412
|
+
entry.isSymbolicLink() ||
|
|
413
|
+
!entry.isFile() ||
|
|
414
|
+
entry.size > 128 ||
|
|
415
|
+
(entry.mode & 0o077) !== 0 ||
|
|
416
|
+
(typeof process.getuid === 'function' && entry.uid !== process.getuid())
|
|
417
|
+
)
|
|
418
|
+
throw new Error('checkpoint_lock_failed');
|
|
419
|
+
const age = Date.now() - entry.mtimeMs;
|
|
420
|
+
const ownerText = await readFile(lock, 'utf8');
|
|
421
|
+
const lockMatch = /^(\d+):([0-9a-f-]{36})\n?$/u.exec(ownerText);
|
|
422
|
+
if (lockMatch === null) throw new Error('checkpoint_lock_failed');
|
|
423
|
+
const ownerPid = Number.parseInt(lockMatch[1], 10);
|
|
424
|
+
let alive = false;
|
|
425
|
+
if (Number.isSafeInteger(ownerPid) && ownerPid > 0) {
|
|
426
|
+
try {
|
|
427
|
+
process.kill(ownerPid, 0);
|
|
428
|
+
alive = true;
|
|
429
|
+
} catch (probeError) {
|
|
430
|
+
alive = Boolean(
|
|
431
|
+
probeError &&
|
|
432
|
+
typeof probeError === 'object' &&
|
|
433
|
+
'code' in probeError &&
|
|
434
|
+
probeError.code !== 'ESRCH',
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (alive || age < STALE_ARTIFACT_MS) throw new Error('checkpoint_lock_timeout');
|
|
439
|
+
const latest = await lstat(lock);
|
|
440
|
+
if (latest.dev !== entry.dev || latest.ino !== entry.ino)
|
|
441
|
+
throw new Error('checkpoint_lock_timeout');
|
|
442
|
+
if ((await readFile(lock, 'utf8')) !== ownerText)
|
|
443
|
+
throw new Error('checkpoint_lock_timeout');
|
|
444
|
+
await unlink(lock);
|
|
445
|
+
} catch (staleError) {
|
|
446
|
+
if (staleError instanceof Error && staleError.message.startsWith('checkpoint_'))
|
|
447
|
+
throw staleError;
|
|
448
|
+
throw new Error('checkpoint_lock_failed');
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return async () => {
|
|
453
|
+
try {
|
|
454
|
+
const entry = await lstat(lock);
|
|
455
|
+
if (
|
|
456
|
+
entry.isSymbolicLink() ||
|
|
457
|
+
!entry.isFile() ||
|
|
458
|
+
(entry.mode & 0o077) !== 0 ||
|
|
459
|
+
identity === undefined ||
|
|
460
|
+
entry.dev !== identity.dev ||
|
|
461
|
+
entry.ino !== identity.ino
|
|
462
|
+
)
|
|
463
|
+
return;
|
|
464
|
+
if ((await readFile(lock, 'utf8')) === `${process.pid}:${nonce}\n`) await unlink(lock);
|
|
465
|
+
} catch {
|
|
466
|
+
// An absent lock is already released. Never remove an unexpected target.
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** @param {unknown} value @param {string} binding @param {string} name */
|
|
472
|
+
function validateEnvelope(value, binding, name) {
|
|
473
|
+
if (!object(value) || value.schema_version !== PROTOCOL_VERSION || value.kind !== 'completed')
|
|
474
|
+
throw new Error('checkpoint_corrupt');
|
|
475
|
+
const allowed = new Set([
|
|
476
|
+
'schema_version',
|
|
477
|
+
'kind',
|
|
478
|
+
'binding',
|
|
479
|
+
'operation_id',
|
|
480
|
+
'session_ref',
|
|
481
|
+
'revision',
|
|
482
|
+
'captured_at',
|
|
483
|
+
'payload',
|
|
484
|
+
'payload_sha256',
|
|
485
|
+
]);
|
|
486
|
+
if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error('checkpoint_corrupt');
|
|
487
|
+
if (
|
|
488
|
+
typeof value.session_ref !== 'string' ||
|
|
489
|
+
!SESSION_REF.test(value.session_ref) ||
|
|
490
|
+
typeof value.revision !== 'string' ||
|
|
491
|
+
!REVISION.test(value.revision) ||
|
|
492
|
+
typeof value.binding !== 'string' ||
|
|
493
|
+
!HEX64.test(value.binding) ||
|
|
494
|
+
typeof value.captured_at !== 'string' ||
|
|
495
|
+
!ISO.test(value.captured_at) ||
|
|
496
|
+
(value.operation_id !== undefined &&
|
|
497
|
+
(typeof value.operation_id !== 'string' || !OPERATION_ID.test(value.operation_id))) ||
|
|
498
|
+
!object(value.payload) ||
|
|
499
|
+
typeof value.payload_sha256 !== 'string' ||
|
|
500
|
+
!HEX64.test(value.payload_sha256)
|
|
501
|
+
)
|
|
502
|
+
throw new Error('checkpoint_corrupt');
|
|
503
|
+
const payload = value.payload;
|
|
504
|
+
const payloadKeys = new Set([
|
|
505
|
+
'version',
|
|
506
|
+
'sessionRef',
|
|
507
|
+
'revision',
|
|
508
|
+
'preservationMode',
|
|
509
|
+
'capturedAt',
|
|
510
|
+
'messages',
|
|
511
|
+
]);
|
|
512
|
+
if (
|
|
513
|
+
Object.keys(payload).some((key) => !payloadKeys.has(key)) ||
|
|
514
|
+
payload.version !== 1 ||
|
|
515
|
+
payload.sessionRef !== value.session_ref ||
|
|
516
|
+
payload.revision !== value.revision ||
|
|
517
|
+
(payload.preservationMode !== 'preserve_internal' &&
|
|
518
|
+
payload.preservationMode !== 'upstream_compatible') ||
|
|
519
|
+
payload.capturedAt !== value.captured_at ||
|
|
520
|
+
!Array.isArray(payload.messages)
|
|
521
|
+
)
|
|
522
|
+
throw new Error('checkpoint_corrupt');
|
|
523
|
+
const normalized = normalizedJson(payload);
|
|
524
|
+
if (
|
|
525
|
+
Buffer.byteLength(normalized, 'utf8') > MAX_SNAPSHOT_BYTES ||
|
|
526
|
+
textDigest(normalized) !== value.payload_sha256 ||
|
|
527
|
+
digest({ binding: value.binding, sessionRef: value.session_ref }) !== name
|
|
528
|
+
)
|
|
529
|
+
throw new Error('checkpoint_corrupt');
|
|
530
|
+
return value;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** @param {string} path @param {string} binding @param {string} name */
|
|
534
|
+
async function readEnvelope(path, binding, name) {
|
|
535
|
+
await ownedFile(path);
|
|
536
|
+
let value;
|
|
537
|
+
try {
|
|
538
|
+
value = JSON.parse(await readFile(path, 'utf8'));
|
|
539
|
+
} catch {
|
|
540
|
+
throw new Error('checkpoint_corrupt');
|
|
541
|
+
}
|
|
542
|
+
return validateEnvelope(value, binding, name);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** @param {string} stateDir @param {string} binding */
|
|
546
|
+
async function listRecords(stateDir, binding) {
|
|
547
|
+
let names;
|
|
548
|
+
try {
|
|
549
|
+
names = await readdir(stateDir);
|
|
550
|
+
} catch {
|
|
551
|
+
throw new Error('checkpoint_read_failed');
|
|
552
|
+
}
|
|
553
|
+
const records = [];
|
|
554
|
+
for (const name of names) {
|
|
555
|
+
if (name === LOCK_NAME || TEMP_NAME.test(name) || BACKUP_NAME.test(name)) continue;
|
|
556
|
+
if (!name.startsWith(FILE_PREFIX)) continue;
|
|
557
|
+
const targetMatch = TARGET_NAME.exec(name);
|
|
558
|
+
if (targetMatch === null) throw new Error('checkpoint_corrupt');
|
|
559
|
+
const path = join(stateDir, name);
|
|
560
|
+
const value = await readEnvelope(path, binding, targetMatch[1]);
|
|
561
|
+
records.push({
|
|
562
|
+
name,
|
|
563
|
+
path,
|
|
564
|
+
value,
|
|
565
|
+
bytes: (await lstat(path)).size,
|
|
566
|
+
foreign: value.binding !== binding,
|
|
567
|
+
});
|
|
568
|
+
if (records.length > MAX_RECORD_FILES) throw new Error('checkpoint_retention_failed');
|
|
569
|
+
}
|
|
570
|
+
return records;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** @param {{sessionRef:string, operationId:string, binding:string, requestId:string, revision:string, projectionHash:string, capturedAt:string, noteSha256:string}} identity */
|
|
574
|
+
function noteFileName(identity) {
|
|
575
|
+
return `${NOTE_FILE_PREFIX}${digest({
|
|
576
|
+
binding: identity.binding,
|
|
577
|
+
sessionRef: identity.sessionRef,
|
|
578
|
+
operationId: identity.operationId,
|
|
579
|
+
requestId: identity.requestId,
|
|
580
|
+
revision: identity.revision,
|
|
581
|
+
projectionHash: identity.projectionHash,
|
|
582
|
+
capturedAt: identity.capturedAt,
|
|
583
|
+
noteSha256: identity.noteSha256,
|
|
584
|
+
})}${FILE_SUFFIX}`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** @param {string} path */
|
|
588
|
+
async function ownedNoteFile(path) {
|
|
589
|
+
let entry;
|
|
590
|
+
try {
|
|
591
|
+
entry = await lstat(path);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
594
|
+
throw error;
|
|
595
|
+
throw new Error('note_corrupt');
|
|
596
|
+
}
|
|
597
|
+
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error('note_corrupt');
|
|
598
|
+
if (typeof process.getuid === 'function' && entry.uid !== process.getuid())
|
|
599
|
+
throw new Error('state_owner_mismatch');
|
|
600
|
+
if ((entry.mode & 0o077) !== 0) throw new Error('state_permissions_invalid');
|
|
601
|
+
if (entry.size > MAX_NOTE_ENVELOPE_BYTES) throw new Error('note_oversized');
|
|
602
|
+
return entry;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** @param {unknown} value @param {string} name */
|
|
606
|
+
function validateNoteEnvelope(value, name) {
|
|
607
|
+
if (
|
|
608
|
+
!object(value) ||
|
|
609
|
+
value.schema_version !== NOTES_PROTOCOL_VERSION ||
|
|
610
|
+
value.kind !== 'retained_note'
|
|
611
|
+
)
|
|
612
|
+
throw new Error('note_corrupt');
|
|
613
|
+
const allowed = new Set([
|
|
614
|
+
'schema_version',
|
|
615
|
+
'kind',
|
|
616
|
+
'binding',
|
|
617
|
+
'session_ref',
|
|
618
|
+
'operation_id',
|
|
619
|
+
'request_id',
|
|
620
|
+
'revision',
|
|
621
|
+
'projection_hash',
|
|
622
|
+
'captured_at',
|
|
623
|
+
'note_text',
|
|
624
|
+
'note_sha256',
|
|
625
|
+
]);
|
|
626
|
+
if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error('note_corrupt');
|
|
627
|
+
if (
|
|
628
|
+
typeof value.binding !== 'string' ||
|
|
629
|
+
!HEX64.test(value.binding) ||
|
|
630
|
+
typeof value.session_ref !== 'string' ||
|
|
631
|
+
!SESSION_REF.test(value.session_ref) ||
|
|
632
|
+
Buffer.byteLength(value.session_ref, 'utf8') > MAX_SESSION_REF_BYTES ||
|
|
633
|
+
typeof value.operation_id !== 'string' ||
|
|
634
|
+
!OPERATION_ID.test(value.operation_id) ||
|
|
635
|
+
typeof value.request_id !== 'string' ||
|
|
636
|
+
!OPERATION_ID.test(value.request_id) ||
|
|
637
|
+
typeof value.revision !== 'string' ||
|
|
638
|
+
!REVISION.test(value.revision) ||
|
|
639
|
+
typeof value.projection_hash !== 'string' ||
|
|
640
|
+
!HEX64.test(value.projection_hash) ||
|
|
641
|
+
typeof value.captured_at !== 'string' ||
|
|
642
|
+
!ISO.test(value.captured_at) ||
|
|
643
|
+
typeof value.note_text !== 'string' ||
|
|
644
|
+
typeof value.note_sha256 !== 'string' ||
|
|
645
|
+
!HEX64.test(value.note_sha256) ||
|
|
646
|
+
noteFileName({
|
|
647
|
+
binding: value.binding,
|
|
648
|
+
sessionRef: value.session_ref,
|
|
649
|
+
operationId: value.operation_id,
|
|
650
|
+
requestId: value.request_id,
|
|
651
|
+
revision: value.revision,
|
|
652
|
+
projectionHash: value.projection_hash,
|
|
653
|
+
capturedAt: value.captured_at,
|
|
654
|
+
noteSha256: value.note_sha256,
|
|
655
|
+
}).slice(NOTE_FILE_PREFIX.length, -FILE_SUFFIX.length) !== name
|
|
656
|
+
)
|
|
657
|
+
throw new Error('note_corrupt');
|
|
658
|
+
try {
|
|
659
|
+
if (new Date(value.captured_at).toISOString() !== value.captured_at)
|
|
660
|
+
throw new Error('note_corrupt');
|
|
661
|
+
} catch {
|
|
662
|
+
throw new Error('note_corrupt');
|
|
663
|
+
}
|
|
664
|
+
if (Date.parse(value.captured_at) > Date.now() + NOTE_MAX_CLOCK_SKEW_MS)
|
|
665
|
+
throw new Error('note_corrupt');
|
|
666
|
+
if (Buffer.byteLength(value.note_text, 'utf8') > MAX_NOTE_TEXT_BYTES)
|
|
667
|
+
throw new Error('note_oversized');
|
|
668
|
+
if (textDigest(value.note_text) !== value.note_sha256) throw new Error('note_corrupt');
|
|
669
|
+
return /** @type {Record<string, unknown> & {schema_version:2, kind:'retained_note', binding:string, session_ref:string, operation_id:string, request_id:string, revision:string, projection_hash:string, captured_at:string, note_text:string, note_sha256:string}} */ (
|
|
670
|
+
value
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** @param {string} path @param {string} name */
|
|
675
|
+
async function readNoteEnvelope(path, name) {
|
|
676
|
+
await ownedNoteFile(path);
|
|
677
|
+
let value;
|
|
678
|
+
try {
|
|
679
|
+
value = JSON.parse(await readFile(path, 'utf8'));
|
|
680
|
+
} catch {
|
|
681
|
+
throw new Error('note_corrupt');
|
|
682
|
+
}
|
|
683
|
+
const normalized = `${JSON.stringify(value)}\n`;
|
|
684
|
+
if (Buffer.byteLength(normalized, 'utf8') > MAX_NOTE_ENVELOPE_BYTES)
|
|
685
|
+
throw new Error('note_oversized');
|
|
686
|
+
return validateNoteEnvelope(value, name);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** @param {string} stateDir */
|
|
690
|
+
async function listNoteRecords(stateDir) {
|
|
691
|
+
let names;
|
|
692
|
+
try {
|
|
693
|
+
names = await readdir(stateDir);
|
|
694
|
+
} catch {
|
|
695
|
+
throw new Error('note_read_failed');
|
|
696
|
+
}
|
|
697
|
+
const records = [];
|
|
698
|
+
let aggregate = 0;
|
|
699
|
+
const scanLimit =
|
|
700
|
+
MAX_NOTE_AGGREGATE_BYTES + MAX_NOTE_ENVELOPE_BYTES * (MAX_NOTE_RECORDS_PER_BINDING + 1);
|
|
701
|
+
for (const name of names) {
|
|
702
|
+
if (name === LOCK_NAME || NOTE_TEMP_NAME.test(name) || NOTE_BACKUP_NAME.test(name)) continue;
|
|
703
|
+
if (!name.startsWith(NOTE_FILE_PREFIX)) continue;
|
|
704
|
+
const targetMatch = NOTE_TARGET_NAME.exec(name);
|
|
705
|
+
if (targetMatch === null) throw new Error('note_corrupt');
|
|
706
|
+
const path = join(stateDir, name);
|
|
707
|
+
const entry = await ownedNoteFile(path);
|
|
708
|
+
aggregate += entry.size;
|
|
709
|
+
if (aggregate > scanLimit) throw new Error('note_retention_failed');
|
|
710
|
+
const value = await readNoteEnvelope(path, targetMatch[1]);
|
|
711
|
+
records.push({
|
|
712
|
+
name,
|
|
713
|
+
path,
|
|
714
|
+
value,
|
|
715
|
+
bytes: entry.size,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return records;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** @param {string} stateDir */
|
|
722
|
+
async function cleanNoteStaleArtifacts(stateDir) {
|
|
723
|
+
let names;
|
|
724
|
+
try {
|
|
725
|
+
names = await readdir(stateDir);
|
|
726
|
+
} catch {
|
|
727
|
+
throw new Error('note_read_failed');
|
|
728
|
+
}
|
|
729
|
+
for (const name of names) {
|
|
730
|
+
if (name === LOCK_NAME || !name.startsWith(NOTE_FILE_PREFIX)) continue;
|
|
731
|
+
const isTemp = NOTE_TEMP_NAME.test(name);
|
|
732
|
+
const isBackup = NOTE_BACKUP_NAME.test(name);
|
|
733
|
+
if (!isTemp && !isBackup && NOTE_TARGET_NAME.test(name)) continue;
|
|
734
|
+
if (!isTemp && !isBackup) throw new Error('note_corrupt');
|
|
735
|
+
const path = join(stateDir, name);
|
|
736
|
+
const entry = await ownedNoteFile(path);
|
|
737
|
+
if (Date.now() - entry.mtimeMs < STALE_ARTIFACT_MS) throw new Error('note_lock_timeout');
|
|
738
|
+
if (isTemp) {
|
|
739
|
+
await unlink(path).catch(() => {
|
|
740
|
+
throw new Error('note_retention_failed');
|
|
741
|
+
});
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
const match = NOTE_BACKUP_NAME.exec(name);
|
|
745
|
+
if (match === null) throw new Error('note_corrupt');
|
|
746
|
+
const targetName = `${NOTE_FILE_PREFIX}${match[1]}${FILE_SUFFIX}`;
|
|
747
|
+
const target = join(stateDir, targetName);
|
|
748
|
+
let targetEntry;
|
|
749
|
+
try {
|
|
750
|
+
targetEntry = await lstat(target);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'))
|
|
753
|
+
throw new Error('note_read_failed');
|
|
754
|
+
}
|
|
755
|
+
if (targetEntry === undefined) {
|
|
756
|
+
await readNoteEnvelope(path, match[1]);
|
|
757
|
+
await rename(path, target);
|
|
758
|
+
} else {
|
|
759
|
+
if (targetEntry.isSymbolicLink() || !targetEntry.isFile()) throw new Error('note_corrupt');
|
|
760
|
+
await readNoteEnvelope(target, match[1]);
|
|
761
|
+
await readNoteEnvelope(path, match[1]);
|
|
762
|
+
await unlink(path).catch(() => {
|
|
763
|
+
throw new Error('note_retention_failed');
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** @param {string} stateDir */
|
|
770
|
+
async function cleanStaleArtifacts(stateDir) {
|
|
771
|
+
let names;
|
|
772
|
+
try {
|
|
773
|
+
names = await readdir(stateDir);
|
|
774
|
+
} catch {
|
|
775
|
+
throw new Error('checkpoint_read_failed');
|
|
776
|
+
}
|
|
777
|
+
for (const name of names) {
|
|
778
|
+
if (name === LOCK_NAME) continue;
|
|
779
|
+
const isTemp = TEMP_NAME.test(name);
|
|
780
|
+
const isBackup = BACKUP_NAME.test(name);
|
|
781
|
+
if (!name.startsWith(FILE_PREFIX)) continue;
|
|
782
|
+
if (!isTemp && !isBackup && TARGET_NAME.test(name)) continue;
|
|
783
|
+
if (!isTemp && !isBackup) throw new Error('checkpoint_corrupt');
|
|
784
|
+
const path = join(stateDir, name);
|
|
785
|
+
let entry;
|
|
786
|
+
try {
|
|
787
|
+
entry = await lstat(path);
|
|
788
|
+
} catch {
|
|
789
|
+
throw new Error('checkpoint_read_failed');
|
|
790
|
+
}
|
|
791
|
+
if (
|
|
792
|
+
entry.isSymbolicLink() ||
|
|
793
|
+
!entry.isFile() ||
|
|
794
|
+
entry.size > MAX_SNAPSHOT_BYTES + 16 * 1024 ||
|
|
795
|
+
(entry.mode & 0o077) !== 0 ||
|
|
796
|
+
(typeof process.getuid === 'function' && entry.uid !== process.getuid())
|
|
797
|
+
)
|
|
798
|
+
throw new Error('checkpoint_corrupt');
|
|
799
|
+
if (Date.now() - entry.mtimeMs < STALE_ARTIFACT_MS) throw new Error('checkpoint_lock_timeout');
|
|
800
|
+
if (isTemp) {
|
|
801
|
+
await unlink(path).catch(() => {
|
|
802
|
+
throw new Error('checkpoint_retention_failed');
|
|
803
|
+
});
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
const match = BACKUP_NAME.exec(name);
|
|
807
|
+
if (match === null) throw new Error('checkpoint_corrupt');
|
|
808
|
+
const targetName = `${FILE_PREFIX}${match[1]}${FILE_SUFFIX}`;
|
|
809
|
+
const target = join(stateDir, targetName);
|
|
810
|
+
let targetEntry;
|
|
811
|
+
try {
|
|
812
|
+
targetEntry = await lstat(target);
|
|
813
|
+
} catch (error) {
|
|
814
|
+
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'))
|
|
815
|
+
throw new Error('checkpoint_read_failed');
|
|
816
|
+
}
|
|
817
|
+
if (targetEntry === undefined) {
|
|
818
|
+
await readEnvelope(path, '', match[1]);
|
|
819
|
+
await rename(path, target);
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (targetEntry.isSymbolicLink() || !targetEntry.isFile())
|
|
823
|
+
throw new Error('checkpoint_corrupt');
|
|
824
|
+
await readEnvelope(target, '', match[1]);
|
|
825
|
+
await readEnvelope(path, '', match[1]);
|
|
826
|
+
await unlink(path).catch(() => {
|
|
827
|
+
throw new Error('checkpoint_retention_failed');
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** @param {string} stateDir @param {string} expectedBinding */
|
|
833
|
+
async function verifyConfigBinding(stateDir, expectedBinding) {
|
|
834
|
+
const checkpointDir = resolve(stateDir);
|
|
835
|
+
const root = resolve(join(checkpointDir, '..', '..'));
|
|
836
|
+
if (checkpointDir !== join(root, 'claude', 'checkpoints')) throw new Error('state_dir_invalid');
|
|
837
|
+
await assertPath(root, false);
|
|
838
|
+
const rootEntry = await lstat(root);
|
|
839
|
+
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) throw new Error('state_dir_invalid');
|
|
840
|
+
if (typeof process.getuid === 'function' && rootEntry.uid !== process.getuid())
|
|
841
|
+
throw new Error('state_owner_mismatch');
|
|
842
|
+
if ((rootEntry.mode & 0o077) !== 0) throw new Error('state_permissions_invalid');
|
|
843
|
+
const configPath = join(root, 'config.json');
|
|
844
|
+
await assertPath(configPath, false);
|
|
845
|
+
const configEntry = await lstat(configPath);
|
|
846
|
+
if (configEntry.isSymbolicLink() || !configEntry.isFile()) throw new Error('state_dir_invalid');
|
|
847
|
+
if (typeof process.getuid === 'function' && configEntry.uid !== process.getuid())
|
|
848
|
+
throw new Error('state_owner_mismatch');
|
|
849
|
+
if ((configEntry.mode & 0o077) !== 0) throw new Error('state_permissions_invalid');
|
|
850
|
+
let config;
|
|
851
|
+
try {
|
|
852
|
+
config = JSON.parse(await readFile(configPath, 'utf8'));
|
|
853
|
+
} catch {
|
|
854
|
+
throw new Error('state_dir_invalid');
|
|
855
|
+
}
|
|
856
|
+
if (
|
|
857
|
+
!object(config) ||
|
|
858
|
+
typeof config.endpoint !== 'string' ||
|
|
859
|
+
typeof config.token !== 'string' ||
|
|
860
|
+
typeof config.disclosedAt !== 'string' ||
|
|
861
|
+
!ISO.test(config.disclosedAt) ||
|
|
862
|
+
config.token.length < 1 ||
|
|
863
|
+
/[\u0000-\u001f\u007f-\u009f]/u.test(config.token)
|
|
864
|
+
)
|
|
865
|
+
throw new Error('state_dir_invalid');
|
|
866
|
+
let endpoint;
|
|
867
|
+
try {
|
|
868
|
+
endpoint = new URL(config.endpoint);
|
|
869
|
+
} catch {
|
|
870
|
+
throw new Error('state_dir_invalid');
|
|
871
|
+
}
|
|
872
|
+
if (
|
|
873
|
+
endpoint.protocol !== 'https:' ||
|
|
874
|
+
endpoint.username ||
|
|
875
|
+
endpoint.password ||
|
|
876
|
+
endpoint.search ||
|
|
877
|
+
endpoint.hash
|
|
878
|
+
)
|
|
879
|
+
throw new Error('state_dir_invalid');
|
|
880
|
+
const actual = textDigest(
|
|
881
|
+
`${endpoint.toString()}\u0000${config.token}\u0000${config.disclosedAt}`,
|
|
882
|
+
);
|
|
883
|
+
if (actual !== expectedBinding) throw new Error('checkpoint_binding_mismatch');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** @param {string} stateDir @param {string} targetName @param {Record<string, unknown>} envelope @param {{failAfterRename?: boolean}} [options] */
|
|
887
|
+
async function atomicWrite(stateDir, targetName, envelope, options = {}) {
|
|
888
|
+
const target = join(stateDir, targetName);
|
|
889
|
+
const temporary = join(stateDir, `${targetName}.tmp-${process.pid}-${randomUUID()}`);
|
|
890
|
+
const backup = join(stateDir, `${targetName}.bak-${process.pid}-${randomUUID()}`);
|
|
891
|
+
const text = `${JSON.stringify(envelope)}\n`;
|
|
892
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
893
|
+
if (bytes > MAX_SNAPSHOT_BYTES + 16 * 1024) throw new Error('checkpoint_oversized');
|
|
894
|
+
let handle;
|
|
895
|
+
let movedPrevious = false;
|
|
896
|
+
try {
|
|
897
|
+
handle = await open(
|
|
898
|
+
temporary,
|
|
899
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
900
|
+
0o600,
|
|
901
|
+
);
|
|
902
|
+
await handle.writeFile(text, 'utf8');
|
|
903
|
+
await handle.sync();
|
|
904
|
+
await handle.close();
|
|
905
|
+
handle = undefined;
|
|
906
|
+
await ownedFile(temporary);
|
|
907
|
+
const readback = await readFile(temporary, 'utf8');
|
|
908
|
+
if (readback !== text) throw new Error('checkpoint_ack_failed');
|
|
909
|
+
try {
|
|
910
|
+
await ownedFile(target);
|
|
911
|
+
await rename(target, backup);
|
|
912
|
+
movedPrevious = true;
|
|
913
|
+
} catch (error) {
|
|
914
|
+
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'))
|
|
915
|
+
throw error;
|
|
916
|
+
}
|
|
917
|
+
await rename(temporary, target);
|
|
918
|
+
await ownedFile(target);
|
|
919
|
+
if ((await readFile(target, 'utf8')) !== text) throw new Error('checkpoint_ack_failed');
|
|
920
|
+
if (options.failAfterRename === true) throw new Error('checkpoint_ack_failed');
|
|
921
|
+
if (movedPrevious) {
|
|
922
|
+
await ownedFile(backup);
|
|
923
|
+
await unlink(backup);
|
|
924
|
+
movedPrevious = false;
|
|
925
|
+
}
|
|
926
|
+
} catch (error) {
|
|
927
|
+
await handle?.close().catch(() => undefined);
|
|
928
|
+
await unlink(temporary).catch(() => undefined);
|
|
929
|
+
if (movedPrevious) {
|
|
930
|
+
await unlink(target).catch(() => undefined);
|
|
931
|
+
await rename(backup, target).catch(() => undefined);
|
|
932
|
+
}
|
|
933
|
+
if (error instanceof Error && ERROR_CODES.has(error.message)) throw error;
|
|
934
|
+
throw new Error('checkpoint_write_failed');
|
|
935
|
+
}
|
|
936
|
+
return bytes;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** @param {string} stateDir @param {string} targetName @param {string} expectedText */
|
|
940
|
+
async function removeInstalledNote(stateDir, targetName, expectedText) {
|
|
941
|
+
const target = join(stateDir, targetName);
|
|
942
|
+
let entry;
|
|
943
|
+
try {
|
|
944
|
+
entry = await lstat(target);
|
|
945
|
+
} catch (error) {
|
|
946
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return;
|
|
947
|
+
throw new Error('note_retention_failed');
|
|
948
|
+
}
|
|
949
|
+
if (
|
|
950
|
+
entry.isSymbolicLink() ||
|
|
951
|
+
!entry.isFile() ||
|
|
952
|
+
(typeof process.getuid === 'function' && entry.uid !== process.getuid()) ||
|
|
953
|
+
(entry.mode & 0o077) !== 0
|
|
954
|
+
)
|
|
955
|
+
throw new Error('note_retention_failed');
|
|
956
|
+
let text;
|
|
957
|
+
try {
|
|
958
|
+
text = await readFile(target, 'utf8');
|
|
959
|
+
} catch {
|
|
960
|
+
throw new Error('note_retention_failed');
|
|
961
|
+
}
|
|
962
|
+
if (text !== expectedText) throw new Error('note_retention_failed');
|
|
963
|
+
await unlink(target).catch(() => {
|
|
964
|
+
throw new Error('note_retention_failed');
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** @param {string} stateDir @param {{name:string, path:string, text:string}} record */
|
|
969
|
+
async function restoreRemovedNote(stateDir, record) {
|
|
970
|
+
const target = join(stateDir, record.name);
|
|
971
|
+
let entry;
|
|
972
|
+
try {
|
|
973
|
+
entry = await lstat(target);
|
|
974
|
+
} catch (error) {
|
|
975
|
+
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'))
|
|
976
|
+
throw new Error('note_retention_failed');
|
|
977
|
+
}
|
|
978
|
+
if (entry !== undefined) {
|
|
979
|
+
if (
|
|
980
|
+
entry.isSymbolicLink() ||
|
|
981
|
+
!entry.isFile() ||
|
|
982
|
+
(typeof process.getuid === 'function' && entry.uid !== process.getuid()) ||
|
|
983
|
+
(entry.mode & 0o077) !== 0
|
|
984
|
+
)
|
|
985
|
+
throw new Error('note_retention_failed');
|
|
986
|
+
if ((await readFile(target, 'utf8')) !== record.text) throw new Error('note_retention_failed');
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const targetMatch = NOTE_TARGET_NAME.exec(record.name);
|
|
990
|
+
if (targetMatch === null) throw new Error('note_retention_failed');
|
|
991
|
+
let envelope;
|
|
992
|
+
try {
|
|
993
|
+
envelope = JSON.parse(record.text);
|
|
994
|
+
} catch {
|
|
995
|
+
throw new Error('note_retention_failed');
|
|
996
|
+
}
|
|
997
|
+
validateNoteEnvelope(envelope, targetMatch[1]);
|
|
998
|
+
await atomicWriteNote(stateDir, record.name, envelope);
|
|
999
|
+
if ((await readFile(target, 'utf8')) !== record.text) {
|
|
1000
|
+
await removeInstalledNote(stateDir, record.name, `${JSON.stringify(envelope)}\n`).catch(
|
|
1001
|
+
() => undefined,
|
|
1002
|
+
);
|
|
1003
|
+
throw new Error('note_retention_failed');
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** @param {string} stateDir @param {string} targetName @param {Record<string, unknown>} envelope @param {{failAfterRename?: boolean, removeNewOnFailure?: boolean}} [options] */
|
|
1008
|
+
async function atomicWriteNote(stateDir, targetName, envelope, options = {}) {
|
|
1009
|
+
const target = join(stateDir, targetName);
|
|
1010
|
+
const temporary = join(stateDir, `${targetName}.tmp-${process.pid}-${randomUUID()}`);
|
|
1011
|
+
const backup = join(stateDir, `${targetName}.bak-${process.pid}-${randomUUID()}`);
|
|
1012
|
+
const text = `${JSON.stringify(envelope)}\n`;
|
|
1013
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
1014
|
+
if (bytes > MAX_NOTE_ENVELOPE_BYTES) throw new Error('note_oversized');
|
|
1015
|
+
let handle;
|
|
1016
|
+
let movedPrevious = false;
|
|
1017
|
+
let installed = false;
|
|
1018
|
+
try {
|
|
1019
|
+
handle = await open(
|
|
1020
|
+
temporary,
|
|
1021
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
1022
|
+
0o600,
|
|
1023
|
+
);
|
|
1024
|
+
await handle.writeFile(text, 'utf8');
|
|
1025
|
+
await handle.sync();
|
|
1026
|
+
await handle.close();
|
|
1027
|
+
handle = undefined;
|
|
1028
|
+
await ownedNoteFile(temporary);
|
|
1029
|
+
if ((await readFile(temporary, 'utf8')) !== text) throw new Error('note_ack_failed');
|
|
1030
|
+
try {
|
|
1031
|
+
await ownedNoteFile(target);
|
|
1032
|
+
await rename(target, backup);
|
|
1033
|
+
movedPrevious = true;
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'))
|
|
1036
|
+
throw error;
|
|
1037
|
+
}
|
|
1038
|
+
await rename(temporary, target);
|
|
1039
|
+
installed = true;
|
|
1040
|
+
await ownedNoteFile(target);
|
|
1041
|
+
if ((await readFile(target, 'utf8')) !== text) throw new Error('note_ack_failed');
|
|
1042
|
+
if (options.failAfterRename === true) throw new Error('note_ack_failed');
|
|
1043
|
+
if (movedPrevious) {
|
|
1044
|
+
await ownedNoteFile(backup);
|
|
1045
|
+
await unlink(backup);
|
|
1046
|
+
movedPrevious = false;
|
|
1047
|
+
}
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
await handle?.close().catch(() => undefined);
|
|
1050
|
+
await unlink(temporary).catch(() => undefined);
|
|
1051
|
+
if (movedPrevious) {
|
|
1052
|
+
await unlink(target).catch(() => undefined);
|
|
1053
|
+
await rename(backup, target).catch(() => undefined);
|
|
1054
|
+
} else if (installed && options.removeNewOnFailure === true) {
|
|
1055
|
+
await removeInstalledNote(stateDir, targetName, text);
|
|
1056
|
+
}
|
|
1057
|
+
if (error instanceof Error && ERROR_CODES.has(error.message)) throw error;
|
|
1058
|
+
throw new Error('note_write_failed');
|
|
1059
|
+
}
|
|
1060
|
+
return bytes;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** @param {string} stateDir @param {string} binding @param {{operation_id?:string, session_ref:string, revision:string, preservation_mode:string, checkpoint:Record<string, unknown>, byte_count:number, payload_sha256:string}} input @param {{failAfterRename?: boolean}} [options] */
|
|
1064
|
+
async function save(stateDir, binding, input, options = {}) {
|
|
1065
|
+
const payload = normalizedJson(input.checkpoint);
|
|
1066
|
+
const bytes = Buffer.byteLength(payload, 'utf8');
|
|
1067
|
+
if (bytes > MAX_SNAPSHOT_BYTES) throw new Error('checkpoint_oversized');
|
|
1068
|
+
let parsed;
|
|
1069
|
+
try {
|
|
1070
|
+
parsed = JSON.parse(payload);
|
|
1071
|
+
} catch {
|
|
1072
|
+
throw new Error('checkpoint_invalid');
|
|
1073
|
+
}
|
|
1074
|
+
const checksum = textDigest(payload);
|
|
1075
|
+
const name = fileName(input.session_ref, binding);
|
|
1076
|
+
const envelope = {
|
|
1077
|
+
schema_version: PROTOCOL_VERSION,
|
|
1078
|
+
kind: 'completed',
|
|
1079
|
+
binding,
|
|
1080
|
+
...(input.operation_id === undefined ? {} : { operation_id: input.operation_id }),
|
|
1081
|
+
session_ref: input.session_ref,
|
|
1082
|
+
revision: input.revision,
|
|
1083
|
+
captured_at: input.checkpoint.capturedAt,
|
|
1084
|
+
payload: parsed,
|
|
1085
|
+
payload_sha256: checksum,
|
|
1086
|
+
};
|
|
1087
|
+
const records = await listRecords(stateDir, binding);
|
|
1088
|
+
const current = records.find((record) => record.name === name);
|
|
1089
|
+
const candidateFileBytes = Buffer.byteLength(`${JSON.stringify(envelope)}\n`, 'utf8');
|
|
1090
|
+
let prospective =
|
|
1091
|
+
records.reduce((total, record) => total + record.bytes, 0) -
|
|
1092
|
+
(current?.bytes ?? 0) +
|
|
1093
|
+
candidateFileBytes;
|
|
1094
|
+
const retained = records
|
|
1095
|
+
.filter((record) => record.name !== name && record.foreign !== true)
|
|
1096
|
+
.sort((a, b) => String(b.value.captured_at).localeCompare(String(a.value.captured_at)));
|
|
1097
|
+
while (retained.length + 1 > MAX_COMPLETED_CHECKPOINTS || prospective > MAX_STORED_BYTES) {
|
|
1098
|
+
const removable = retained.pop();
|
|
1099
|
+
if (removable === undefined) break;
|
|
1100
|
+
await unlink(removable.path).catch(() => {
|
|
1101
|
+
throw new Error('checkpoint_retention_failed');
|
|
1102
|
+
});
|
|
1103
|
+
prospective -= removable.bytes;
|
|
1104
|
+
}
|
|
1105
|
+
if (prospective > MAX_STORED_BYTES) throw new Error('checkpoint_retention_failed');
|
|
1106
|
+
const byteCount = await atomicWrite(stateDir, name, envelope, options);
|
|
1107
|
+
return {
|
|
1108
|
+
schema_version: PROTOCOL_VERSION,
|
|
1109
|
+
accepted: true,
|
|
1110
|
+
operation: 'checkpoint.write',
|
|
1111
|
+
binding,
|
|
1112
|
+
...(input.operation_id === undefined ? {} : { operation_id: input.operation_id }),
|
|
1113
|
+
session_ref: input.session_ref,
|
|
1114
|
+
revision: input.revision,
|
|
1115
|
+
byte_count: bytes,
|
|
1116
|
+
payload_sha256: checksum,
|
|
1117
|
+
stored_bytes: byteCount,
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** @param {string} stateDir @param {string} binding @param {ReturnType<typeof parseNotesInput> & {operation:'notes.write'}} input @param {{failAfterRename?: boolean, failRetirementAt?: number, failAfterRetirementAt?: number, onRetirementStep?: (index:number, path:string) => Promise<void>, onRetirementRemoved?: (index:number, path:string) => Promise<void>}} [options] */
|
|
1122
|
+
async function saveNote(stateDir, binding, input, options = {}) {
|
|
1123
|
+
if (input.binding !== binding) throw new Error('note_binding_mismatch');
|
|
1124
|
+
validateNoteTimestamp(input.captured_at, 'note_invalid');
|
|
1125
|
+
const envelope = {
|
|
1126
|
+
schema_version: NOTES_PROTOCOL_VERSION,
|
|
1127
|
+
kind: 'retained_note',
|
|
1128
|
+
binding,
|
|
1129
|
+
session_ref: input.session_ref,
|
|
1130
|
+
operation_id: input.operation_id,
|
|
1131
|
+
request_id: input.request_id,
|
|
1132
|
+
revision: input.revision,
|
|
1133
|
+
projection_hash: input.projection_hash,
|
|
1134
|
+
captured_at: input.captured_at,
|
|
1135
|
+
note_text: input.note_text,
|
|
1136
|
+
note_sha256: input.note_sha256,
|
|
1137
|
+
};
|
|
1138
|
+
const targetName = noteFileName({
|
|
1139
|
+
sessionRef: input.session_ref,
|
|
1140
|
+
operationId: input.operation_id,
|
|
1141
|
+
binding,
|
|
1142
|
+
requestId: input.request_id,
|
|
1143
|
+
revision: input.revision,
|
|
1144
|
+
projectionHash: input.projection_hash,
|
|
1145
|
+
capturedAt: input.captured_at,
|
|
1146
|
+
noteSha256: input.note_sha256,
|
|
1147
|
+
});
|
|
1148
|
+
const candidateBytes = Buffer.byteLength(`${JSON.stringify(envelope)}\n`, 'utf8');
|
|
1149
|
+
const candidateText = `${JSON.stringify(envelope)}\n`;
|
|
1150
|
+
if (candidateBytes > MAX_NOTE_ENVELOPE_BYTES) throw new Error('note_oversized');
|
|
1151
|
+
const records = await listNoteRecords(stateDir);
|
|
1152
|
+
const sameOperation = records.filter(
|
|
1153
|
+
(record) =>
|
|
1154
|
+
record.value.binding === binding &&
|
|
1155
|
+
record.value.session_ref === input.session_ref &&
|
|
1156
|
+
record.value.operation_id === input.operation_id,
|
|
1157
|
+
);
|
|
1158
|
+
if (sameOperation.length > 1) throw new Error('note_corrupt');
|
|
1159
|
+
const current = sameOperation[0];
|
|
1160
|
+
if (current !== undefined) {
|
|
1161
|
+
const currentValue = current.value;
|
|
1162
|
+
const same =
|
|
1163
|
+
currentValue.binding === binding &&
|
|
1164
|
+
currentValue.session_ref === input.session_ref &&
|
|
1165
|
+
currentValue.operation_id === input.operation_id &&
|
|
1166
|
+
currentValue.request_id === input.request_id &&
|
|
1167
|
+
currentValue.revision === input.revision &&
|
|
1168
|
+
currentValue.projection_hash === input.projection_hash &&
|
|
1169
|
+
currentValue.captured_at === input.captured_at &&
|
|
1170
|
+
currentValue.note_sha256 === input.note_sha256 &&
|
|
1171
|
+
currentValue.note_text === input.note_text;
|
|
1172
|
+
if (!same) throw new Error('note_metadata_mismatch');
|
|
1173
|
+
return noteWriteAck(input);
|
|
1174
|
+
}
|
|
1175
|
+
const sameBinding = records
|
|
1176
|
+
.filter((record) => record.value.binding === binding)
|
|
1177
|
+
.sort((left, right) => {
|
|
1178
|
+
const timestamp = Date.parse(left.value.captured_at) - Date.parse(right.value.captured_at);
|
|
1179
|
+
return timestamp || left.name.localeCompare(right.name);
|
|
1180
|
+
});
|
|
1181
|
+
const aggregate = records.reduce((total, record) => total + record.bytes, 0);
|
|
1182
|
+
/** @type {Array<{name:string, path:string, bytes:number, value:Record<string, unknown>}>} */
|
|
1183
|
+
const retirees = [];
|
|
1184
|
+
while (
|
|
1185
|
+
sameBinding.length - retirees.length + 1 > MAX_NOTE_RECORDS_PER_BINDING ||
|
|
1186
|
+
aggregate - retirees.reduce((total, record) => total + record.bytes, 0) + candidateBytes >
|
|
1187
|
+
MAX_NOTE_AGGREGATE_BYTES
|
|
1188
|
+
) {
|
|
1189
|
+
const retire = sameBinding[retirees.length];
|
|
1190
|
+
if (retire === undefined) throw new Error('note_retention_failed');
|
|
1191
|
+
retirees.push(retire);
|
|
1192
|
+
}
|
|
1193
|
+
await atomicWriteNote(stateDir, targetName, envelope, { ...options, removeNewOnFailure: true });
|
|
1194
|
+
const removed = [];
|
|
1195
|
+
try {
|
|
1196
|
+
for (const [index, retire] of retirees.entries()) {
|
|
1197
|
+
await options.onRetirementStep?.(index, retire.path);
|
|
1198
|
+
if (options.failRetirementAt === index) throw new Error('note_retention_failed');
|
|
1199
|
+
removed.push({
|
|
1200
|
+
name: retire.name,
|
|
1201
|
+
path: retire.path,
|
|
1202
|
+
text: await readFile(retire.path, 'utf8'),
|
|
1203
|
+
});
|
|
1204
|
+
await unlink(retire.path);
|
|
1205
|
+
await options.onRetirementRemoved?.(index, retire.path);
|
|
1206
|
+
if (options.failAfterRetirementAt === index) throw new Error('note_retention_failed');
|
|
1207
|
+
}
|
|
1208
|
+
} catch {
|
|
1209
|
+
let rollbackFailed = false;
|
|
1210
|
+
await removeInstalledNote(stateDir, targetName, candidateText).catch(() => {
|
|
1211
|
+
rollbackFailed = true;
|
|
1212
|
+
});
|
|
1213
|
+
for (const previous of removed.reverse()) {
|
|
1214
|
+
await restoreRemovedNote(stateDir, previous).catch(() => {
|
|
1215
|
+
rollbackFailed = true;
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
if (rollbackFailed) throw new Error('note_retention_failed');
|
|
1219
|
+
throw new Error('note_retention_failed');
|
|
1220
|
+
}
|
|
1221
|
+
return noteWriteAck(input);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/** @param {Record<string, unknown>} input */
|
|
1225
|
+
function noteWriteAck(input) {
|
|
1226
|
+
return {
|
|
1227
|
+
schema_version: NOTES_PROTOCOL_VERSION,
|
|
1228
|
+
accepted: true,
|
|
1229
|
+
operation: 'notes.write',
|
|
1230
|
+
binding: input.binding,
|
|
1231
|
+
session_ref: input.session_ref,
|
|
1232
|
+
operation_id: input.operation_id,
|
|
1233
|
+
note_sha256: input.note_sha256,
|
|
1234
|
+
byte_count: Buffer.byteLength(/** @type {string} */ (input.note_text), 'utf8'),
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** @param {string} stateDir @param {string} binding @param {ReturnType<typeof parseNotesInput> & {operation:'notes.read'}} input */
|
|
1239
|
+
async function readNote(stateDir, binding, input) {
|
|
1240
|
+
if (input.binding !== binding) throw new Error('note_binding_mismatch');
|
|
1241
|
+
const records = await listNoteRecords(stateDir);
|
|
1242
|
+
if (records.reduce((total, record) => total + record.bytes, 0) > MAX_NOTE_AGGREGATE_BYTES)
|
|
1243
|
+
throw new Error('note_retention_failed');
|
|
1244
|
+
const record = records.find(
|
|
1245
|
+
(candidate) =>
|
|
1246
|
+
candidate.value.binding === binding &&
|
|
1247
|
+
candidate.value.session_ref === input.session_ref &&
|
|
1248
|
+
candidate.value.operation_id === input.operation_id &&
|
|
1249
|
+
candidate.value.note_sha256 === input.note_sha256,
|
|
1250
|
+
);
|
|
1251
|
+
if (record === undefined) throw new Error('note_not_found');
|
|
1252
|
+
const captured = Date.parse(record.value.captured_at);
|
|
1253
|
+
if (!Number.isFinite(captured) || Date.now() - captured > NOTE_EXPIRY_MS)
|
|
1254
|
+
throw new Error('note_expired');
|
|
1255
|
+
return {
|
|
1256
|
+
schema_version: NOTES_PROTOCOL_VERSION,
|
|
1257
|
+
accepted: true,
|
|
1258
|
+
operation: 'notes.read',
|
|
1259
|
+
binding,
|
|
1260
|
+
session_ref: record.value.session_ref,
|
|
1261
|
+
operation_id: record.value.operation_id,
|
|
1262
|
+
note_sha256: record.value.note_sha256,
|
|
1263
|
+
byte_count: Buffer.byteLength(record.value.note_text, 'utf8'),
|
|
1264
|
+
note_text: record.value.note_text,
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** @param {string} stateDir @param {string} binding @param {ReturnType<typeof parseInput>} input @param {{failAfterRename?: boolean, failRetirementAt?: number, failAfterRetirementAt?: number, onRetirementStep?: (index:number, path:string) => Promise<void>, onRetirementRemoved?: (index:number, path:string) => Promise<void>}} [options] */
|
|
1269
|
+
export async function executeCheckpoint(stateDir, binding, input, options = {}) {
|
|
1270
|
+
const isNote = input.operation === 'notes.write' || input.operation === 'notes.read';
|
|
1271
|
+
let release;
|
|
1272
|
+
try {
|
|
1273
|
+
release = await acquireLock(stateDir);
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
if (isNote && error instanceof Error && error.message === 'checkpoint_lock_timeout')
|
|
1276
|
+
throw new Error('note_lock_timeout');
|
|
1277
|
+
if (isNote && error instanceof Error && error.message === 'checkpoint_lock_failed')
|
|
1278
|
+
throw new Error('note_lock_failed');
|
|
1279
|
+
throw error;
|
|
1280
|
+
}
|
|
1281
|
+
try {
|
|
1282
|
+
await cleanStaleArtifacts(stateDir);
|
|
1283
|
+
if (input.operation === 'notes.write') {
|
|
1284
|
+
await cleanNoteStaleArtifacts(stateDir);
|
|
1285
|
+
return await saveNote(stateDir, binding, input, options);
|
|
1286
|
+
}
|
|
1287
|
+
if (input.operation === 'notes.read') {
|
|
1288
|
+
await cleanNoteStaleArtifacts(stateDir);
|
|
1289
|
+
return await readNote(stateDir, binding, input);
|
|
1290
|
+
}
|
|
1291
|
+
return await save(stateDir, binding, input, options);
|
|
1292
|
+
} finally {
|
|
1293
|
+
await release();
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** @param {unknown} error @param {number} [protocolVersion] */
|
|
1298
|
+
function safeErrorCode(error, protocolVersion = PROTOCOL_VERSION) {
|
|
1299
|
+
return error instanceof Error && ERROR_CODES.has(error.message)
|
|
1300
|
+
? error.message
|
|
1301
|
+
: protocolVersion === NOTES_PROTOCOL_VERSION
|
|
1302
|
+
? 'note_read_failed'
|
|
1303
|
+
: 'checkpoint_failed';
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
1307
|
+
let protocolVersion = PROTOCOL_VERSION;
|
|
1308
|
+
try {
|
|
1309
|
+
const { stateDir, configDigest, binding } = parseArgs(argv);
|
|
1310
|
+
await verifyConfigBinding(stateDir, configDigest);
|
|
1311
|
+
const safeDir = await ownedDirectory(stateDir);
|
|
1312
|
+
const rawInput = await readStdin();
|
|
1313
|
+
try {
|
|
1314
|
+
const hinted = JSON.parse(rawInput);
|
|
1315
|
+
if (object(hinted) && hinted.schema_version === NOTES_PROTOCOL_VERSION)
|
|
1316
|
+
protocolVersion = NOTES_PROTOCOL_VERSION;
|
|
1317
|
+
} catch {
|
|
1318
|
+
// parseInput emits the closed invalid_input response below.
|
|
1319
|
+
}
|
|
1320
|
+
const input = parseInput(rawInput);
|
|
1321
|
+
protocolVersion = input.schema_version;
|
|
1322
|
+
if (input.binding !== binding)
|
|
1323
|
+
throw new Error(
|
|
1324
|
+
input.operation.startsWith('notes.') ? 'note_binding_mismatch' : 'checkpoint_ack_failed',
|
|
1325
|
+
);
|
|
1326
|
+
const result = await executeCheckpoint(safeDir, binding, input);
|
|
1327
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1328
|
+
return 0;
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
process.stdout.write(
|
|
1331
|
+
`${JSON.stringify({ schema_version: protocolVersion, accepted: false, error_code: safeErrorCode(error, protocolVersion) })}\n`,
|
|
1332
|
+
);
|
|
1333
|
+
return 1;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === resolve(process.argv[1]))
|
|
1338
|
+
process.exitCode = await main();
|