specpi 0.11.2 → 0.13.0
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/CHANGELOG.md +21 -1
- package/NPM_RELEASE.md +3 -1
- package/README.md +41 -29
- package/SECURITY_MODEL.md +36 -0
- package/THIRD_PARTY.md +18 -2
- package/docs/browser-testing.md +76 -0
- package/docs/delegation/README.md +264 -0
- package/docs/delegation/design-protocol.md +382 -0
- package/docs/delegation/design.md +525 -0
- package/docs/delegation/evaluation.md +307 -0
- package/docs/delegation/protocol.md +271 -0
- package/docs/delegation/research.md +216 -0
- package/extensions/browser/core.d.mts +64 -0
- package/extensions/browser/diagnostics.ts +275 -0
- package/extensions/browser/index.ts +349 -57
- package/extensions/browser/interactions.ts +118 -0
- package/extensions/browser/lifecycle.ts +28 -0
- package/extensions/command-guard/index.ts +118 -33
- package/extensions/delegation/core.mjs +772 -0
- package/extensions/delegation/errors.mjs +8 -0
- package/extensions/delegation/extension.mjs +475 -0
- package/extensions/delegation/index.ts +9 -0
- package/extensions/delegation/managed-files.mjs +13 -0
- package/extensions/delegation/native.mjs +155 -0
- package/extensions/delegation/presentation.mjs +315 -0
- package/extensions/delegation/protocol.mjs +296 -0
- package/extensions/delegation/provider.mjs +689 -0
- package/extensions/delegation/snapshot.mjs +532 -0
- package/extensions/delegation/worker.mjs +218 -0
- package/extensions/tool-wishlist/verification.mjs +1 -0
- package/extensions/workflow-controls/index.ts +5 -1
- package/package.json +17 -4
- package/scripts/check-package.mjs +29 -3
- package/scripts/check-pi-package.mjs +5 -0
- package/scripts/check-syntax.mjs +61 -0
- package/scripts/run-browser-tests.mjs +61 -0
- package/scripts/setup-browser-tests.mjs +38 -0
- package/scripts/site-browser.mjs +272 -0
- package/scripts/specpi.mjs +24 -0
- package/site/logo.svg +1 -9
- package/templates/AGENTS.md +1 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { DelegationError } from "./errors.mjs";
|
|
5
|
+
|
|
6
|
+
const MAX_FILES = 200;
|
|
7
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
const MAX_RESPONSE_BYTES = 16 * 1024;
|
|
9
|
+
const TEXT_EXTENSIONS = new Set([
|
|
10
|
+
".astro",
|
|
11
|
+
".bash",
|
|
12
|
+
".bat",
|
|
13
|
+
".c",
|
|
14
|
+
".cc",
|
|
15
|
+
".cjs",
|
|
16
|
+
".cmake",
|
|
17
|
+
".cmd",
|
|
18
|
+
".cpp",
|
|
19
|
+
".cs",
|
|
20
|
+
".css",
|
|
21
|
+
".cts",
|
|
22
|
+
".go",
|
|
23
|
+
".gql",
|
|
24
|
+
".graphql",
|
|
25
|
+
".h",
|
|
26
|
+
".hpp",
|
|
27
|
+
".htm",
|
|
28
|
+
".html",
|
|
29
|
+
".ini",
|
|
30
|
+
".java",
|
|
31
|
+
".js",
|
|
32
|
+
".json",
|
|
33
|
+
".jsx",
|
|
34
|
+
".kt",
|
|
35
|
+
".kts",
|
|
36
|
+
".less",
|
|
37
|
+
".lock",
|
|
38
|
+
".lua",
|
|
39
|
+
".md",
|
|
40
|
+
".mdx",
|
|
41
|
+
".mjs",
|
|
42
|
+
".mts",
|
|
43
|
+
".php",
|
|
44
|
+
".properties",
|
|
45
|
+
".proto",
|
|
46
|
+
".ps1",
|
|
47
|
+
".py",
|
|
48
|
+
".r",
|
|
49
|
+
".rb",
|
|
50
|
+
".rs",
|
|
51
|
+
".rst",
|
|
52
|
+
".sass",
|
|
53
|
+
".scss",
|
|
54
|
+
".sh",
|
|
55
|
+
".sql",
|
|
56
|
+
".svelte",
|
|
57
|
+
".svg",
|
|
58
|
+
".swift",
|
|
59
|
+
".toml",
|
|
60
|
+
".ts",
|
|
61
|
+
".tsx",
|
|
62
|
+
".txt",
|
|
63
|
+
".vue",
|
|
64
|
+
".xml",
|
|
65
|
+
".yaml",
|
|
66
|
+
".yml",
|
|
67
|
+
]);
|
|
68
|
+
const TEXT_NAMES = new Set([
|
|
69
|
+
"dockerfile",
|
|
70
|
+
"makefile",
|
|
71
|
+
"cmakelists.txt",
|
|
72
|
+
"license",
|
|
73
|
+
"notice",
|
|
74
|
+
".editorconfig",
|
|
75
|
+
".gitignore",
|
|
76
|
+
".gitattributes",
|
|
77
|
+
".prettierrc",
|
|
78
|
+
".eslintrc",
|
|
79
|
+
]);
|
|
80
|
+
const PRIVATE_SEGMENT =
|
|
81
|
+
/(?:^|[._-])(?:auth|authentication|secret|secrets|credential|credentials|session|sessions|history|mission|missions|trust)(?:$|[._-])/i;
|
|
82
|
+
const PRIVATE_DIRECTORY = /^(?:\.git|\.pi|\.codex|\.ssh|\.aws|\.azure|\.gnupg)$/i;
|
|
83
|
+
const DEVICE_NAME = /^(?:con|prn|aux|nul|clock\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i;
|
|
84
|
+
|
|
85
|
+
function fail(label) {
|
|
86
|
+
throw new DelegationError(`Snapshot ${label}.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isPrivate(segment) {
|
|
90
|
+
return PRIVATE_DIRECTORY.test(segment) || PRIVATE_SEGMENT.test(segment) || /^\.env(?:\.|$)/i.test(segment);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function samePath(left, right) {
|
|
94
|
+
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function boundedInteger(value, maximum) {
|
|
98
|
+
return Number.isSafeInteger(value) && value >= 1 && value <= maximum;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateRelative(value) {
|
|
102
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4096) {
|
|
103
|
+
fail("path rejected");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || /[\x00-\x1f\x7f:*?]/.test(value)) {
|
|
107
|
+
fail("path rejected");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const parts = value.replaceAll("\\", "/").split("/");
|
|
111
|
+
if (
|
|
112
|
+
parts.length > 128 ||
|
|
113
|
+
parts.some(
|
|
114
|
+
(part) =>
|
|
115
|
+
!part ||
|
|
116
|
+
part === "." ||
|
|
117
|
+
part === ".." ||
|
|
118
|
+
/[. ]$/.test(part) ||
|
|
119
|
+
isPrivate(part) ||
|
|
120
|
+
DEVICE_NAME.test(part),
|
|
121
|
+
)
|
|
122
|
+
) {
|
|
123
|
+
fail("path rejected");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const name = parts.at(-1).toLowerCase();
|
|
127
|
+
if (!TEXT_EXTENSIONS.has(path.posix.extname(name)) && !TEXT_NAMES.has(name)) {
|
|
128
|
+
fail("file type rejected");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return parts.join("/");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function identityEqual(left, right) {
|
|
135
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function fileEqual(left, right) {
|
|
139
|
+
return (
|
|
140
|
+
identityEqual(left, right) &&
|
|
141
|
+
left.size === right.size &&
|
|
142
|
+
left.nlink === right.nlink &&
|
|
143
|
+
left.mtimeNs === right.mtimeNs &&
|
|
144
|
+
left.ctimeNs === right.ctimeNs &&
|
|
145
|
+
left.birthtimeNs === right.birthtimeNs
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function checkedStat(filename, directory) {
|
|
150
|
+
const stat = fs.lstatSync(filename, { bigint: true });
|
|
151
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n)) {
|
|
152
|
+
fail("path rejected");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!samePath(fs.realpathSync.native(filename), filename)) {
|
|
156
|
+
fail("path binding changed");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return stat;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function binding(root, relative) {
|
|
163
|
+
let filename = root;
|
|
164
|
+
const chain = [checkedStat(root, true)];
|
|
165
|
+
const parts = relative.split("/");
|
|
166
|
+
for (const [index, part] of parts.entries()) {
|
|
167
|
+
filename = path.join(filename, part);
|
|
168
|
+
chain.push(checkedStat(filename, index < parts.length - 1));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { filename, chain, stat: chain.at(-1) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function bindingEqual(left, right) {
|
|
175
|
+
return (
|
|
176
|
+
left.chain.length === right.chain.length &&
|
|
177
|
+
left.chain.every((stat, index) =>
|
|
178
|
+
index === left.chain.length - 1
|
|
179
|
+
? fileEqual(stat, right.chain[index])
|
|
180
|
+
: identityEqual(stat, right.chain[index]),
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function hash(bytes) {
|
|
186
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readBoundFile(root, relative, expected, maximum) {
|
|
190
|
+
if (!bindingEqual(expected, binding(root, relative)) || expected.stat.size > BigInt(maximum)) {
|
|
191
|
+
fail("source changed");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// NOFOLLOW protects the final component where supported. Node does not expose
|
|
195
|
+
// portable openat/Windows reparse controls for every ancestor. Identity and
|
|
196
|
+
// canonical-path checks narrow races; this is not a hostile-OS sandbox or an
|
|
197
|
+
// atomic filesystem snapshot. The local filesystem is a trusted boundary.
|
|
198
|
+
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
|
|
199
|
+
let descriptor;
|
|
200
|
+
let bytes;
|
|
201
|
+
try {
|
|
202
|
+
descriptor = fs.openSync(expected.filename, flags);
|
|
203
|
+
const before = fs.fstatSync(descriptor, { bigint: true });
|
|
204
|
+
if (!before.isFile() || before.nlink !== 1n || !fileEqual(before, expected.stat)) {
|
|
205
|
+
fail("source changed");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
bytes = Buffer.alloc(Number(before.size) + 1);
|
|
209
|
+
let count = 0;
|
|
210
|
+
while (count < bytes.length) {
|
|
211
|
+
const received = fs.readSync(descriptor, bytes, count, bytes.length - count, null);
|
|
212
|
+
if (received === 0) {
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
count += received;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (
|
|
220
|
+
count !== Number(before.size) ||
|
|
221
|
+
!fileEqual(before, fs.fstatSync(descriptor, { bigint: true })) ||
|
|
222
|
+
!bindingEqual(expected, binding(root, relative))
|
|
223
|
+
) {
|
|
224
|
+
fail("source changed");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return bytes.subarray(0, count);
|
|
228
|
+
} catch {
|
|
229
|
+
bytes?.fill(0);
|
|
230
|
+
fail("source unavailable or changed");
|
|
231
|
+
} finally {
|
|
232
|
+
if (descriptor !== undefined) {
|
|
233
|
+
fs.closeSync(descriptor);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function textLines(bytes) {
|
|
239
|
+
if (bytes.some((byte) => (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) || byte === 127)) {
|
|
240
|
+
fail("non-text source rejected");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let text;
|
|
244
|
+
try {
|
|
245
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
246
|
+
} catch {
|
|
247
|
+
fail("non-text source rejected");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (text.length === 0) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const lines = text.split(/\r\n|\n|\r/);
|
|
255
|
+
if (lines.at(-1) === "") {
|
|
256
|
+
lines.pop();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return lines;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function responseBytes(value) {
|
|
263
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Capture only parent-selected relative text files; never discover or walk files.
|
|
268
|
+
* Limits may be lowered, never raised. Line results normalize newlines to LF;
|
|
269
|
+
* digests always cover the original bytes. Private-path denial is a conservative
|
|
270
|
+
* filename boundary, not a claim to detect secrets embedded in ordinary sources.
|
|
271
|
+
* Nothing is persisted. Byte buffers are cleared after use; destroy drops string
|
|
272
|
+
* references. JS strings and returned data cannot be securely erased from memory.
|
|
273
|
+
*/
|
|
274
|
+
export function createSnapshot(root, paths, options = {}) {
|
|
275
|
+
const records = [];
|
|
276
|
+
let closed = false;
|
|
277
|
+
let canonicalRoot;
|
|
278
|
+
try {
|
|
279
|
+
if (
|
|
280
|
+
!options ||
|
|
281
|
+
typeof options !== "object" ||
|
|
282
|
+
!boundedInteger(options.maxFiles ?? MAX_FILES, MAX_FILES) ||
|
|
283
|
+
!boundedInteger(options.maxBytes ?? MAX_BYTES, MAX_BYTES)
|
|
284
|
+
) {
|
|
285
|
+
fail("limits rejected");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const maxFiles = options.maxFiles ?? MAX_FILES;
|
|
289
|
+
const maxBytes = options.maxBytes ?? MAX_BYTES;
|
|
290
|
+
if (!Array.isArray(paths) || paths.length > maxFiles) {
|
|
291
|
+
fail("file quota exceeded");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Validate the entire selection before inspecting any selected file.
|
|
295
|
+
const selected = paths.map(validateRelative);
|
|
296
|
+
const keys = selected.map((value) => (process.platform === "win32" ? value.toLowerCase() : value));
|
|
297
|
+
if (new Set(keys).size !== selected.length) {
|
|
298
|
+
fail("duplicate source rejected");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (
|
|
302
|
+
typeof root !== "string" ||
|
|
303
|
+
root.length === 0 ||
|
|
304
|
+
root.length > 4096 ||
|
|
305
|
+
/[\x00-\x1f\x7f]/.test(root) ||
|
|
306
|
+
root.replaceAll("\\", "/").split("/").some(isPrivate)
|
|
307
|
+
) {
|
|
308
|
+
fail("root rejected");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
canonicalRoot = path.resolve(root);
|
|
312
|
+
if (canonicalRoot.replaceAll("\\", "/").split("/").some(isPrivate)) {
|
|
313
|
+
fail("root rejected");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
checkedStat(canonicalRoot, true);
|
|
317
|
+
let totalBytes = 0;
|
|
318
|
+
for (const relative of selected) {
|
|
319
|
+
const original = binding(canonicalRoot, relative);
|
|
320
|
+
if (original.stat.size > BigInt(maxBytes - totalBytes)) {
|
|
321
|
+
fail("byte quota exceeded");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
totalBytes += Number(original.stat.size);
|
|
325
|
+
records.push({ relative, original, digest: undefined, lines: undefined });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for (const record of records) {
|
|
329
|
+
const bytes = readBoundFile(canonicalRoot, record.relative, record.original, maxBytes);
|
|
330
|
+
try {
|
|
331
|
+
record.digest = hash(bytes);
|
|
332
|
+
record.lines = textLines(bytes);
|
|
333
|
+
} finally {
|
|
334
|
+
bytes.fill(0);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Recheck every digest after all captures, then all path/stat bindings.
|
|
339
|
+
// This detects observed drift, without claiming an atomic OS snapshot.
|
|
340
|
+
for (const record of records) {
|
|
341
|
+
const bytes = readBoundFile(canonicalRoot, record.relative, record.original, maxBytes);
|
|
342
|
+
try {
|
|
343
|
+
if (hash(bytes) !== record.digest) {
|
|
344
|
+
fail("source changed");
|
|
345
|
+
}
|
|
346
|
+
} finally {
|
|
347
|
+
bytes.fill(0);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const record of records) {
|
|
352
|
+
if (!bindingEqual(record.original, binding(canonicalRoot, record.relative))) {
|
|
353
|
+
fail("source changed");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
records.length = 0;
|
|
358
|
+
fail("creation rejected");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const sources = Object.freeze(
|
|
362
|
+
records.map((record, index) =>
|
|
363
|
+
Object.freeze({
|
|
364
|
+
id: `s${index + 1}`,
|
|
365
|
+
path: record.relative,
|
|
366
|
+
digest: record.digest,
|
|
367
|
+
bytes: Number(record.original.stat.size),
|
|
368
|
+
lineCount: record.lines.length,
|
|
369
|
+
}),
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
const byId = new Map(sources.map((source, index) => [source.id, records[index]]));
|
|
373
|
+
|
|
374
|
+
function ensureOpen() {
|
|
375
|
+
if (closed) {
|
|
376
|
+
fail("closed");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return Object.freeze({
|
|
381
|
+
sources,
|
|
382
|
+
read(id, startLine = 1, maxLines = 200) {
|
|
383
|
+
ensureOpen();
|
|
384
|
+
const record = byId.get(id);
|
|
385
|
+
if (
|
|
386
|
+
!record ||
|
|
387
|
+
!boundedInteger(startLine, Number.MAX_SAFE_INTEGER) ||
|
|
388
|
+
!boundedInteger(maxLines, 200) ||
|
|
389
|
+
startLine > Math.max(1, record.lines.length)
|
|
390
|
+
) {
|
|
391
|
+
fail("read request rejected");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const requestedEnd = Math.min(record.lines.length, startLine - 1 + maxLines);
|
|
395
|
+
const result = {
|
|
396
|
+
sourceId: id,
|
|
397
|
+
digest: record.digest,
|
|
398
|
+
startLine,
|
|
399
|
+
endLine: startLine - 1,
|
|
400
|
+
text: "",
|
|
401
|
+
truncated: false,
|
|
402
|
+
};
|
|
403
|
+
const lines = [];
|
|
404
|
+
const overhead = responseBytes(result) - String(result.endLine).length;
|
|
405
|
+
let textBytes = 0;
|
|
406
|
+
for (let index = startLine - 1; index < requestedEnd; index += 1) {
|
|
407
|
+
// JSON encodes a separator newline as two bytes. Encode each line once,
|
|
408
|
+
// including escapes and UTF-8, instead of repeatedly encoding its prefix.
|
|
409
|
+
const nextBytes = textBytes + responseBytes(record.lines[index]) - 2 + (lines.length ? 2 : 0);
|
|
410
|
+
if (overhead + String(index + 1).length + nextBytes > MAX_RESPONSE_BYTES) {
|
|
411
|
+
if (index === startLine - 1) {
|
|
412
|
+
fail("line exceeds response quota");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
result.truncated = true;
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
lines.push(record.lines[index]);
|
|
420
|
+
textBytes = nextBytes;
|
|
421
|
+
result.endLine = index + 1;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
result.text = lines.join("\n");
|
|
425
|
+
|
|
426
|
+
return result;
|
|
427
|
+
},
|
|
428
|
+
search(query, limit = 20, allowedIds) {
|
|
429
|
+
ensureOpen();
|
|
430
|
+
if (
|
|
431
|
+
typeof query !== "string" ||
|
|
432
|
+
query.length < 1 ||
|
|
433
|
+
query.length > 200 ||
|
|
434
|
+
/[\x00-\x1f\x7f]/.test(query) ||
|
|
435
|
+
!boundedInteger(limit, 20)
|
|
436
|
+
) {
|
|
437
|
+
fail("search request rejected");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let selectedIds;
|
|
441
|
+
if (allowedIds !== undefined) {
|
|
442
|
+
if (!(allowedIds instanceof Set) || allowedIds.size > byId.size) {
|
|
443
|
+
fail("search scope rejected");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
selectedIds = new Set(allowedIds);
|
|
447
|
+
for (const id of selectedIds) {
|
|
448
|
+
if (!byId.has(id)) {
|
|
449
|
+
fail("search scope rejected");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const results = [];
|
|
455
|
+
let resultBytes = 2;
|
|
456
|
+
for (const [id, record] of byId) {
|
|
457
|
+
if (selectedIds && !selectedIds.has(id)) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const [index, line] of record.lines.entries()) {
|
|
462
|
+
if (!line.includes(query)) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const match = { sourceId: id, line: index + 1, text: line };
|
|
467
|
+
const nextBytes = resultBytes + responseBytes(match) + (results.length ? 1 : 0);
|
|
468
|
+
if (nextBytes > MAX_RESPONSE_BYTES) {
|
|
469
|
+
if (results.length === 0) {
|
|
470
|
+
fail("line exceeds response quota");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return results;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
results.push(match);
|
|
477
|
+
resultBytes = nextBytes;
|
|
478
|
+
if (results.length === limit) {
|
|
479
|
+
return results;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return results;
|
|
485
|
+
},
|
|
486
|
+
assertBindings() {
|
|
487
|
+
try {
|
|
488
|
+
for (const record of records) {
|
|
489
|
+
if (!bindingEqual(record.original, binding(canonicalRoot, record.relative))) {
|
|
490
|
+
fail("source changed");
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} catch {
|
|
494
|
+
fail("source unavailable or changed");
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
assertFresh() {
|
|
498
|
+
try {
|
|
499
|
+
for (const record of records) {
|
|
500
|
+
const bytes = readBoundFile(canonicalRoot, record.relative, record.original, MAX_BYTES);
|
|
501
|
+
try {
|
|
502
|
+
if (hash(bytes) !== record.digest) {
|
|
503
|
+
fail("source changed");
|
|
504
|
+
}
|
|
505
|
+
} finally {
|
|
506
|
+
bytes.fill(0);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
for (const record of records) {
|
|
511
|
+
if (!bindingEqual(record.original, binding(canonicalRoot, record.relative))) {
|
|
512
|
+
fail("source changed");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
} catch {
|
|
516
|
+
fail("source unavailable or changed");
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
destroy() {
|
|
520
|
+
if (!closed) {
|
|
521
|
+
for (const record of records) {
|
|
522
|
+
record.lines.length = 0;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
byId.clear();
|
|
526
|
+
// Retain only identity, path and digest metadata for later receipt checks.
|
|
527
|
+
// Captured text is no longer available after the worker deadline.
|
|
528
|
+
closed = true;
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
}
|