@youdie006/prodex 0.40.49 → 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/http-mcp.js CHANGED
@@ -6,11 +6,101 @@ import { createServer as createMcpServer } from "./mcp.js";
6
6
  import { normalizeLoopbackHttpHost } from "./config.js";
7
7
  const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 1_048_576;
8
8
  const TRANSPORT_CLOSE_TIMEOUT_MS = 5_000;
9
+ /**
10
+ * Every initialize used to keep its MCP server and transport until a DELETE
11
+ * that clients rarely send, about 170 KB each: measured 2026-11-09, RSS went
12
+ * from 92 MB to 1 GB over 5,000 initializes. The bridge tools answer in
13
+ * seconds, so a session with no request for half an hour and no stream open
14
+ * has been left behind; a client that does come back gets 404, which MCP
15
+ * clients answer by initializing again, at the cost of one round trip.
16
+ */
17
+ const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60_000;
18
+ /**
19
+ * A personal server sees a few clients at a time (a ChatGPT project, Codex, a
20
+ * Claude session), so 100 live sessions is far above real use while holding
21
+ * at most about 17 MB, also against a client that initializes in a loop.
22
+ */
23
+ const DEFAULT_MAX_SESSIONS = 100;
24
+ const SESSION_SWEEP_INTERVAL_MAX_MS = 60_000;
25
+ /**
26
+ * The live MCP sessions, kept in least-recently-used order. A session is in
27
+ * use while a request of its own is open (a GET stream stays open for as long
28
+ * as the client listens), and idle from when its last request ended.
29
+ */
30
+ class HttpMcpSessions {
31
+ limits;
32
+ entries = new Map();
33
+ constructor(limits) {
34
+ this.limits = limits;
35
+ }
36
+ /** The session's transport, counted as in use until res closes; undefined when unknown or closed. */
37
+ use(sessionId, res) {
38
+ const entry = this.entries.get(sessionId);
39
+ if (!entry)
40
+ return undefined;
41
+ this.entries.delete(sessionId);
42
+ this.entries.set(sessionId, entry);
43
+ entry.openRequests += 1;
44
+ entry.lastActiveMs = this.limits.now();
45
+ res.once("close", () => {
46
+ entry.openRequests -= 1;
47
+ entry.lastActiveMs = this.limits.now();
48
+ });
49
+ return entry.transport;
50
+ }
51
+ add(sessionId, transport) {
52
+ this.entries.set(sessionId, { transport, lastActiveMs: this.limits.now(), openRequests: 0 });
53
+ while (this.entries.size > this.limits.maxSessions) {
54
+ // The least recently used session without an open request, else the
55
+ // least recently used of all; never the one just added, which is last.
56
+ let victim;
57
+ for (const [id, entry] of this.entries) {
58
+ if (entry.openRequests === 0) {
59
+ victim = id;
60
+ break;
61
+ }
62
+ }
63
+ this.close(victim ?? this.entries.keys().next().value);
64
+ }
65
+ }
66
+ remove(sessionId, transport) {
67
+ if (this.entries.get(sessionId)?.transport === transport)
68
+ this.entries.delete(sessionId);
69
+ }
70
+ sweep() {
71
+ const now = this.limits.now();
72
+ for (const [id, entry] of this.entries) {
73
+ if (entry.openRequests === 0 && now - entry.lastActiveMs >= this.limits.idleTimeoutMs)
74
+ this.close(id);
75
+ }
76
+ }
77
+ async closeAll() {
78
+ const transports = Array.from(this.entries.values(), (entry) => entry.transport);
79
+ this.entries.clear();
80
+ await Promise.all(transports.map((transport) => closeTransport(transport)));
81
+ }
82
+ /** Forgotten at once, so the id answers 404 like any unknown session. */
83
+ close(sessionId) {
84
+ const entry = this.entries.get(sessionId);
85
+ if (!entry)
86
+ return;
87
+ this.entries.delete(sessionId);
88
+ void closeTransport(entry.transport);
89
+ }
90
+ }
91
+ function closeTransport(transport) {
92
+ return withTimeout(transport.close(), TRANSPORT_CLOSE_TIMEOUT_MS, "timed out closing HTTP MCP transport").catch(() => undefined);
93
+ }
9
94
  export async function startHttpMcpServer(options) {
10
95
  const host = normalizeLoopbackHttpHost(options.host ?? "127.0.0.1");
11
96
  const token = options.token;
12
97
  const requestBodyLimitBytes = options.requestBodyLimitBytes ?? DEFAULT_REQUEST_BODY_LIMIT_BYTES;
13
- const transports = new Map();
98
+ const sessionIdleTimeoutMs = Math.max(1, options.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS);
99
+ const sessions = new HttpMcpSessions({
100
+ idleTimeoutMs: sessionIdleTimeoutMs,
101
+ maxSessions: Math.max(1, options.maxSessions ?? DEFAULT_MAX_SESSIONS),
102
+ now: options.now ?? Date.now
103
+ });
14
104
  const server = http.createServer(async (req, res) => {
15
105
  try {
16
106
  // Only the path and query are read, so the base is fixed: taking it from
@@ -41,11 +131,11 @@ export async function startHttpMcpServer(options) {
41
131
  return;
42
132
  }
43
133
  if (req.method === "POST") {
44
- await handlePost(req, res, transports, options.cwd, requestBodyLimitBytes, options.registerRoot);
134
+ await handlePost(req, res, sessions, options.cwd, requestBodyLimitBytes, options.registerRoot);
45
135
  return;
46
136
  }
47
137
  if (req.method === "GET" || req.method === "DELETE") {
48
- await handleSessionRequest(req, res, transports);
138
+ await handleSessionRequest(req, res, sessions);
49
139
  return;
50
140
  }
51
141
  writeJson(res, 405, { error: "method_not_allowed" });
@@ -80,6 +170,9 @@ export async function startHttpMcpServer(options) {
80
170
  resolve();
81
171
  });
82
172
  });
173
+ // Unref'd: an idle sweep is no reason for the process to stay up.
174
+ const sweepTimer = setInterval(() => sessions.sweep(), Math.min(SESSION_SWEEP_INTERVAL_MAX_MS, sessionIdleTimeoutMs));
175
+ sweepTimer.unref();
83
176
  const address = server.address();
84
177
  const port = typeof address === "object" && address ? address.port : options.port ?? 8787;
85
178
  const url = `http://${host}:${port}`;
@@ -89,8 +182,8 @@ export async function startHttpMcpServer(options) {
89
182
  url,
90
183
  mcp_url: token ? `${url}/mcp?prodex_token=${encodeURIComponent(token)}` : `${url}/mcp`,
91
184
  close: async (closeOptions = {}) => {
92
- await Promise.all(Array.from(transports.values()).map((entry) => withTimeout(entry.transport.close(), TRANSPORT_CLOSE_TIMEOUT_MS, "timed out closing HTTP MCP transport").catch(() => undefined)));
93
- transports.clear();
185
+ clearInterval(sweepTimer);
186
+ await sessions.closeAll();
94
187
  await closeHttpServer(server, closeOptions);
95
188
  }
96
189
  };
@@ -140,16 +233,16 @@ async function withTimeout(promise, timeoutMs, message) {
140
233
  clearTimeout(timeout);
141
234
  }
142
235
  }
143
- async function handlePost(req, res, transports, cwd, requestBodyLimitBytes, registerRoot) {
236
+ async function handlePost(req, res, sessions, cwd, requestBodyLimitBytes, registerRoot) {
144
237
  const body = await readJsonBody(req, requestBodyLimitBytes);
145
238
  const sessionId = headerValue(req.headers["mcp-session-id"]);
146
239
  if (sessionId) {
147
- const entry = transports.get(sessionId);
148
- if (!entry) {
240
+ const transport = sessions.use(sessionId, res);
241
+ if (!transport) {
149
242
  writeJson(res, 404, { error: "session_not_found" });
150
243
  return;
151
244
  }
152
- await entry.transport.handleRequest(req, res, body);
245
+ await transport.handleRequest(req, res, body);
153
246
  return;
154
247
  }
155
248
  if (!sessionId && isInitializeRequest(body)) {
@@ -159,13 +252,13 @@ async function handlePost(req, res, transports, cwd, requestBodyLimitBytes, regi
159
252
  enableJsonResponse: true,
160
253
  onsessioninitialized: (newSessionId) => {
161
254
  initializedSessionId = newSessionId;
162
- transports.set(newSessionId, { transport });
255
+ sessions.add(newSessionId, transport);
163
256
  }
164
257
  });
165
258
  transport.onclose = () => {
166
259
  const id = transport.sessionId || initializedSessionId;
167
260
  if (id)
168
- transports.delete(id);
261
+ sessions.remove(id, transport);
169
262
  };
170
263
  const mcpServer = createMcpServer(cwd, {
171
264
  source: "chatgpt_project",
@@ -182,17 +275,18 @@ async function handlePost(req, res, transports, cwd, requestBodyLimitBytes, regi
182
275
  id: null
183
276
  });
184
277
  }
185
- async function handleSessionRequest(req, res, transports) {
278
+ async function handleSessionRequest(req, res, sessions) {
186
279
  const sessionId = headerValue(req.headers["mcp-session-id"]);
187
280
  if (!sessionId) {
188
281
  writeJson(res, 400, { error: "invalid_or_missing_session" });
189
282
  return;
190
283
  }
191
- if (!transports.has(sessionId)) {
284
+ const transport = sessions.use(sessionId, res);
285
+ if (!transport) {
192
286
  writeJson(res, 404, { error: "session_not_found" });
193
287
  return;
194
288
  }
195
- await transports.get(sessionId).transport.handleRequest(req, res);
289
+ await transport.handleRequest(req, res);
196
290
  }
197
291
  async function readJsonBody(req, limitBytes) {
198
292
  const contentLength = parseContentLength(req.headers["content-length"]);
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) {