@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/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
- export const DEFAULT_STDIO_MESSAGE_LIMIT_BYTES = 1_048_576;
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 and UTF-8 BOM when the new content leaves them out.",
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
- return asText(withServerVersionNotice(await browserConsult({ ...input, session_key: input.session_key ?? mcpSessionKey }, onProgress), serverVersionNotice()));
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
- buffer = Buffer.alloc(0);
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.buffer = Buffer.alloc(0);
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
- const buffer = Buffer.from(chunk);
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.buffer = Buffer.alloc(0);
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
- processBuffer() {
346
- while (true) {
347
- const newlineIndex = this.buffer.indexOf("\n");
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 (this.buffer.length > this.maxMessageBytes) {
350
- throw new Error(`MCP stdio message is too large (${this.buffer.length} bytes > ${this.maxMessageBytes} bytes)`);
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
- if (newlineIndex > this.maxMessageBytes) {
355
- throw new Error(`MCP stdio message is too large (${newlineIndex} bytes > ${this.maxMessageBytes} bytes)`);
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 line = this.buffer.toString("utf8", 0, newlineIndex).replace(/\r$/, "");
358
- this.buffer = this.buffer.subarray(newlineIndex + 1);
359
- if (line.trim().length === 0)
360
- continue;
361
- let message;
362
- try {
363
- message = JSONRPCMessageSchema.parse(JSON.parse(line));
364
- }
365
- catch (error) {
366
- // A single malformed frame is recoverable: report it and keep
367
- // processing the rest of the buffer instead of tearing down the whole
368
- // session (which would also drop any valid pipelined messages after
369
- // it). This matches the SDK's StdioServerTransport. Only the oversize
370
- // guards above stay fatal.
371
- this.onerror?.(error instanceof Error ? error : new Error(String(error)));
372
- continue;
373
- }
374
- this.onmessage?.(message);
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
  }
@@ -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
- await execFileAsync("git", ["add", "--", ...stagedPaths], { cwd: root });
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 (relative.startsWith("..") || path.isAbsolute(relative)) {
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
- const parsedMatches = stdout
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.text),
242
+ path: normalizeRipgrepMatchPath(ripgrepText(event.data.path)),
212
243
  line: event.data.line_number,
213
- text: stripOneTrailingLineEnding(event.data.lines.text)
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 (isRecord(matchPath) &&
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 (relative.startsWith("..") || path.isAbsolute(relative)) {
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
- return decodeFileText(buffer.subarray(0, offset), decodeUtf16Bom);
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
- function delay(ms) {
645
- return new Promise((resolve) => setTimeout(resolve, ms));
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}`;