@youdie006/prodex 0.40.50 → 0.40.51
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/browser-send-lock.js +6 -3
- package/dist/chatgpt-browser.js +14 -3
- package/dist/cli-args.js +28 -1
- package/dist/cli-ledger.js +3 -8
- package/dist/cli-pro.js +40 -10
- package/dist/cli.js +66 -21
- package/dist/http-mcp.js +108 -14
- package/dist/mcp.js +86 -39
- package/dist/repo-write.js +25 -6
- package/dist/repo.js +59 -27
- package/dist/safe-file.js +41 -7
- package/dist/store.js +102 -63
- package/dist/tui-run.js +111 -37
- package/dist/tui.js +27 -10
- package/docs/clients.md +11 -0
- package/docs/http-mcp.md +3 -1
- package/docs/releasing.md +38 -0
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -15,7 +15,17 @@ const BridgeFileInputSchema = z.object({
|
|
|
15
15
|
role: z.enum(["context", "artifact", "result"]).optional(),
|
|
16
16
|
bytes: z.number().int().nonnegative().optional()
|
|
17
17
|
});
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Large enough for the largest call the tools accept. That is
|
|
20
|
+
* bridge_block_task with every list full: 100 commands and 100 warnings of
|
|
21
|
+
* 100,000 characters, 100 artifact paths of 10,000 and its text fields, about
|
|
22
|
+
* 21.2 million characters. JSON may write each one as a six-byte \uXXXX escape
|
|
23
|
+
* (a control character is one byte to the handlers' byte checks), so the frame
|
|
24
|
+
* can reach 127.3 MB. The old 1 MiB closed the session on a 1,000,000-character
|
|
25
|
+
* repo_write_file_dry_run that had only its newlines escaped. The limit still
|
|
26
|
+
* caps what a client that never sends a newline can make this process buffer.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_STDIO_MESSAGE_LIMIT_BYTES = 128 * 1024 * 1024;
|
|
19
29
|
function asText(value) {
|
|
20
30
|
return {
|
|
21
31
|
content: [
|
|
@@ -144,7 +154,7 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
144
154
|
inputSchema: { query: McpBridgeTextSchema.min(1), glob: McpShortTextSchema.optional() }
|
|
145
155
|
}, async (input) => asText(await handlers.repo_search(input)));
|
|
146
156
|
server.registerTool("repo_write_file_dry_run", {
|
|
147
|
-
description: "Create a receipt-gated write preview for an existing repo-relative text file. Does not modify files. The file keeps its CRLF line endings
|
|
157
|
+
description: "Create a receipt-gated write preview for an existing repo-relative text file. Does not modify files. The file keeps its CRLF line endings, UTF-8 BOM and final newline when the new content leaves them out, and keeps its file mode. A file that is not UTF-8 text is refused.",
|
|
148
158
|
inputSchema: {
|
|
149
159
|
path: McpShortTextSchema,
|
|
150
160
|
content: z.string().max(1_000_000),
|
|
@@ -234,7 +244,9 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
234
244
|
// Progress delivery must never break the consult.
|
|
235
245
|
});
|
|
236
246
|
};
|
|
237
|
-
|
|
247
|
+
// A client that cancels (Esc, its own timeout) aborts extra.signal.
|
|
248
|
+
// The consult stops on it until the prompt is submitted.
|
|
249
|
+
return asText(withServerVersionNotice(await browserConsult({ ...input, session_key: input.session_key ?? mcpSessionKey }, onProgress, extra.signal), serverVersionNotice()));
|
|
238
250
|
});
|
|
239
251
|
}
|
|
240
252
|
const browserRecover = options.browserRecover;
|
|
@@ -250,9 +262,23 @@ export function createServer(cwd = process.cwd(), options = {}) {
|
|
|
250
262
|
}
|
|
251
263
|
return server;
|
|
252
264
|
}
|
|
253
|
-
export async function runMcpServer(cwd = process.cwd(), options = {}) {
|
|
265
|
+
export async function runMcpServer(cwd = process.cwd(), options = {}, stdio = {}) {
|
|
254
266
|
const server = createServer(cwd, options);
|
|
255
|
-
const transport = new LimitedStdioServerTransport();
|
|
267
|
+
const transport = new LimitedStdioServerTransport(process.stdin, process.stdout, stdio);
|
|
268
|
+
// Set before connect, which chains its own close handler after this one.
|
|
269
|
+
transport.onclose = () => {
|
|
270
|
+
const reason = transport.fatalInputError;
|
|
271
|
+
if (!reason)
|
|
272
|
+
return;
|
|
273
|
+
// The client is still waiting on its requests, and a closed transport
|
|
274
|
+
// answers none of them. Only the process exiting tells it the server is
|
|
275
|
+
// gone; without this it waited out its tool timeout (about 28 hours in
|
|
276
|
+
// Claude Code), or saw a clean exit 0, with nothing said either way.
|
|
277
|
+
process.exitCode = 1;
|
|
278
|
+
const exit = () => process.exit(1);
|
|
279
|
+
setTimeout(exit, 2_000);
|
|
280
|
+
process.stderr.write(`prodex mcp: ${reason.message}; closing the stdio session.\n`, exit);
|
|
281
|
+
};
|
|
256
282
|
await server.connect(transport);
|
|
257
283
|
}
|
|
258
284
|
export class LimitedStdioServerTransport {
|
|
@@ -263,7 +289,13 @@ export class LimitedStdioServerTransport {
|
|
|
263
289
|
onmessage;
|
|
264
290
|
sessionId;
|
|
265
291
|
setProtocolVersion;
|
|
266
|
-
|
|
292
|
+
/** Set when unreadable input closed the transport (an oversize message, a stdin error). */
|
|
293
|
+
fatalInputError;
|
|
294
|
+
// The unfinished message, as the chunks it arrived in. Joining each chunk
|
|
295
|
+
// onto one buffer and rescanning it for a newline made a large message cost
|
|
296
|
+
// time quadratic in its size.
|
|
297
|
+
pending = [];
|
|
298
|
+
pendingBytes = 0;
|
|
267
299
|
started = false;
|
|
268
300
|
closed = false;
|
|
269
301
|
maxMessageBytes;
|
|
@@ -290,7 +322,8 @@ export class LimitedStdioServerTransport {
|
|
|
290
322
|
if (this.stdin.listenerCount("data") === 0) {
|
|
291
323
|
this.stdin.pause();
|
|
292
324
|
}
|
|
293
|
-
this.
|
|
325
|
+
this.pending = [];
|
|
326
|
+
this.pendingBytes = 0;
|
|
294
327
|
this.onclose?.();
|
|
295
328
|
}
|
|
296
329
|
async send(message) {
|
|
@@ -328,50 +361,64 @@ export class LimitedStdioServerTransport {
|
|
|
328
361
|
}
|
|
329
362
|
onData = (chunk) => {
|
|
330
363
|
try {
|
|
331
|
-
|
|
332
|
-
this.buffer = this.buffer.length === 0 ? buffer : Buffer.concat([this.buffer, buffer]);
|
|
333
|
-
this.processBuffer();
|
|
364
|
+
this.processChunk(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
334
365
|
}
|
|
335
366
|
catch (error) {
|
|
336
|
-
this.
|
|
337
|
-
this.onerror?.(error instanceof Error ? error : new Error(String(error)));
|
|
338
|
-
void this.close();
|
|
367
|
+
this.failInput(error instanceof Error ? error : new Error(String(error)));
|
|
339
368
|
}
|
|
340
369
|
};
|
|
341
370
|
onInputError = (error) => {
|
|
371
|
+
this.failInput(error);
|
|
372
|
+
};
|
|
373
|
+
failInput(error) {
|
|
374
|
+
if (this.closed)
|
|
375
|
+
return;
|
|
376
|
+
this.fatalInputError = error;
|
|
342
377
|
this.onerror?.(error);
|
|
343
378
|
void this.close();
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
379
|
+
}
|
|
380
|
+
processChunk(chunk) {
|
|
381
|
+
let start = 0;
|
|
382
|
+
while (!this.closed) {
|
|
383
|
+
const newlineIndex = chunk.indexOf(0x0a, start);
|
|
348
384
|
if (newlineIndex === -1) {
|
|
349
|
-
if (
|
|
350
|
-
|
|
385
|
+
if (start < chunk.length) {
|
|
386
|
+
this.pending.push(chunk.subarray(start));
|
|
387
|
+
this.pendingBytes += chunk.length - start;
|
|
388
|
+
}
|
|
389
|
+
if (this.pendingBytes > this.maxMessageBytes) {
|
|
390
|
+
throw new Error(`MCP stdio message is too large (at least ${this.pendingBytes} bytes, limit ${this.maxMessageBytes} bytes)`);
|
|
351
391
|
}
|
|
352
392
|
return;
|
|
353
393
|
}
|
|
354
|
-
|
|
355
|
-
|
|
394
|
+
const lineBytes = this.pendingBytes + newlineIndex - start;
|
|
395
|
+
if (lineBytes > this.maxMessageBytes) {
|
|
396
|
+
throw new Error(`MCP stdio message is too large (${lineBytes} bytes, limit ${this.maxMessageBytes} bytes)`);
|
|
356
397
|
}
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
398
|
+
const tail = chunk.subarray(start, newlineIndex);
|
|
399
|
+
const lineBuffer = this.pending.length === 0 ? tail : Buffer.concat([...this.pending, tail], lineBytes);
|
|
400
|
+
this.pending = [];
|
|
401
|
+
this.pendingBytes = 0;
|
|
402
|
+
start = newlineIndex + 1;
|
|
403
|
+
this.processLine(lineBuffer.toString("utf8").replace(/\r$/, ""));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
processLine(line) {
|
|
407
|
+
if (line.trim().length === 0)
|
|
408
|
+
return;
|
|
409
|
+
let message;
|
|
410
|
+
try {
|
|
411
|
+
message = JSONRPCMessageSchema.parse(JSON.parse(line));
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
// A single malformed frame is recoverable: report it and keep
|
|
415
|
+
// processing the rest of the buffer instead of tearing down the whole
|
|
416
|
+
// session (which would also drop any valid pipelined messages after
|
|
417
|
+
// it). This matches the SDK's StdioServerTransport. Only the oversize
|
|
418
|
+
// guards above stay fatal.
|
|
419
|
+
this.onerror?.(error instanceof Error ? error : new Error(String(error)));
|
|
420
|
+
return;
|
|
375
421
|
}
|
|
422
|
+
this.onmessage?.(message);
|
|
376
423
|
}
|
|
377
424
|
}
|
package/dist/repo-write.js
CHANGED
|
@@ -4,7 +4,7 @@ import { realpath } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { assertResolvedRepoPathAllowed, resolveRepoPath } from "./repo.js";
|
|
7
|
-
import { readVerifiedUtf8File, replaceVerifiedUtf8File, withCrossProcessFileLock } from "./safe-file.js";
|
|
7
|
+
import { NotUtf8Error, readVerifiedUtf8File, replaceVerifiedUtf8File, withCrossProcessFileLock } from "./safe-file.js";
|
|
8
8
|
const execFileAsync = promisify(execFile);
|
|
9
9
|
const MAX_WRITE_BYTES = 1_000_000;
|
|
10
10
|
let testHooks = {};
|
|
@@ -17,12 +17,19 @@ export function setRepoWriteTestHooks(hooks) {
|
|
|
17
17
|
* a UTF-8 BOM was dropped the same way. Keep what the file already uses, as an
|
|
18
18
|
* editor does: CRLF only when the file uses it throughout and the new content
|
|
19
19
|
* carries no CR of its own, and a BOM only when the new content has none.
|
|
20
|
+
*
|
|
21
|
+
* repo_read_file also returns its lines without a newline after the last one,
|
|
22
|
+
* so writing back what was read dropped the file's final newline (found
|
|
23
|
+
* 2026-11-09). A file that ends with one keeps it unless the new content is
|
|
24
|
+
* empty.
|
|
20
25
|
*/
|
|
21
26
|
export function keepFileConventions(current, next) {
|
|
22
27
|
let content = next;
|
|
23
28
|
const crlf = current.includes("\r\n") && !/(^|[^\r])\n/.test(current);
|
|
24
29
|
if (crlf && !content.includes("\r"))
|
|
25
30
|
content = content.replace(/\n/g, "\r\n");
|
|
31
|
+
if (current.endsWith("\n") && content !== "" && !content.endsWith("\n"))
|
|
32
|
+
content += crlf ? "\r\n" : "\n";
|
|
26
33
|
if (current.startsWith("\uFEFF") && !content.startsWith("\uFEFF"))
|
|
27
34
|
content = `\uFEFF${content}`;
|
|
28
35
|
return content;
|
|
@@ -34,6 +41,11 @@ export async function createRepoWriteDryRun(root, store, input) {
|
|
|
34
41
|
}
|
|
35
42
|
const current = await readWritableExistingFile(root, input.path);
|
|
36
43
|
const content = keepFileConventions(current.content, input.content);
|
|
44
|
+
// Restoring CRLF and a BOM adds bytes, so the limit is checked again on what
|
|
45
|
+
// will be written: content that apply refuses used to pass the dry run.
|
|
46
|
+
if (Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
|
|
47
|
+
throw new Error(`New content is too large to write through repo tools`);
|
|
48
|
+
}
|
|
37
49
|
if (current.content === content) {
|
|
38
50
|
throw new Error(`No changes for ${input.path}`);
|
|
39
51
|
}
|
|
@@ -211,7 +223,10 @@ export async function stageReviewedPaths(root, store, input) {
|
|
|
211
223
|
try {
|
|
212
224
|
await testHooks.beforeGitAdd?.(stagedPaths);
|
|
213
225
|
await assertGitHead(root, input.expected_head);
|
|
214
|
-
|
|
226
|
+
// Literal: read as pathspecs, a reviewed "pages/[id].tsx" also staged an
|
|
227
|
+
// unreviewed "pages/i.tsx" (found 2026-11-09). Every git call here that
|
|
228
|
+
// takes a reviewed path reads it literally.
|
|
229
|
+
await execFileAsync("git", ["--literal-pathspecs", "add", "--", ...stagedPaths], { cwd: root });
|
|
215
230
|
staged = true;
|
|
216
231
|
await verifyStagedContent(root, expectedObjectIdByPath);
|
|
217
232
|
await testHooks.beforeStageReceipt?.(stagedPaths);
|
|
@@ -269,7 +284,7 @@ async function verifyStagedContent(root, expectedObjectIdByPath) {
|
|
|
269
284
|
}
|
|
270
285
|
}
|
|
271
286
|
async function gitObjectIdForStagedPath(root, gitPath) {
|
|
272
|
-
const { stdout } = await execFileAsync("git", ["ls-files", "--stage", "-z", "--", gitPath], {
|
|
287
|
+
const { stdout } = await execFileAsync("git", ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", gitPath], {
|
|
273
288
|
cwd: root,
|
|
274
289
|
maxBuffer: MAX_WRITE_BYTES + 1024
|
|
275
290
|
});
|
|
@@ -283,7 +298,7 @@ async function gitObjectIdForStagedPath(root, gitPath) {
|
|
|
283
298
|
async function readGitIndexEntries(root, paths) {
|
|
284
299
|
const entries = new Map();
|
|
285
300
|
for (const gitPath of paths) {
|
|
286
|
-
const { stdout } = await execFileAsync("git", ["ls-files", "--stage", "-z", "--", gitPath], {
|
|
301
|
+
const { stdout } = await execFileAsync("git", ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", gitPath], {
|
|
287
302
|
cwd: root,
|
|
288
303
|
encoding: "buffer",
|
|
289
304
|
maxBuffer: MAX_WRITE_BYTES + 1024
|
|
@@ -308,7 +323,7 @@ async function restoreGitIndexEntries(root, entries) {
|
|
|
308
323
|
await execFileAsync("git", ["update-index", "--add", "--cacheinfo", entry.mode, entry.objectId, gitPath], { cwd: root });
|
|
309
324
|
}
|
|
310
325
|
else {
|
|
311
|
-
await execFileAsync("git", ["rm", "--cached", "--ignore-unmatch", "--", gitPath], { cwd: root });
|
|
326
|
+
await execFileAsync("git", ["--literal-pathspecs", "rm", "--cached", "--ignore-unmatch", "--", gitPath], { cwd: root });
|
|
312
327
|
}
|
|
313
328
|
}
|
|
314
329
|
}
|
|
@@ -375,11 +390,15 @@ async function readWritableExistingFile(root, repoPath) {
|
|
|
375
390
|
const resolved = resolveRepoPath(root, repoPath);
|
|
376
391
|
await assertResolvedRepoPathAllowed(root, resolved, repoPath);
|
|
377
392
|
const content = await readVerifiedUtf8File(resolved, () => assertResolvedRepoPathAllowed(root, resolved, repoPath), {
|
|
378
|
-
maxBytes: MAX_WRITE_BYTES
|
|
393
|
+
maxBytes: MAX_WRITE_BYTES,
|
|
394
|
+
requireUtf8: true
|
|
379
395
|
}).catch((error) => {
|
|
380
396
|
if (error instanceof Error && /too large/.test(error.message)) {
|
|
381
397
|
throw new Error(`Path ${repoPath} is too large to write through repo tools`);
|
|
382
398
|
}
|
|
399
|
+
if (error instanceof NotUtf8Error) {
|
|
400
|
+
throw new Error(`Path ${repoPath} is not UTF-8 text. Repo tools write UTF-8 only, so writing it would change its encoding; edit it with a tool that keeps its encoding.`);
|
|
401
|
+
}
|
|
383
402
|
throw error;
|
|
384
403
|
});
|
|
385
404
|
return { resolved, content };
|
package/dist/repo.js
CHANGED
|
@@ -57,11 +57,16 @@ export function assertRepoRelativePath(repoPath) {
|
|
|
57
57
|
}
|
|
58
58
|
assertNotSensitiveRepoPath(normalized);
|
|
59
59
|
}
|
|
60
|
+
// "..notes.md" is a file in the root; only ".." itself or a path through it
|
|
61
|
+
// leaves it.
|
|
62
|
+
function escapesRoot(relative) {
|
|
63
|
+
return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
|
64
|
+
}
|
|
60
65
|
export function resolveRepoPath(root, repoPath) {
|
|
61
66
|
assertRepoRelativePath(repoPath);
|
|
62
67
|
const resolved = path.resolve(root, repoPath);
|
|
63
68
|
const relative = path.relative(root, resolved);
|
|
64
|
-
if (
|
|
69
|
+
if (escapesRoot(relative)) {
|
|
65
70
|
throw new Error(`Path ${repoPath} escapes the repository root`);
|
|
66
71
|
}
|
|
67
72
|
return resolved;
|
|
@@ -103,6 +108,9 @@ export async function searchRepoWithMetadata(root, query, glob) {
|
|
|
103
108
|
if (!query.trim()) {
|
|
104
109
|
throw new Error("Search query must not be empty");
|
|
105
110
|
}
|
|
111
|
+
if (/[\r\n]/.test(query)) {
|
|
112
|
+
throw new Error("repo_search matches one line at a time, so a query cannot contain a line break; search for one of its lines.");
|
|
113
|
+
}
|
|
106
114
|
if (glob) {
|
|
107
115
|
assertRepoRelativeGlob(glob);
|
|
108
116
|
}
|
|
@@ -110,12 +118,20 @@ export async function searchRepoWithMetadata(root, query, glob) {
|
|
|
110
118
|
"--no-config",
|
|
111
119
|
"--no-follow",
|
|
112
120
|
"--json",
|
|
121
|
+
// repo_read_file reads .github/, .eslintrc and the like, and without this
|
|
122
|
+
// ripgrep skipped every hidden file. Results still pass the same path
|
|
123
|
+
// policy as reads, and the globs below keep the sensitive ones unopened.
|
|
124
|
+
"--hidden",
|
|
113
125
|
...(glob ? ["--glob", glob] : []),
|
|
114
126
|
"--glob",
|
|
115
127
|
"!.bridge/**",
|
|
116
128
|
"--glob",
|
|
117
129
|
"!**/.bridge/**",
|
|
118
130
|
"--glob",
|
|
131
|
+
"!.git",
|
|
132
|
+
"--glob",
|
|
133
|
+
"!**/.git",
|
|
134
|
+
"--glob",
|
|
119
135
|
"!.git/**",
|
|
120
136
|
"--glob",
|
|
121
137
|
"!**/.git/**",
|
|
@@ -154,23 +170,7 @@ export async function searchRepoWithMetadata(root, query, glob) {
|
|
|
154
170
|
timeout: 10_000,
|
|
155
171
|
maxBuffer: 1024 * 1024
|
|
156
172
|
});
|
|
157
|
-
|
|
158
|
-
.split("\n")
|
|
159
|
-
.filter(Boolean)
|
|
160
|
-
.map(parseRipgrepJsonMatch)
|
|
161
|
-
.filter((match) => match !== undefined);
|
|
162
|
-
const allowedMatches = [];
|
|
163
|
-
let truncated = false;
|
|
164
|
-
for (const match of parsedMatches) {
|
|
165
|
-
if (await isAllowedRepoSearchResult(root, match.path)) {
|
|
166
|
-
if (allowedMatches.length >= MAX_REPO_SEARCH_MATCHES) {
|
|
167
|
-
truncated = true;
|
|
168
|
-
break;
|
|
169
|
-
}
|
|
170
|
-
allowedMatches.push(match);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return { matches: allowedMatches, truncated, limit: MAX_REPO_SEARCH_MATCHES };
|
|
173
|
+
return await allowedSearchResult(root, stdout, false);
|
|
174
174
|
}
|
|
175
175
|
catch (error) {
|
|
176
176
|
const maybe = error;
|
|
@@ -178,10 +178,23 @@ export async function searchRepoWithMetadata(root, query, glob) {
|
|
|
178
178
|
throw new Error("ripgrep (rg) is required on PATH for repo_search");
|
|
179
179
|
}
|
|
180
180
|
if (isSearchOutputTooLarge(error)) {
|
|
181
|
+
// A broad query outgrew the buffer: return the matches read so far,
|
|
182
|
+
// marked truncated, rather than none.
|
|
183
|
+
const partial = await allowedSearchResult(root, maybe.stdout ?? "", true);
|
|
184
|
+
if (partial.matches.length > 0)
|
|
185
|
+
return partial;
|
|
181
186
|
throw new Error("repo_search returned too many matches; narrow the query or glob and try again");
|
|
182
187
|
}
|
|
183
188
|
if (maybe.code === 1)
|
|
184
189
|
return { matches: [], truncated: false, limit: MAX_REPO_SEARCH_MATCHES };
|
|
190
|
+
// ripgrep exits 2 when it could not read some path (a directory it may
|
|
191
|
+
// not enter, say) but still prints what it found elsewhere; that used to
|
|
192
|
+
// fail the whole search with the raw command line.
|
|
193
|
+
if (maybe.code === 2 && !ripgrepRegexError(error.stderr)) {
|
|
194
|
+
const partial = await allowedSearchResult(root, maybe.stdout ?? "", false);
|
|
195
|
+
if (partial.matches.length > 0)
|
|
196
|
+
return partial;
|
|
197
|
+
}
|
|
185
198
|
// An invalid pattern used to surface as "Command failed: <rg path> ..."
|
|
186
199
|
// with rg's parse error buried in it (measured 2026-10-21, query "(").
|
|
187
200
|
const reason = ripgrepRegexError(error.stderr);
|
|
@@ -191,6 +204,24 @@ export async function searchRepoWithMetadata(root, query, glob) {
|
|
|
191
204
|
throw error;
|
|
192
205
|
}
|
|
193
206
|
}
|
|
207
|
+
async function allowedSearchResult(root, stdout, truncated) {
|
|
208
|
+
const parsedMatches = stdout
|
|
209
|
+
.split("\n")
|
|
210
|
+
.filter(Boolean)
|
|
211
|
+
.map(parseRipgrepJsonMatch)
|
|
212
|
+
.filter((match) => match !== undefined);
|
|
213
|
+
const allowedMatches = [];
|
|
214
|
+
for (const match of parsedMatches) {
|
|
215
|
+
if (await isAllowedRepoSearchResult(root, match.path)) {
|
|
216
|
+
if (allowedMatches.length >= MAX_REPO_SEARCH_MATCHES) {
|
|
217
|
+
truncated = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
allowedMatches.push(match);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { matches: allowedMatches, truncated, limit: MAX_REPO_SEARCH_MATCHES };
|
|
224
|
+
}
|
|
194
225
|
function ripgrepRegexError(stderr) {
|
|
195
226
|
if (!stderr || !/regex parse error/i.test(stderr))
|
|
196
227
|
return undefined;
|
|
@@ -208,11 +239,17 @@ function parseRipgrepJsonMatch(line) {
|
|
|
208
239
|
if (!isRipgrepJsonMatch(event))
|
|
209
240
|
return undefined;
|
|
210
241
|
return {
|
|
211
|
-
path: normalizeRipgrepMatchPath(event.data.path
|
|
242
|
+
path: normalizeRipgrepMatchPath(ripgrepText(event.data.path)),
|
|
212
243
|
line: event.data.line_number,
|
|
213
|
-
text: stripOneTrailingLineEnding(event.data.lines
|
|
244
|
+
text: stripOneTrailingLineEnding(ripgrepText(event.data.lines))
|
|
214
245
|
};
|
|
215
246
|
}
|
|
247
|
+
function ripgrepText(value) {
|
|
248
|
+
return "text" in value ? value.text : Buffer.from(value.bytes, "base64").toString("utf8");
|
|
249
|
+
}
|
|
250
|
+
function isRipgrepJsonText(value) {
|
|
251
|
+
return isRecord(value) && (typeof value.text === "string" || typeof value.bytes === "string");
|
|
252
|
+
}
|
|
216
253
|
function normalizeRipgrepMatchPath(matchPath) {
|
|
217
254
|
return matchPath.split(path.sep).join("/").replace(/^\.\//, "");
|
|
218
255
|
}
|
|
@@ -220,12 +257,7 @@ function isRipgrepJsonMatch(value) {
|
|
|
220
257
|
if (!isRecord(value) || value.type !== "match" || !isRecord(value.data))
|
|
221
258
|
return false;
|
|
222
259
|
const { path: matchPath, line_number: lineNumber, lines } = value.data;
|
|
223
|
-
return (
|
|
224
|
-
typeof matchPath.text === "string" &&
|
|
225
|
-
typeof lineNumber === "number" &&
|
|
226
|
-
Number.isFinite(lineNumber) &&
|
|
227
|
-
isRecord(lines) &&
|
|
228
|
-
typeof lines.text === "string");
|
|
260
|
+
return isRipgrepJsonText(matchPath) && typeof lineNumber === "number" && Number.isFinite(lineNumber) && isRipgrepJsonText(lines);
|
|
229
261
|
}
|
|
230
262
|
function isRecord(value) {
|
|
231
263
|
return typeof value === "object" && value !== null;
|
|
@@ -494,7 +526,7 @@ export async function assertResolvedRepoPathAllowed(root, resolved, repoPath) {
|
|
|
494
526
|
throw error;
|
|
495
527
|
}
|
|
496
528
|
const relative = path.relative(realRoot, realTarget);
|
|
497
|
-
if (
|
|
529
|
+
if (escapesRoot(relative)) {
|
|
498
530
|
throw new Error(`Path ${repoPath} escapes the repository root after resolving symlinks`);
|
|
499
531
|
}
|
|
500
532
|
if (relative) {
|
package/dist/safe-file.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isUtf8 } from "node:buffer";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { constants, readFileSync } from "node:fs";
|
|
3
4
|
import { chmod, link, lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
|
|
@@ -20,6 +21,8 @@ export async function withCrossProcessFileLock(filePath, options, fn) {
|
|
|
20
21
|
let retriedMissingAtDeadline = false;
|
|
21
22
|
let lease;
|
|
22
23
|
for (;;) {
|
|
24
|
+
if (options.signal?.aborted)
|
|
25
|
+
throw options.abortedError?.() ?? new Error("Stopped waiting for the lock: the caller cancelled");
|
|
23
26
|
lease = await tryAcquireFileLock(filePath, parentSnapshot);
|
|
24
27
|
if (lease)
|
|
25
28
|
break;
|
|
@@ -49,7 +52,7 @@ export async function withCrossProcessFileLock(filePath, options, fn) {
|
|
|
49
52
|
waited = true;
|
|
50
53
|
options.onWait?.(snapshot.holder);
|
|
51
54
|
}
|
|
52
|
-
await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())));
|
|
55
|
+
await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())), options.signal);
|
|
53
56
|
}
|
|
54
57
|
try {
|
|
55
58
|
return await fn();
|
|
@@ -58,6 +61,18 @@ export async function withCrossProcessFileLock(filePath, options, fn) {
|
|
|
58
61
|
await releaseFileLockLease(filePath, lease, parentSnapshot);
|
|
59
62
|
}
|
|
60
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* A file whose bytes are not UTF-8. Read as UTF-8, such bytes become U+FFFD,
|
|
66
|
+
* and writing the text back destroys them: measured 2026-11-09, an EUC-KR
|
|
67
|
+
* "한글" line came back as replacement characters through repo writes while
|
|
68
|
+
* the diff showed the line as unchanged. Writers ask for it to be refused.
|
|
69
|
+
*/
|
|
70
|
+
export class NotUtf8Error extends Error {
|
|
71
|
+
constructor() {
|
|
72
|
+
super("Target file is not UTF-8 text");
|
|
73
|
+
this.name = "NotUtf8Error";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
61
76
|
export async function readVerifiedUtf8File(filePath, validate, options = {}) {
|
|
62
77
|
await validate();
|
|
63
78
|
const parentSnapshot = await captureParentSnapshot(filePath);
|
|
@@ -72,7 +87,7 @@ export async function readVerifiedUtf8File(filePath, validate, options = {}) {
|
|
|
72
87
|
if (options.maxBytes !== undefined && stat.size > options.maxBytes) {
|
|
73
88
|
throw new Error(`Target file is too large (${stat.size} bytes)`);
|
|
74
89
|
}
|
|
75
|
-
const content = await readHandleUtf8(handle, filePath, options.maxBytes, options.decodeUtf16Bom === true);
|
|
90
|
+
const content = await readHandleUtf8(handle, filePath, options.maxBytes, options.decodeUtf16Bom === true, options.requireUtf8 === true);
|
|
76
91
|
if (options.mode !== undefined) {
|
|
77
92
|
await testHooks.beforeChmod?.(filePath, "read");
|
|
78
93
|
await assertSafeOpenFile(handle, filePath, "read");
|
|
@@ -127,19 +142,24 @@ export async function replaceVerifiedUtf8File(filePath, content, validate, verif
|
|
|
127
142
|
const parentSnapshot = await captureParentSnapshot(filePath);
|
|
128
143
|
await testHooks.beforeOpen?.(filePath, "write");
|
|
129
144
|
const handle = await openStableNoFollow(filePath, constants.O_RDWR, "write", parentSnapshot, options.mode);
|
|
145
|
+
// The replacement is a new file renamed into place, so it takes the mode of
|
|
146
|
+
// the file it replaces unless one is given: created with the default mode,
|
|
147
|
+
// an edited script lost its executable bit (found 2026-11-09).
|
|
148
|
+
let mode = options.mode;
|
|
130
149
|
try {
|
|
131
150
|
const currentContent = await readHandleUtf8(handle, filePath, options.maxBytes);
|
|
132
151
|
await verifyCurrentContent(currentContent);
|
|
133
152
|
await testHooks.beforeReplace?.(filePath);
|
|
134
153
|
const latestContent = await readHandleUtf8(handle, filePath, options.maxBytes);
|
|
135
154
|
await verifyCurrentContent(latestContent);
|
|
155
|
+
mode ??= (await handle.stat()).mode & 0o777;
|
|
136
156
|
await testHooks.beforeWrite?.(filePath, "write");
|
|
137
157
|
}
|
|
138
158
|
finally {
|
|
139
159
|
await handle.close();
|
|
140
160
|
}
|
|
141
161
|
// Windows cannot replace some files while their verification handle is open.
|
|
142
|
-
await replaceByVerifiedTempFile(filePath, content, validate, options, {
|
|
162
|
+
await replaceByVerifiedTempFile(filePath, content, validate, { ...options, mode }, {
|
|
143
163
|
beforeOpenAlreadyRan: true,
|
|
144
164
|
beforeWriteAlreadyRan: true,
|
|
145
165
|
parentSnapshot,
|
|
@@ -186,7 +206,7 @@ async function replaceByVerifiedTempFile(filePath, content, validate, options =
|
|
|
186
206
|
throw error;
|
|
187
207
|
}
|
|
188
208
|
}
|
|
189
|
-
async function readHandleUtf8(handle, filePath, maxBytes, decodeUtf16Bom = false) {
|
|
209
|
+
async function readHandleUtf8(handle, filePath, maxBytes, decodeUtf16Bom = false, requireUtf8 = false) {
|
|
190
210
|
await assertOpenFileMatchesPath(handle, filePath, "read");
|
|
191
211
|
const stat = await handle.stat();
|
|
192
212
|
if (!stat.isFile()) {
|
|
@@ -207,7 +227,10 @@ async function readHandleUtf8(handle, filePath, maxBytes, decodeUtf16Bom = false
|
|
|
207
227
|
throw new Error(`Target file is too large (${offset} bytes)`);
|
|
208
228
|
}
|
|
209
229
|
}
|
|
210
|
-
|
|
230
|
+
const bytes = buffer.subarray(0, offset);
|
|
231
|
+
if (requireUtf8 && !isUtf8(bytes))
|
|
232
|
+
throw new NotUtf8Error();
|
|
233
|
+
return decodeFileText(bytes, decodeUtf16Bom);
|
|
211
234
|
}
|
|
212
235
|
/**
|
|
213
236
|
* UTF-8, or UTF-16 when asked and the file starts with its BOM. Windows
|
|
@@ -641,8 +664,19 @@ function linuxProcessStartTicks(pid) {
|
|
|
641
664
|
return undefined;
|
|
642
665
|
}
|
|
643
666
|
}
|
|
644
|
-
|
|
645
|
-
|
|
667
|
+
/** Resolves after ms, or as soon as signal aborts. */
|
|
668
|
+
function delay(ms, signal) {
|
|
669
|
+
if (!signal)
|
|
670
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
671
|
+
return new Promise((resolve) => {
|
|
672
|
+
const done = () => {
|
|
673
|
+
clearTimeout(timer);
|
|
674
|
+
signal.removeEventListener("abort", done);
|
|
675
|
+
resolve();
|
|
676
|
+
};
|
|
677
|
+
const timer = setTimeout(done, ms);
|
|
678
|
+
signal.addEventListener("abort", done, { once: true });
|
|
679
|
+
});
|
|
646
680
|
}
|
|
647
681
|
function procFdPath(fd) {
|
|
648
682
|
return `/proc/self/fd/${fd}`;
|