@remcp/runtime 0.2.108 → 0.2.109

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.
@@ -1,8 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import { constants } from 'node:fs';
3
- import { open, readdir, stat } from 'node:fs/promises';
3
+ import { readdir } from 'node:fs/promises';
4
4
  import { spawn, spawnSync } from 'node:child_process';
5
- import { runtimeConfig } from '../config.mjs';
5
+ import { openDirectoryPath, openRegularFile } from './files.mjs';
6
6
  import { countEvent, recordEvent } from '../telemetry.mjs';
7
7
  import {
8
8
  appendSearchResults,
@@ -76,6 +76,12 @@ function rememberSearchWarning(session, warning) {
76
76
  session.warning = warning instanceof Error ? warning.message : String(warning);
77
77
  }
78
78
 
79
+ function displayRipgrepLine(line, target) {
80
+ if (line === target.anchor) return target.displayPath;
81
+ if (line.startsWith(`${target.anchor}/`)) return `${target.displayPath}${line.slice(target.anchor.length)}`;
82
+ return line;
83
+ }
84
+
79
85
  function ripgrepTraversalWarnings(stderr, target) {
80
86
  const lines = String(stderr || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
81
87
  if (!lines.length) return null;
@@ -90,19 +96,50 @@ function ripgrepTraversalWarnings(stderr, target) {
90
96
  return lines;
91
97
  }
92
98
 
93
- async function walk(target, options, onFile, isRoot = true) {
94
- let info;
99
+ const SEARCH_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
100
+
101
+ function onceAsync(callback) {
102
+ let called = false;
103
+ return async () => {
104
+ if (called) return;
105
+ called = true;
106
+ await callback();
107
+ };
108
+ }
109
+
110
+ async function openSearchTarget(absolute) {
111
+ let directory;
112
+ let directoryError;
95
113
  try {
96
- info = await stat(target);
114
+ directory = await openDirectoryPath(absolute);
115
+ const info = await directory.handle.stat();
116
+ if (!info.isDirectory()) fail('Search path is not a directory');
117
+ const close = onceAsync(directory.close);
118
+ const anchor = directory.anchor.replace(/^\/proc\/self\/fd\//, `/proc/${process.pid}/fd/`);
119
+ return { kind:'directory', anchor, displayPath:absolute, close };
97
120
  } catch (error) {
98
- if (isRoot) fail(`Search path not found: ${displayPath(target)}`);
99
- options.onWarning?.(error);
121
+ directoryError = error;
122
+ if (directory) await directory.close().catch(() => {});
123
+ }
124
+ try {
125
+ const opened = await openRegularFile(absolute, SEARCH_READ_FLAGS);
126
+ const close = onceAsync(opened.close);
127
+ const anchor = process.platform === 'linux' ? `/proc/${process.pid}/fd/${opened.handle.fd}` : absolute;
128
+ return { kind:'file', anchor, displayPath:absolute, handle:opened.handle, info:opened.info, close };
129
+ } catch (error) {
130
+ if (directoryError?.code === 'ENOENT' || error?.code === 'ENOENT') fail(`Search path not found: ${displayPath(absolute)}`);
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ async function walk(node, options, onFile, isRoot = true) {
136
+ if (node.kind === 'file') {
137
+ await onFile(node);
100
138
  return;
101
139
  }
102
- if (info.isFile()) { await onFile(target); return; }
103
140
  let entries;
104
141
  try {
105
- entries = await readdir(target, { withFileTypes: true });
142
+ entries = await readdir(node.anchor, { withFileTypes: true });
106
143
  } catch (error) {
107
144
  if (isRoot) throw error;
108
145
  options.onWarning?.(error);
@@ -111,20 +148,40 @@ async function walk(target, options, onFile, isRoot = true) {
111
148
  for (const entry of entries) {
112
149
  if (options.stopped()) return;
113
150
  if (!options.includeHidden && entry.name.startsWith('.')) continue;
114
- const child = path.join(target, entry.name);
151
+ const childDisplay = path.join(node.displayPath, entry.name);
152
+ const childAnchor = path.join(node.anchor, entry.name);
115
153
  if (entry.isDirectory()) {
116
154
  if (options.skipDirectories.has(entry.name)) continue;
117
- await walk(child, options, onFile, false);
155
+ let child;
156
+ try {
157
+ child = await openDirectoryPath(childAnchor);
158
+ const info = await child.handle.stat();
159
+ if (!info.isDirectory()) fail('Filesystem path component is not a directory');
160
+ await walk({ kind:'directory', anchor:child.anchor, displayPath:childDisplay, close:child.close }, options, onFile, false);
161
+ } catch (error) {
162
+ options.onWarning?.(error);
163
+ } finally {
164
+ if (child) await child.close().catch(() => {});
165
+ }
118
166
  } else if (entry.isFile()) {
119
- await onFile(child);
167
+ let child;
168
+ try {
169
+ child = await openRegularFile(childAnchor, SEARCH_READ_FLAGS);
170
+ await onFile({ kind:'file', displayPath:childDisplay, handle:child.handle, info:child.info });
171
+ } catch (error) {
172
+ options.onWarning?.(error);
173
+ } finally {
174
+ if (child) await child.close().catch(() => {});
175
+ }
120
176
  }
121
177
  }
122
178
  }
123
179
 
124
- function runRipgrep(session, { path: target, pattern, searchType, filePattern, ignoreCase, includeHidden, includeIgnored, contextLines, maxResults, patternIsLiteral }) {
180
+ function runRipgrep(session, { target, pattern, searchType, filePattern, ignoreCase, includeHidden, includeIgnored, contextLines, maxResults, patternIsLiteral }) {
125
181
  // Flags must come before the `--` separator: anything after it is treated as a path,
126
182
  // which silently turned `--hidden` into a search target.
127
183
  const args = ['--no-heading', '--color', 'never'];
184
+ const searchTarget = target.anchor;
128
185
  if (ignoreCase) args.push('--ignore-case');
129
186
  if (includeHidden) args.push('--hidden');
130
187
  if (!includeIgnored) for (const glob of SKIP_GLOBS) args.push('-g', glob);
@@ -134,12 +191,12 @@ function runRipgrep(session, { path: target, pattern, searchType, filePattern, i
134
191
  // Globs are case-sensitive even under --ignore-case, so a case-insensitive file
135
192
  // search needs --iglob.
136
193
  args.push(ignoreCase ? '--iglob' : '-g', fileNameGlob(pattern));
137
- args.push('--', target);
194
+ args.push('--', searchTarget);
138
195
  } else {
139
196
  args.push('--line-number', '--with-filename');
140
197
  if (patternIsLiteral) args.push('--fixed-strings');
141
198
  if (contextLines) args.push('-C', String(contextLines));
142
- args.push('--', pattern, target);
199
+ args.push('--', pattern, searchTarget);
143
200
  }
144
201
  const child = spawn(ripgrep(), args, { stdio: ['ignore', 'pipe', 'pipe'] });
145
202
  session.cancel = () => child.kill('SIGTERM');
@@ -154,7 +211,7 @@ function runRipgrep(session, { path: target, pattern, searchType, filePattern, i
154
211
  const batch = [];
155
212
  for (const line of lines) {
156
213
  if (!line.trim()) continue;
157
- batch.push(line);
214
+ batch.push(displayRipgrepLine(line, target));
158
215
  collected += 1;
159
216
  if (collected >= maxResults) { capped = true; child.kill('SIGTERM'); break; }
160
217
  }
@@ -167,7 +224,7 @@ function runRipgrep(session, { path: target, pattern, searchType, filePattern, i
167
224
  // ripgrep exits 2 both for fatal invocation errors and for partial traversal errors.
168
225
  // Descendant OS errors are recoverable: preserve readable matches and surface a warning.
169
226
  if (code === 2 && stderr.trim()) {
170
- const warnings = ripgrepTraversalWarnings(stderr, target);
227
+ const warnings = ripgrepTraversalWarnings(stderr, searchTarget);
171
228
  if (!warnings) { finishSearchSession(session, 'failed', stderr.trim().split(/\r?\n/)[0]); return; }
172
229
  rememberSearchWarning(session, warnings.length === 1 ? warnings[0] : `${warnings[0]} (+${warnings.length - 1} more)`);
173
230
  }
@@ -175,7 +232,7 @@ function runRipgrep(session, { path: target, pattern, searchType, filePattern, i
175
232
  });
176
233
  }
177
234
 
178
- async function runFallback(session, { path: target, matcher, searchType, filePattern, ignoreCase, contextLines, maxResults, includeHidden, includeIgnored }) {
235
+ async function runFallback(session, { target, matcher, searchType, filePattern, ignoreCase, contextLines, maxResults, includeHidden, includeIgnored }) {
179
236
  const fileGlobs = splitGlobs(filePattern).map(globToRegExp);
180
237
  const nameGlob = searchType === 'files' ? globToRegExp(fileNameGlob(session.pattern)) : null;
181
238
  const nameLiteral = searchType === 'files' && !/[*?[\]{}]/.test(session.pattern) ? session.pattern : null;
@@ -187,30 +244,17 @@ async function runFallback(session, { path: target, matcher, searchType, filePat
187
244
  onWarning: warning => rememberSearchWarning(session, warning),
188
245
  }, async file => {
189
246
  if (session.status !== 'running' || collected >= maxResults) return;
190
- if (fileGlobs.length && !fileGlobs.some(glob => glob.test(path.basename(file)))) return;
247
+ if (fileGlobs.length && !fileGlobs.some(glob => glob.test(path.basename(file.displayPath)))) return;
191
248
  if (searchType === 'files') {
192
- const base = path.basename(file);
249
+ const base = path.basename(file.displayPath);
193
250
  const matches = (nameGlob && nameGlob.test(base)) || (nameLiteral && (ignoreCase ? base.toLowerCase().includes(nameLiteral.toLowerCase()) : base.includes(nameLiteral)));
194
251
  if (!matches) return;
195
252
  collected += 1;
196
- appendSearchResults(session, [displayPath(file)]);
197
- return;
198
- }
199
- let handle;
200
- try {
201
- handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
202
- } catch (error) {
203
- rememberSearchWarning(session, error);
253
+ appendSearchResults(session, [displayPath(file.displayPath)]);
204
254
  return;
205
255
  }
206
- let buffer;
207
- try {
208
- const info = await handle.stat();
209
- if (!info.isFile() || info.size > MAX_FALLBACK_FILE_BYTES) return;
210
- buffer = await handle.readFile();
211
- } finally {
212
- await handle.close();
213
- }
256
+ if (!file.info.isFile() || file.info.size > MAX_FALLBACK_FILE_BYTES) return;
257
+ const buffer = await file.handle.readFile();
214
258
  if (looksBinary(buffer)) return;
215
259
  const lines = splitLines(buffer.toString('utf8'));
216
260
  for (let index = 0; index < lines.length; index += 1) {
@@ -224,7 +268,7 @@ async function runFallback(session, { path: target, matcher, searchType, filePat
224
268
  }
225
269
  }
226
270
  collected += 1;
227
- appendSearchResults(session, [formatContentResult(file, index + 1, lines[index], context)]);
271
+ appendSearchResults(session, [formatContentResult(file.displayPath, index + 1, lines[index], context)]);
228
272
  }
229
273
  });
230
274
  finishSearchSession(session, session.status === 'running' ? (collected >= maxResults ? 'capped' : 'completed') : session.status);
@@ -232,7 +276,6 @@ async function runFallback(session, { path: target, matcher, searchType, filePat
232
276
 
233
277
  export async function startSearchTool(args) {
234
278
  const target = await resolveSafePath(args.path);
235
- await stat(target).catch(() => fail(`Search path not found: ${displayPath(target)}`));
236
279
  const pattern = requireString(args.pattern, 'pattern');
237
280
  const searchType = String(args.searchType || 'content').toLowerCase();
238
281
  if (!['content', 'files'].includes(searchType)) fail('searchType must be content or files');
@@ -244,12 +287,12 @@ export async function startSearchTool(args) {
244
287
  const contextLines = clampInteger(args.contextLines, 0, 0, 10);
245
288
  const maxResults = clampInteger(args.maxResults, 200, 1, 5000);
246
289
  const matcher = searchType === 'content' ? normalizePattern(pattern, args.literalSearch === true, ignoreCase) : { regex: null, literal: pattern, patternIsLiteral: false };
290
+ const openedTarget = await openSearchTarget(target);
247
291
  const session = createSearchSession({ type: searchType, pattern, path: target, filePattern });
292
+ session.release = openedTarget.close;
248
293
  countEvent('searchesStarted');
249
294
  recordEvent('session_started', { sessionKind: 'search', success: true });
250
- const options = { path: target, pattern, searchType, filePattern, ignoreCase, includeHidden, includeIgnored, contextLines, maxResults, matcher, patternIsLiteral: matcher.patternIsLiteral === true };
251
- // Literal searches also go to ripgrep through --fixed-strings; the JavaScript fallback
252
- // only runs when ripgrep is unavailable.
295
+ const options = { target: openedTarget, pattern, searchType, filePattern, ignoreCase, includeHidden, includeIgnored, contextLines, maxResults, matcher, patternIsLiteral: matcher.patternIsLiteral === true };
253
296
  if (ripgrep()) runRipgrep(session, options);
254
297
  else void runFallback(session, options).catch(error => finishSearchSession(session, 'failed', error instanceof Error ? error.message : String(error)));
255
298
  await waitForSearchResults(session, 1, 800);
package/src/util.mjs CHANGED
@@ -76,6 +76,10 @@ export async function canonicalizePath(target) {
76
76
  }
77
77
 
78
78
  let resolvedRootsPromise;
79
+ export async function resolvedAllowedRoots() {
80
+ return resolvedRoots();
81
+ }
82
+
79
83
  function resolvedRoots() {
80
84
  if (!resolvedRootsPromise) {
81
85
  resolvedRootsPromise = Promise.all(runtimeConfig.allowedRoots.map(async root => {