@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/browser-send-lock.js +6 -3
- package/dist/bundle.js +3 -1
- package/dist/chatgpt-browser.js +32 -7
- package/dist/cli-args.js +28 -1
- package/dist/cli-ledger.js +3 -8
- package/dist/cli-pro.js +65 -20
- package/dist/cli.js +66 -21
- package/dist/followup-budget.js +24 -2
- 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 +20 -4
- package/docs/http-mcp.md +3 -1
- package/docs/releasing.md +139 -0
- package/package.json +1 -1
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
93
|
-
|
|
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,
|
|
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
|
|
148
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|