@remcp/runtime 0.2.93 → 0.2.96
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/CHANGELOG.md +21 -0
- package/package.json +1 -1
- package/src/documents.mjs +187 -24
- package/src/extended/browser.mjs +71 -43
- package/src/extended/common.mjs +14 -2
- package/src/extended/desktop-linux.mjs +3 -4
- package/src/extended/desktop-macos.mjs +3 -3
- package/src/extended/desktop-windows.mjs +3 -3
- package/src/index.mjs +6 -4
- package/src/sessions.mjs +1 -0
- package/src/tools/files.mjs +152 -83
- package/src/tools/search.mjs +70 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.96 — 2026-09-22
|
|
4
|
+
|
|
5
|
+
- Keeps readable search matches when an unreadable descendant is encountered while surfacing a warning.
|
|
6
|
+
- Makes the regression backend-neutral so both ripgrep and the built-in scanner are validated consistently.
|
|
7
|
+
- Supersedes the failed unpublished 0.2.95 tag; runtime behavior is otherwise unchanged from that candidate.
|
|
8
|
+
|
|
9
|
+
## 0.2.95 — 2026-09-22
|
|
10
|
+
|
|
11
|
+
- Search sessions preserve readable matches when ripgrep encounters unreadable descendant paths, reporting the partial traversal problem as a warning instead of a fatal session error.
|
|
12
|
+
- The dependency-free fallback scanner now skips unreadable descendant directories/files with the same warning semantics.
|
|
13
|
+
- Requested-root failures and non-traversal ripgrep errors still fail closed.
|
|
14
|
+
- Added ripgrep and fallback regressions; runtime tool names and schemas remain unchanged.
|
|
15
|
+
|
|
16
|
+
## 0.2.94 — 2026-09-22
|
|
17
|
+
|
|
18
|
+
- Replaced DOCX/PDF regex-heavy extraction with bounded scanners and added adversarial parser regressions.
|
|
19
|
+
- Browser actions pass user-controlled values through structured CDP arguments instead of interpolating them into executable JavaScript.
|
|
20
|
+
- File/search/screenshot/hash operations validate and consume the same open descriptor, using `O_NOFOLLOW` where supported to prevent symlink-swap/check-then-use races.
|
|
21
|
+
- Temporary desktop captures and telemetry marker creation are now descriptor-bound or atomic.
|
|
22
|
+
- Tool schemas and the 83 granular runtime operations remain unchanged.
|
|
23
|
+
|
|
3
24
|
## 0.2.93 — 2026-09-21
|
|
4
25
|
|
|
5
26
|
- Lockstep release for the device client's fresher npm metadata revalidation during exact release updates.
|
package/package.json
CHANGED
package/src/documents.mjs
CHANGED
|
@@ -55,17 +55,70 @@ function unzipEntry(buffer, wanted) {
|
|
|
55
55
|
|
|
56
56
|
const DOCX_ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
57
57
|
|
|
58
|
+
function xmlTagName(tag) {
|
|
59
|
+
let index = 0;
|
|
60
|
+
while (index < tag.length && /\s/.test(tag[index])) index += 1;
|
|
61
|
+
let closing = false;
|
|
62
|
+
if (tag[index] === '/') {
|
|
63
|
+
closing = true;
|
|
64
|
+
index += 1;
|
|
65
|
+
while (index < tag.length && /\s/.test(tag[index])) index += 1;
|
|
66
|
+
}
|
|
67
|
+
const start = index;
|
|
68
|
+
while (index < tag.length && !/\s|\/|>/.test(tag[index])) index += 1;
|
|
69
|
+
return { name: tag.slice(start, index), closing };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function wordXmlText(xml) {
|
|
73
|
+
let output = '';
|
|
74
|
+
let index = 0;
|
|
75
|
+
while (index < xml.length) {
|
|
76
|
+
if (xml[index] !== '<') {
|
|
77
|
+
output += xml[index];
|
|
78
|
+
index += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (xml.startsWith('<!--', index)) {
|
|
82
|
+
const end = xml.indexOf('-->', index + 4);
|
|
83
|
+
index = end < 0 ? xml.length : end + 3;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (xml.startsWith('<![CDATA[', index)) {
|
|
87
|
+
const end = xml.indexOf(']]>', index + 9);
|
|
88
|
+
if (end < 0) break;
|
|
89
|
+
output += xml.slice(index + 9, end);
|
|
90
|
+
index = end + 3;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
let quote = '';
|
|
94
|
+
let end = index + 1;
|
|
95
|
+
for (; end < xml.length; end += 1) {
|
|
96
|
+
const character = xml[end];
|
|
97
|
+
if (quote) {
|
|
98
|
+
if (character === quote) quote = '';
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (character === '"' || character === "'") {
|
|
102
|
+
quote = character;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (character === '>') break;
|
|
106
|
+
}
|
|
107
|
+
if (end >= xml.length) break;
|
|
108
|
+
const { name, closing } = xmlTagName(xml.slice(index + 1, end));
|
|
109
|
+
if (closing && name === 'w:p') output += '\n';
|
|
110
|
+
else if (!closing && (name === 'w:br' || name === 'w:cr')) output += '\n';
|
|
111
|
+
else if (!closing && name === 'w:tab') output += '\t';
|
|
112
|
+
index = end + 1;
|
|
113
|
+
}
|
|
114
|
+
return output;
|
|
115
|
+
}
|
|
116
|
+
|
|
58
117
|
export function readDocxText(buffer) {
|
|
59
118
|
const document = unzipEntry(buffer, 'word/document.xml');
|
|
60
119
|
if (!document) throw new Error('This file is not a readable .docx (its word/document.xml is missing or compressed in an unsupported way)');
|
|
61
120
|
const xml = document.toString('utf8');
|
|
62
|
-
|
|
63
|
-
const withBreaks = xml
|
|
64
|
-
.replace(/<w:(?:br|cr)\b[^>]*\/?>/g, '\n')
|
|
65
|
-
.replace(/<\/w:p>/g, '\n')
|
|
66
|
-
.replace(/<w:tab\b[^>]*\/?>/g, '\t');
|
|
67
|
-
const text = withBreaks.replace(/<[^>]+>/g, '');
|
|
68
|
-
return text
|
|
121
|
+
return wordXmlText(xml)
|
|
69
122
|
.replace(/&(amp|lt|gt|quot|apos);/g, match => DOCX_ENTITIES[match])
|
|
70
123
|
.replace(/\n{3,}/g, '\n\n')
|
|
71
124
|
.trim();
|
|
@@ -78,6 +131,132 @@ function decodePdfString(raw) {
|
|
|
78
131
|
.replace(/\\([0-7]{1,3})/g, (_match, octal) => String.fromCharCode(Number.parseInt(octal, 8)));
|
|
79
132
|
}
|
|
80
133
|
|
|
134
|
+
const PDF_HEX = '0123456789abcdefABCDEF';
|
|
135
|
+
|
|
136
|
+
function readPdfLiteral(content, start) {
|
|
137
|
+
let raw = '';
|
|
138
|
+
let depth = 1;
|
|
139
|
+
let index = start + 1;
|
|
140
|
+
while (index < content.length) {
|
|
141
|
+
const character = content[index];
|
|
142
|
+
if (character === '\\') {
|
|
143
|
+
raw += character;
|
|
144
|
+
index += 1;
|
|
145
|
+
if (index < content.length) {
|
|
146
|
+
raw += content[index];
|
|
147
|
+
index += 1;
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (character === '(') {
|
|
152
|
+
depth += 1;
|
|
153
|
+
raw += character;
|
|
154
|
+
index += 1;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (character === ')') {
|
|
158
|
+
depth -= 1;
|
|
159
|
+
index += 1;
|
|
160
|
+
if (depth === 0) return { value: decodePdfString(raw), next: index };
|
|
161
|
+
raw += character;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
raw += character;
|
|
165
|
+
index += 1;
|
|
166
|
+
}
|
|
167
|
+
return { value: '', next: content.length };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readPdfHex(content, start) {
|
|
171
|
+
let hex = '';
|
|
172
|
+
let index = start + 1;
|
|
173
|
+
while (index < content.length && content[index] !== '>') {
|
|
174
|
+
if (PDF_HEX.includes(content[index])) hex += content[index];
|
|
175
|
+
index += 1;
|
|
176
|
+
}
|
|
177
|
+
if (hex.length % 2) hex += '0';
|
|
178
|
+
const value = Buffer.from(hex, 'hex').toString('latin1').replace(/\0/g, '');
|
|
179
|
+
return { value, next: index < content.length ? index + 1 : index };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function readPdfArray(content, start) {
|
|
183
|
+
const values = [];
|
|
184
|
+
let index = start + 1;
|
|
185
|
+
while (index < content.length) {
|
|
186
|
+
const character = content[index];
|
|
187
|
+
if (character === ']') return { value: values.join(''), next: index + 1 };
|
|
188
|
+
if (character === '(') {
|
|
189
|
+
const token = readPdfLiteral(content, index);
|
|
190
|
+
if (token.value) values.push(token.value);
|
|
191
|
+
index = token.next;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (character === '<' && content[index + 1] !== '<') {
|
|
195
|
+
const token = readPdfHex(content, index);
|
|
196
|
+
if (token.value) values.push(token.value);
|
|
197
|
+
index = token.next;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
index += 1;
|
|
201
|
+
}
|
|
202
|
+
return { value: values.join(''), next: index };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function pdfTextPieces(content) {
|
|
206
|
+
const pieces = [];
|
|
207
|
+
let operand = '';
|
|
208
|
+
let index = 0;
|
|
209
|
+
while (index < content.length) {
|
|
210
|
+
const character = content[index];
|
|
211
|
+
if (/\s/.test(character)) {
|
|
212
|
+
index += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (character === '%') {
|
|
216
|
+
const newline = content.indexOf('\n', index + 1);
|
|
217
|
+
index = newline < 0 ? content.length : newline + 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (character === '(') {
|
|
221
|
+
const token = readPdfLiteral(content, index);
|
|
222
|
+
operand = token.value;
|
|
223
|
+
index = token.next;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (character === '<' && content[index + 1] !== '<') {
|
|
227
|
+
const token = readPdfHex(content, index);
|
|
228
|
+
operand = token.value;
|
|
229
|
+
index = token.next;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (character === '[') {
|
|
233
|
+
const token = readPdfArray(content, index);
|
|
234
|
+
operand = token.value;
|
|
235
|
+
index = token.next;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const start = index;
|
|
239
|
+
while (index < content.length && !/\s|[()[\]<>]/.test(content[index])) index += 1;
|
|
240
|
+
if (index === start) {
|
|
241
|
+
index += 1;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const operator = content.slice(start, index);
|
|
245
|
+
if (operator === 'Tj' || operator === 'TJ') {
|
|
246
|
+
if (operand) pieces.push(operand);
|
|
247
|
+
operand = '';
|
|
248
|
+
} else if (operator === "'" || operator === '"') {
|
|
249
|
+
pieces.push('\n');
|
|
250
|
+
if (operand) pieces.push(operand);
|
|
251
|
+
operand = '';
|
|
252
|
+
} else if (operator === 'T*' || operator === 'Td' || operator === 'TD' || operator === 'ET') {
|
|
253
|
+
pieces.push('\n');
|
|
254
|
+
operand = '';
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return pieces;
|
|
258
|
+
}
|
|
259
|
+
|
|
81
260
|
export function readPdfText(buffer) {
|
|
82
261
|
const raw = buffer.toString('latin1');
|
|
83
262
|
const chunks = [];
|
|
@@ -97,23 +276,7 @@ export function readPdfText(buffer) {
|
|
|
97
276
|
}
|
|
98
277
|
const content = chunks.join('\n');
|
|
99
278
|
|
|
100
|
-
const pieces =
|
|
101
|
-
const showText = /(?:\((?:\\.|[^\\()])*\)|<[0-9A-Fa-f\s]+>)\s*Tj|\[((?:[^\][]|\\.)*)\]\s*TJ|\((?:\\.|[^\\()])*\)\s*['"]|T\*|Td|TD|ET/g;
|
|
102
|
-
for (const match of content.matchAll(showText)) {
|
|
103
|
-
const token = match[0];
|
|
104
|
-
if (/^T\*|Td|TD|ET$/.test(token)) { pieces.push('\n'); continue; }
|
|
105
|
-
if (token.includes('TJ')) {
|
|
106
|
-
const array = match[1] ?? '';
|
|
107
|
-
for (const part of array.matchAll(/\((?:\\.|[^\\()])*\)|<[0-9A-Fa-f\s]+>/g)) {
|
|
108
|
-
const value = part[0];
|
|
109
|
-
if (value.startsWith('(')) pieces.push(decodePdfString(value.slice(1, -1)));
|
|
110
|
-
else pieces.push(Buffer.from(value.slice(1, -1).replace(/\s+/g, ''), 'hex').toString('latin1').replace(/\0/g, ''));
|
|
111
|
-
}
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
if (token.startsWith('(')) pieces.push(decodePdfString(token.slice(1, token.lastIndexOf(')'))));
|
|
115
|
-
else if (token.startsWith('<')) pieces.push(Buffer.from(token.slice(1, token.indexOf('>')).replace(/\s+/g, ''), 'hex').toString('latin1').replace(/\0/g, ''));
|
|
116
|
-
}
|
|
279
|
+
const pieces = pdfTextPieces(content);
|
|
117
280
|
const text = pieces.join('').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
118
281
|
const printable = text.replace(/[^\p{L}\p{N}\p{P}\p{Zs}\n\t]/gu, '');
|
|
119
282
|
if (text.length < 8 || printable.length / Math.max(1, text.length) < 0.7) {
|
package/src/extended/browser.mjs
CHANGED
|
@@ -294,6 +294,29 @@ async function evaluate(session, expression, awaitPromise = true) {
|
|
|
294
294
|
return result.result?.value;
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
async function callPageFunction(session, functionDeclaration, args = [], awaitPromise = true) {
|
|
298
|
+
const global = await session.send('Runtime.evaluate', {
|
|
299
|
+
expression: 'globalThis',
|
|
300
|
+
returnByValue: false,
|
|
301
|
+
awaitPromise: false,
|
|
302
|
+
});
|
|
303
|
+
if (global.exceptionDetails || !global.result?.objectId) {
|
|
304
|
+
throw new Error(global.exceptionDetails?.exception?.description || global.exceptionDetails?.text || 'Could not access the page execution context');
|
|
305
|
+
}
|
|
306
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
307
|
+
objectId: global.result.objectId,
|
|
308
|
+
functionDeclaration,
|
|
309
|
+
arguments: args.map(value => ({ value })),
|
|
310
|
+
returnByValue: true,
|
|
311
|
+
awaitPromise,
|
|
312
|
+
userGesture: true,
|
|
313
|
+
});
|
|
314
|
+
if (result.exceptionDetails) {
|
|
315
|
+
throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'JavaScript function call failed');
|
|
316
|
+
}
|
|
317
|
+
return result.result?.value;
|
|
318
|
+
}
|
|
319
|
+
|
|
297
320
|
export async function browserTabs(args = {}) {
|
|
298
321
|
const targets = await listBrowserTargets(args.endpoint);
|
|
299
322
|
return jsonResult(targets.filter(item => item.type === 'page').slice(0, 500).map(item => ({
|
|
@@ -559,18 +582,41 @@ export async function browserFind(args) {
|
|
|
559
582
|
});
|
|
560
583
|
}
|
|
561
584
|
|
|
562
|
-
function
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
585
|
+
const PAGE_ELEMENT_ACTION = `function(selector, needle, action, value) {
|
|
586
|
+
let el = selector ? document.querySelector(selector) : null;
|
|
587
|
+
if (!el && needle) {
|
|
588
|
+
const lower = String(needle).toLowerCase();
|
|
589
|
+
el = Array.from(document.querySelectorAll('*')).find(node =>
|
|
590
|
+
String(node.innerText || node.textContent || '').trim().toLowerCase().includes(lower)
|
|
591
|
+
) || null;
|
|
592
|
+
}
|
|
593
|
+
if (!el) throw new Error('Element not found');
|
|
594
|
+
if (action === 'click' || action === 'type' || action === 'scroll_into_view') {
|
|
595
|
+
el.scrollIntoView({block:'center', inline:'center'});
|
|
596
|
+
}
|
|
597
|
+
if (action === 'focus' || action === 'type') el.focus();
|
|
598
|
+
if (action === 'select') {
|
|
599
|
+
el.value = value;
|
|
600
|
+
el.dispatchEvent(new Event('input', {bubbles:true}));
|
|
601
|
+
el.dispatchEvent(new Event('change', {bubbles:true}));
|
|
602
|
+
} else if (action === 'set_value') {
|
|
603
|
+
const proto = Object.getPrototypeOf(el);
|
|
604
|
+
const own = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
605
|
+
const parent = Object.getPrototypeOf(proto);
|
|
606
|
+
const inherited = parent ? Object.getOwnPropertyDescriptor(parent, 'value') : null;
|
|
607
|
+
const setter = own?.set || inherited?.set;
|
|
608
|
+
if (setter) setter.call(el, value); else el.value = value;
|
|
609
|
+
el.dispatchEvent(new Event('input', {bubbles:true}));
|
|
610
|
+
el.dispatchEvent(new Event('change', {bubbles:true}));
|
|
611
|
+
}
|
|
612
|
+
const b = el.getBoundingClientRect();
|
|
613
|
+
if (action === 'click' && (!b.width || !b.height)) throw new Error('Element has no clickable bounds');
|
|
614
|
+
return {
|
|
615
|
+
tag: el.tagName.toLowerCase(),
|
|
616
|
+
value: 'value' in el ? String(el.value) : '',
|
|
617
|
+
x: b.x, y: b.y, width: b.width, height: b.height,
|
|
618
|
+
};
|
|
619
|
+
}`;
|
|
574
620
|
|
|
575
621
|
export function browserActionInputValue(args = {}, action = '') {
|
|
576
622
|
// text is the public field models naturally use for type, while set_value/select historically
|
|
@@ -616,19 +662,14 @@ export async function browserAction(args) {
|
|
|
616
662
|
await session.send('Input.dispatchKeyEvent', { type: 'keyUp', key });
|
|
617
663
|
return jsonResult({ target_id: target.id, action, key });
|
|
618
664
|
}
|
|
619
|
-
const
|
|
665
|
+
const selector = optionalString(args.selector);
|
|
666
|
+
const needle = optionalString(args.text);
|
|
667
|
+
if (!selector && !needle) throw new Error('selector or text is required');
|
|
620
668
|
const value = browserActionInputValue(args, action);
|
|
621
669
|
const option = String(args.option ?? value);
|
|
622
670
|
|
|
623
671
|
if (action === 'click') {
|
|
624
|
-
const rect = await
|
|
625
|
-
const el = ${lookup};
|
|
626
|
-
if (!el) throw new Error('Element not found');
|
|
627
|
-
el.scrollIntoView({block:'center',inline:'center'});
|
|
628
|
-
const b = el.getBoundingClientRect();
|
|
629
|
-
if (!b.width || !b.height) throw new Error('Element has no clickable bounds');
|
|
630
|
-
return { tag:el.tagName.toLowerCase(), x:b.x, y:b.y, width:b.width, height:b.height };
|
|
631
|
-
})()`);
|
|
672
|
+
const rect = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'click', '']);
|
|
632
673
|
const x = Number(rect.x) + Number(rect.width) / 2;
|
|
633
674
|
const y = Number(rect.y) + Number(rect.height) / 2;
|
|
634
675
|
await session.send('Input.dispatchMouseEvent', { type:'mouseMoved', x, y, button:'none' });
|
|
@@ -638,31 +679,18 @@ export async function browserAction(args) {
|
|
|
638
679
|
}
|
|
639
680
|
|
|
640
681
|
if (action === 'type') {
|
|
641
|
-
const focused = await
|
|
642
|
-
const el = ${lookup};
|
|
643
|
-
if (!el) throw new Error('Element not found');
|
|
644
|
-
el.scrollIntoView({block:'center',inline:'center'});
|
|
645
|
-
el.focus();
|
|
646
|
-
const b = el.getBoundingClientRect();
|
|
647
|
-
return { tag:el.tagName.toLowerCase(), value:'value' in el?String(el.value):'', x:b.x, y:b.y, width:b.width, height:b.height };
|
|
648
|
-
})()`);
|
|
682
|
+
const focused = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'type', '']);
|
|
649
683
|
await session.send('Input.insertText', { text:value });
|
|
650
|
-
const current = await
|
|
651
|
-
return jsonResult({ target_id:target.id, action, result:{ ...focused, value:current } });
|
|
684
|
+
const current = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'read_value', '']);
|
|
685
|
+
return jsonResult({ target_id:target.id, action, result:{ ...focused, value:current.value } });
|
|
652
686
|
}
|
|
653
687
|
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
`const proto=Object.getPrototypeOf(el);const own=Object.getOwnPropertyDescriptor(proto,'value');const parent=Object.getPrototypeOf(proto);const inherited=parent?Object.getOwnPropertyDescriptor(parent,'value'):null;const setter=own?.set||inherited?.set;if(setter)setter.call(el,${JSON.stringify(value)});else el.value=${JSON.stringify(value)};el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));`
|
|
661
|
-
}
|
|
662
|
-
const b = el.getBoundingClientRect();
|
|
663
|
-
return { tag: el.tagName.toLowerCase(), value: 'value' in el ? String(el.value) : '', x: b.x, y: b.y, width: b.width, height: b.height };
|
|
664
|
-
})()`;
|
|
665
|
-
return jsonResult({ target_id: target.id, action, result: await evaluate(session, expression) });
|
|
688
|
+
const pageValue = action === 'select' ? option : value;
|
|
689
|
+
return jsonResult({
|
|
690
|
+
target_id: target.id,
|
|
691
|
+
action,
|
|
692
|
+
result: await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, action, pageValue]),
|
|
693
|
+
});
|
|
666
694
|
});
|
|
667
695
|
}
|
|
668
696
|
|
package/src/extended/common.mjs
CHANGED
|
@@ -2,8 +2,8 @@ import os from 'node:os';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { execFile, spawn } from 'node:child_process';
|
|
5
|
-
import { existsSync } from 'node:fs';
|
|
6
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { constants, existsSync } from 'node:fs';
|
|
6
|
+
import { mkdtemp, open, rm } from 'node:fs/promises';
|
|
7
7
|
import { StringDecoder } from 'node:string_decoder';
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
9
|
import { ToolError, fail, structured, text } from '../util.mjs';
|
|
@@ -259,6 +259,18 @@ export async function spawnDetachedWithInput(file, args = [], input = '', option
|
|
|
259
259
|
});
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
export async function readPrivateTempFile(file, maxBytes) {
|
|
263
|
+
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
264
|
+
try {
|
|
265
|
+
const info = await handle.stat();
|
|
266
|
+
if (!info.isFile()) throw new ToolError('Temporary capture is not a regular file');
|
|
267
|
+
if (info.size > maxBytes) throw new ToolError(`Captured file is ${info.size} bytes, above the ${maxBytes}-byte inline limit`);
|
|
268
|
+
return { info, data: await handle.readFile() };
|
|
269
|
+
} finally {
|
|
270
|
+
await handle.close();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
262
274
|
export async function tempDir(prefix = 'remcp-') {
|
|
263
275
|
return mkdtemp(path.join(os.tmpdir(), prefix));
|
|
264
276
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import process from 'node:process';
|
|
3
|
-
|
|
3
|
+
|
|
4
4
|
import { image, multi, text } from '../util.mjs';
|
|
5
5
|
import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
|
|
6
6
|
import { hasWaylandRemoteDesktopGrant, portalPointerButton, portalPointerMotion, portalPointerMotionAbsolute, portalScroll, portalShortcut, portalTypeText, waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
commandExists,
|
|
10
10
|
jsonResult,
|
|
11
11
|
optionalString,
|
|
12
|
+
readPrivateTempFile,
|
|
12
13
|
removeTemp,
|
|
13
14
|
requireEnum,
|
|
14
15
|
runFile,
|
|
@@ -1274,9 +1275,7 @@ export async function screenshotRegion(args = {}) {
|
|
|
1274
1275
|
} else {
|
|
1275
1276
|
unavailable('Region screenshots', isWaylandSession() ? 'the XDG Desktop Portal plus ffmpeg, or grim, is required' : 'install ImageMagick import');
|
|
1276
1277
|
}
|
|
1277
|
-
const
|
|
1278
|
-
if (info.size > 4 * 1024 * 1024) throw new Error(`captured region is ${info.size} bytes; request a smaller region so it fits the inline image limit`);
|
|
1279
|
-
const data = await readFile(target);
|
|
1278
|
+
const {data} = await readPrivateTempFile(target, 4 * 1024 * 1024);
|
|
1280
1279
|
return multi([{type:'text',text:`Captured ${width}x${height} at ${x},${y}.`}, image(data.toString('base64'),'image/png')]);
|
|
1281
1280
|
} finally { await removeTemp(dir); }
|
|
1282
1281
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
import { image, multi, text } from '../util.mjs';
|
|
4
4
|
import {
|
|
5
5
|
clamp,
|
|
6
6
|
escapeAppleScript,
|
|
7
7
|
jsonResult,
|
|
8
8
|
optionalString,
|
|
9
|
+
readPrivateTempFile,
|
|
9
10
|
removeTemp,
|
|
10
11
|
requireEnum,
|
|
11
12
|
runFile,
|
|
@@ -379,8 +380,7 @@ export async function screenshotRegion(args = {}) {
|
|
|
379
380
|
const dir=await tempDir('remcp-region-'),target=path.join(dir,'region.png');
|
|
380
381
|
try{
|
|
381
382
|
await runFile('/usr/sbin/screencapture',['-x','-R',`${x},${y},${width},${height}`,target],{label:'screenshot region'});
|
|
382
|
-
const
|
|
383
|
-
const data=await readFile(target);
|
|
383
|
+
const {data}=await readPrivateTempFile(target,4*1024*1024);
|
|
384
384
|
return multi([{type:'text',text:`Captured ${width}x${height} at ${x},${y}.`},image(data.toString('base64'),'image/png')]);
|
|
385
385
|
}finally{await removeTemp(dir);}
|
|
386
386
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import process from 'node:process';
|
|
3
|
-
|
|
3
|
+
|
|
4
4
|
import { image, multi, text } from '../util.mjs';
|
|
5
5
|
import {
|
|
6
6
|
clamp,
|
|
7
7
|
escapePowerShellSingle,
|
|
8
8
|
jsonResult,
|
|
9
9
|
optionalString,
|
|
10
|
+
readPrivateTempFile,
|
|
10
11
|
removeTemp,
|
|
11
12
|
requireEnum,
|
|
12
13
|
runPowerShell,
|
|
@@ -486,8 +487,7 @@ export async function screenshotRegion(args = {}) {
|
|
|
486
487
|
try{
|
|
487
488
|
const safe=escapePowerShellSingle(target);
|
|
488
489
|
await runPowerShell(`Add-Type -AssemblyName System.Drawing;$b=New-Object System.Drawing.Bitmap(${width},${height});$g=[System.Drawing.Graphics]::FromImage($b);$g.CopyFromScreen(${x},${y},0,0,$b.Size);$b.Save('${safe}',[System.Drawing.Imaging.ImageFormat]::Png);$g.Dispose();$b.Dispose()`,{label:'screenshot region'});
|
|
489
|
-
const
|
|
490
|
-
const data=await readFile(target);
|
|
490
|
+
const {data}=await readPrivateTempFile(target,4*1024*1024);
|
|
491
491
|
return multi([{type:'text',text:`Captured ${width}x${height} at ${x},${y}.`},image(data.toString('base64'),'image/png')]);
|
|
492
492
|
}finally{await removeTemp(dir);}
|
|
493
493
|
}
|
package/src/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from 'node:process';
|
|
3
|
-
import {
|
|
3
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { advertisedTools as advertisedCoreTools, toolDefinitions as coreToolDefinitions } from './catalog.mjs';
|
|
6
6
|
import { allExtendedTools, advertisedExtendedTools, extendedToolDefinitions } from './extended/catalog.mjs';
|
|
@@ -76,10 +76,12 @@ function announceTelemetryOnce() {
|
|
|
76
76
|
if (!telemetryEnabled()) return;
|
|
77
77
|
const marker = path.join(runtimeConfigDir, '.telemetry-notice');
|
|
78
78
|
try {
|
|
79
|
-
if (existsSync(marker)) return;
|
|
80
79
|
mkdirSync(runtimeConfigDir, { recursive: true, mode: 0o700 });
|
|
81
|
-
writeFileSync(marker, `${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
82
|
-
} catch {
|
|
80
|
+
writeFileSync(marker, `${new Date().toISOString()}\n`, { mode: 0o600, flag: 'wx' });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error?.code === 'EEXIST') return;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
83
85
|
console.error('ReMCP runtime: anonymous usage metrics are on (tool names, timings, outcomes only - never file paths, commands or output). They go to your own ReMCP account through the paired agent. Disable with `remcp telemetry off`.');
|
|
84
86
|
}
|
|
85
87
|
|
package/src/sessions.mjs
CHANGED
package/src/tools/files.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { createHash } from 'node:crypto';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { constants, createReadStream } from 'node:fs';
|
|
6
|
-
import { access, chmod, chown, copyFile, cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
7
7
|
import { pipeline } from 'node:stream/promises';
|
|
8
8
|
import { liveConfig, runtimeConfig } from '../config.mjs';
|
|
9
9
|
import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
|
|
@@ -40,6 +40,42 @@ function assertRegularFile(info, absolute) {
|
|
|
40
40
|
return info;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
const READ_NOFOLLOW = constants.O_RDONLY | (constants.O_NOFOLLOW || 0);
|
|
44
|
+
const READWRITE_NOFOLLOW = constants.O_RDWR | (constants.O_NOFOLLOW || 0);
|
|
45
|
+
|
|
46
|
+
async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
|
|
47
|
+
const handle = await open(absolute, flags);
|
|
48
|
+
try {
|
|
49
|
+
const info = assertRegularFile(await handle.stat(), absolute);
|
|
50
|
+
return { handle, info };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
await handle.close().catch(() => {});
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readRegularBuffer(absolute, maxBytes = Infinity) {
|
|
58
|
+
const { handle, info } = await openRegularFile(absolute);
|
|
59
|
+
try {
|
|
60
|
+
if (info.size > maxBytes) fail(`File is too large to read inline (${info.size} bytes)`);
|
|
61
|
+
return { info, buffer: await handle.readFile() };
|
|
62
|
+
} finally {
|
|
63
|
+
await handle.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function overwriteOpenFile(handle, content) {
|
|
68
|
+
const buffer = Buffer.from(content, 'utf8');
|
|
69
|
+
await handle.truncate(0);
|
|
70
|
+
let offset = 0;
|
|
71
|
+
while (offset < buffer.length) {
|
|
72
|
+
const { bytesWritten } = await handle.write(buffer, offset, buffer.length - offset, offset);
|
|
73
|
+
if (!bytesWritten) throw new Error('Could not finish writing the open file');
|
|
74
|
+
offset += bytesWritten;
|
|
75
|
+
}
|
|
76
|
+
await handle.truncate(buffer.length);
|
|
77
|
+
}
|
|
78
|
+
|
|
43
79
|
// Traversal helper for every multi-file tool. A symbolic link inside an allowed root can point
|
|
44
80
|
// anywhere, so links are never followed and each collected path is resolved through
|
|
45
81
|
// resolveSafePath again before a tool reads or writes it. `stat` follows links, which is exactly
|
|
@@ -85,10 +121,13 @@ async function confineAll(paths) {
|
|
|
85
121
|
}
|
|
86
122
|
|
|
87
123
|
async function readTextFile(absolute) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
124
|
+
let opened;
|
|
125
|
+
try { opened = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES); }
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
const { info, buffer } = opened;
|
|
92
131
|
const decoded = decodeText(buffer);
|
|
93
132
|
if (decoded.encoding === 'utf8' && looksBinary(buffer)) {
|
|
94
133
|
fail(`${displayPath(absolute)} looks like a binary file and cannot be read as text. Use read_image for images, or get_file_info and hash_file for other binaries.`);
|
|
@@ -101,10 +140,7 @@ export async function readFileTool(args) {
|
|
|
101
140
|
// Documents first: a .docx or .pdf is not text, and the binary guard below would refuse it.
|
|
102
141
|
const kind = documentKind(absolute);
|
|
103
142
|
if (kind) {
|
|
104
|
-
const
|
|
105
|
-
assertRegularFile(info, absolute);
|
|
106
|
-
if (info.size > MAX_INLINE_FILE_BYTES) fail(`File is too large to read inline (${info.size} bytes)`);
|
|
107
|
-
const buffer = await readFile(absolute);
|
|
143
|
+
const { buffer } = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES);
|
|
108
144
|
const extracted = kind === 'docx' ? readDocxText(buffer) : readPdfText(buffer);
|
|
109
145
|
const documentLines = splitLines(extracted);
|
|
110
146
|
const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
|
|
@@ -157,15 +193,20 @@ export async function readMultipleFilesTool(args) {
|
|
|
157
193
|
|
|
158
194
|
export async function readImageTool(args) {
|
|
159
195
|
const absolute = await resolveSafePath(args.path);
|
|
160
|
-
|
|
161
|
-
|
|
196
|
+
let opened;
|
|
197
|
+
try { opened = await readRegularBuffer(absolute, MAX_IMAGE_BYTES); }
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
const { info, buffer } = opened;
|
|
162
203
|
const mimeType = IMAGE_TYPES.get(path.extname(absolute).toLowerCase());
|
|
163
204
|
if (!mimeType) fail(`${displayPath(absolute)} is not a supported image type (${[...IMAGE_TYPES.keys()].join(', ')})`);
|
|
164
205
|
if (mimeType === 'image/svg+xml') {
|
|
165
|
-
const
|
|
166
|
-
|
|
206
|
+
const decoded = decodeText(buffer);
|
|
207
|
+
if (decoded.encoding === 'utf8' && looksBinary(buffer)) fail(`${displayPath(absolute)} looks like a binary file`);
|
|
208
|
+
return text(`SVG image ${displayPath(absolute)} (${info.size} bytes):\n${decoded.text}`);
|
|
167
209
|
}
|
|
168
|
-
const buffer = await readFile(absolute);
|
|
169
210
|
return multi([
|
|
170
211
|
{ type: 'text', text: `${displayPath(absolute)} — ${mimeType}, ${info.size} bytes` },
|
|
171
212
|
image(buffer.toString('base64'), mimeType),
|
|
@@ -174,12 +215,22 @@ export async function readImageTool(args) {
|
|
|
174
215
|
|
|
175
216
|
export async function hashFileTool(args) {
|
|
176
217
|
const absolute = await resolveSafePath(args.path);
|
|
177
|
-
|
|
218
|
+
let opened;
|
|
219
|
+
try { opened = await openRegularFile(absolute); }
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
const { handle, info } = opened;
|
|
178
225
|
const algorithm = String(args.algorithm || 'sha256').toLowerCase();
|
|
179
|
-
if (!['sha256', 'sha1', 'md5'].includes(algorithm)) fail('algorithm must be sha256, sha1, or md5');
|
|
226
|
+
if (!['sha256', 'sha1', 'md5'].includes(algorithm)) { await handle.close(); fail('algorithm must be sha256, sha1, or md5'); }
|
|
180
227
|
const hash = createHash(algorithm);
|
|
181
|
-
|
|
182
|
-
|
|
228
|
+
try {
|
|
229
|
+
await pipeline(createReadStream(absolute, { fd: handle.fd, autoClose: false }), hash);
|
|
230
|
+
return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
|
|
231
|
+
} finally {
|
|
232
|
+
await handle.close();
|
|
233
|
+
}
|
|
183
234
|
}
|
|
184
235
|
|
|
185
236
|
async function listEntry(base, depth, maxDepth, prefix, pattern) {
|
|
@@ -230,11 +281,11 @@ export async function getFileInfoTool(args) {
|
|
|
230
281
|
permissions: `0${(info.mode & 0o777).toString(8)}`,
|
|
231
282
|
};
|
|
232
283
|
if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
235
|
-
const decoded = decodeText(buffer);
|
|
284
|
+
const opened = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES).catch(() => null);
|
|
285
|
+
if (opened) {
|
|
286
|
+
const decoded = decodeText(opened.buffer);
|
|
236
287
|
if (decoded.encoding !== 'utf8') payload.encoding = decoded.encoding;
|
|
237
|
-
if (decoded.encoding !== 'utf8' || !looksBinary(buffer)) {
|
|
288
|
+
if (decoded.encoding !== 'utf8' || !looksBinary(opened.buffer)) {
|
|
238
289
|
const lines = splitLines(decoded.text);
|
|
239
290
|
payload.lineCount = lines.length;
|
|
240
291
|
payload.lastLine = Math.max(0, lines.length - 1);
|
|
@@ -284,12 +335,17 @@ export async function writeFileTool(args) {
|
|
|
284
335
|
// large file is read as a sequence of base64 slices and written back the same way.
|
|
285
336
|
export async function readBinaryTool(args) {
|
|
286
337
|
const absolute = await resolveSafePath(args.path);
|
|
287
|
-
|
|
338
|
+
let opened;
|
|
339
|
+
try { opened = await openRegularFile(absolute); }
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
const { handle, info } = opened;
|
|
288
345
|
const offset = Math.max(0, Number.isFinite(Number(args.offset_bytes)) ? Math.trunc(Number(args.offset_bytes)) : 0);
|
|
289
346
|
const length = clampInteger(args.length_bytes, MAX_BINARY_CHUNK_BYTES, 1, MAX_BINARY_CHUNK_BYTES);
|
|
290
347
|
const start = Math.min(offset, info.size);
|
|
291
348
|
const end = Math.min(info.size, start + length);
|
|
292
|
-
const handle = await open(absolute, 'r');
|
|
293
349
|
try {
|
|
294
350
|
const buffer = Buffer.alloc(end - start);
|
|
295
351
|
if (buffer.length) await handle.read(buffer, 0, buffer.length, start);
|
|
@@ -449,23 +505,29 @@ export async function replaceInFilesTool(args) {
|
|
|
449
505
|
for (const file of files) {
|
|
450
506
|
if (changed.length >= maxFiles) break;
|
|
451
507
|
if (glob && !glob.test(path.basename(file))) continue;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
508
|
+
let opened;
|
|
509
|
+
try { opened = await openRegularFile(file, dryRun ? READ_NOFOLLOW : READWRITE_NOFOLLOW); }
|
|
510
|
+
catch { continue; }
|
|
511
|
+
const { handle, info: fileInfo } = opened;
|
|
512
|
+
try {
|
|
513
|
+
if (fileInfo.size > MAX_INLINE_FILE_BYTES) continue;
|
|
514
|
+
const buffer = await handle.readFile();
|
|
515
|
+
const decoded = decodeText(buffer);
|
|
516
|
+
if (decoded.encoding === 'utf8' && looksBinary(buffer)) continue;
|
|
517
|
+
scanned += 1;
|
|
518
|
+
const original = decoded.text;
|
|
519
|
+
const count = isRegex ? (original.match(matcher) || []).length : original.split(pattern).length - 1;
|
|
520
|
+
if (!count) continue;
|
|
521
|
+
if (isRegex) matcher.lastIndex = 0;
|
|
522
|
+
const updated = isRegex ? original.replace(matcher, replacement) : original.split(pattern).join(replacement);
|
|
523
|
+
if (updated === original) continue;
|
|
524
|
+
assertWritableSize(updated);
|
|
525
|
+
if (!dryRun) await overwriteOpenFile(handle, updated);
|
|
526
|
+
const stats = diffStats(original, updated);
|
|
527
|
+
changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
|
|
528
|
+
} finally {
|
|
529
|
+
await handle.close();
|
|
530
|
+
}
|
|
469
531
|
}
|
|
470
532
|
if (!changed.length) return text(`No matches for ${JSON.stringify(pattern)} in ${displayPath(root)} (${scanned} text files scanned).`);
|
|
471
533
|
const rows = changed.map(entry => `${dryRun ? 'would change' : 'changed'} ${entry.file} · ${entry.replacements} replacement(s) · +${entry.added}/-${entry.removed} lines`);
|
|
@@ -963,55 +1025,62 @@ function windowsScreenshotScript(file) {
|
|
|
963
1025
|
}
|
|
964
1026
|
|
|
965
1027
|
export async function takeScreenshotTool(args) {
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
const
|
|
1028
|
+
const requestedDirectory = args.directory ? await resolveSafePath(args.directory, 'directory') : '';
|
|
1029
|
+
const temporaryDirectory = requestedDirectory ? '' : await mkdtemp(path.join(os.tmpdir(), 'remcp-screenshot-'));
|
|
1030
|
+
const directory = requestedDirectory || temporaryDirectory;
|
|
1031
|
+
if (requestedDirectory) await mkdir(directory, { recursive: true });
|
|
1032
|
+
const file = path.join(directory, `capture-${randomUUID()}.png`);
|
|
969
1033
|
const attempts = [];
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1034
|
+
let preserveCapture = false;
|
|
1035
|
+
try {
|
|
1036
|
+
if (process.platform === 'win32') {
|
|
1037
|
+
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScreenshotScript(file)], { encoding: 'utf8', timeout: 30000 });
|
|
1038
|
+
attempts.push(`powershell: ${(result.stderr || '').trim() || ('exit ' + result.status)}`);
|
|
1039
|
+
} else {
|
|
1040
|
+
if (process.platform === 'linux' && isWaylandSession()) {
|
|
1041
|
+
try {
|
|
1042
|
+
await capturePortalScreenshot(file);
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
const message = error instanceof Error ? error.message : `xdg-desktop-portal: ${String(error)}`;
|
|
1045
|
+
attempts.push(message);
|
|
1046
|
+
if (error && typeof error === 'object' && error.code === 'PORTAL_CANCELLED') {
|
|
1047
|
+
fail(`Screen capture was cancelled in the desktop permission dialog (${message}).`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (!await pathExists(file)) {
|
|
1052
|
+
for (const candidate of SCREENSHOT_COMMANDS) {
|
|
1053
|
+
if (spawnSync('which', [candidate.command], { encoding: 'utf8' }).status !== 0) continue;
|
|
1054
|
+
const result = spawnSync(candidate.command, candidate.args(file), { encoding: 'utf8', timeout: 30000 });
|
|
1055
|
+
if (result.status === 0 && await pathExists(file)) break;
|
|
1056
|
+
attempts.push(`${candidate.command}: ${(result.stderr || '').trim() || ('exit ' + result.status)}`);
|
|
985
1057
|
}
|
|
986
1058
|
}
|
|
987
1059
|
}
|
|
988
|
-
if (!await pathExists(file))
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1060
|
+
if (!await pathExists(file)) fail(screenshotAdvice(attempts));
|
|
1061
|
+
|
|
1062
|
+
const { handle, info } = await openRegularFile(file);
|
|
1063
|
+
try {
|
|
1064
|
+
if (info.size <= MAX_IMAGE_BYTES) {
|
|
1065
|
+
const buffer = await handle.readFile();
|
|
1066
|
+
preserveCapture = args.keep === true;
|
|
1067
|
+
return multi([
|
|
1068
|
+
{ type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(file)) : ''}` },
|
|
1069
|
+
image(buffer.toString('base64'), 'image/png'),
|
|
1070
|
+
]);
|
|
994
1071
|
}
|
|
1072
|
+
preserveCapture = true;
|
|
1073
|
+
return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(file)} instead of being returned as an image. Fetch it with read_binary using chunks of up to ${MAX_BINARY_CHUNK_BYTES} bytes (offset_bytes and length_bytes), or ask for a smaller region.`);
|
|
1074
|
+
} finally {
|
|
1075
|
+
await handle.close();
|
|
1076
|
+
}
|
|
1077
|
+
} finally {
|
|
1078
|
+
if (!preserveCapture) {
|
|
1079
|
+
if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
|
|
1080
|
+
else await rm(file, { force: true }).catch(() => {});
|
|
995
1081
|
}
|
|
996
1082
|
}
|
|
997
|
-
if (!await pathExists(file)) {
|
|
998
|
-
fail(screenshotAdvice(attempts));
|
|
999
|
-
}
|
|
1000
|
-
const info = await stat(file);
|
|
1001
|
-
if (info.size <= MAX_IMAGE_BYTES) {
|
|
1002
|
-
const buffer = await readFile(file);
|
|
1003
|
-
if (args.keep !== true) await rm(file, { force: true });
|
|
1004
|
-
return multi([
|
|
1005
|
-
{ type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${args.keep === true ? ` saved at ${displayPath(file)}` : ''}` },
|
|
1006
|
-
image(buffer.toString('base64'), 'image/png'),
|
|
1007
|
-
]);
|
|
1008
|
-
}
|
|
1009
|
-
// A big screen is not an error: the PNG stays on the computer and the model is told how to fetch it
|
|
1010
|
-
// in chunks, which is the same path every other large file takes. Failing here used to lose the
|
|
1011
|
-
// screenshot entirely.
|
|
1012
|
-
return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(file)} instead of being returned as an image. Fetch it with read_binary using chunks of up to ${MAX_BINARY_CHUNK_BYTES} bytes (offset_bytes and length_bytes), or ask for a smaller region.`);
|
|
1013
1083
|
}
|
|
1014
|
-
|
|
1015
1084
|
export const fileToolHandlers = {
|
|
1016
1085
|
read_file: readFileTool,
|
|
1017
1086
|
read_files: readFilesTool,
|
package/src/tools/search.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open, readdir, stat } from 'node:fs/promises';
|
|
3
4
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
5
|
import { runtimeConfig } from '../config.mjs';
|
|
5
6
|
import { countEvent, recordEvent } from '../telemetry.mjs';
|
|
@@ -70,17 +71,50 @@ function formatContentResult(file, lineNumber, line, contextLines) {
|
|
|
70
71
|
return rows.join('\n');
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
function rememberSearchWarning(session, warning) {
|
|
75
|
+
if (session.warning) return;
|
|
76
|
+
session.warning = warning instanceof Error ? warning.message : String(warning);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ripgrepTraversalWarnings(stderr, target) {
|
|
80
|
+
const lines = String(stderr || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
81
|
+
if (!lines.length) return null;
|
|
82
|
+
const root = path.resolve(target);
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
const match = line.match(/^rg: (.*): .* \(os error \d+\)$/);
|
|
85
|
+
if (!match) return null;
|
|
86
|
+
const reported = path.resolve(match[1]);
|
|
87
|
+
const relative = path.relative(root, reported);
|
|
88
|
+
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
89
|
+
}
|
|
90
|
+
return lines;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function walk(target, options, onFile, isRoot = true) {
|
|
94
|
+
let info;
|
|
95
|
+
try {
|
|
96
|
+
info = await stat(target);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (isRoot) fail(`Search path not found: ${displayPath(target)}`);
|
|
99
|
+
options.onWarning?.(error);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
75
102
|
if (info.isFile()) { await onFile(target); return; }
|
|
76
|
-
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = await readdir(target, { withFileTypes: true });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (isRoot) throw error;
|
|
108
|
+
options.onWarning?.(error);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
77
111
|
for (const entry of entries) {
|
|
78
112
|
if (options.stopped()) return;
|
|
79
113
|
if (!options.includeHidden && entry.name.startsWith('.')) continue;
|
|
80
114
|
const child = path.join(target, entry.name);
|
|
81
115
|
if (entry.isDirectory()) {
|
|
82
116
|
if (options.skipDirectories.has(entry.name)) continue;
|
|
83
|
-
await walk(child, options, onFile);
|
|
117
|
+
await walk(child, options, onFile, false);
|
|
84
118
|
} else if (entry.isFile()) {
|
|
85
119
|
await onFile(child);
|
|
86
120
|
}
|
|
@@ -130,8 +164,13 @@ function runRipgrep(session, { path: target, pattern, searchType, filePattern, i
|
|
|
130
164
|
child.on('error', error => finishSearchSession(session, 'failed', error.message));
|
|
131
165
|
child.on('close', code => {
|
|
132
166
|
if (session.status !== 'running') { finishSearchSession(session, session.status); return; }
|
|
133
|
-
// ripgrep exits 2 for
|
|
134
|
-
|
|
167
|
+
// ripgrep exits 2 both for fatal invocation errors and for partial traversal errors.
|
|
168
|
+
// Descendant OS errors are recoverable: preserve readable matches and surface a warning.
|
|
169
|
+
if (code === 2 && stderr.trim()) {
|
|
170
|
+
const warnings = ripgrepTraversalWarnings(stderr, target);
|
|
171
|
+
if (!warnings) { finishSearchSession(session, 'failed', stderr.trim().split(/\r?\n/)[0]); return; }
|
|
172
|
+
rememberSearchWarning(session, warnings.length === 1 ? warnings[0] : `${warnings[0]} (+${warnings.length - 1} more)`);
|
|
173
|
+
}
|
|
135
174
|
finishSearchSession(session, capped ? 'capped' : 'completed');
|
|
136
175
|
});
|
|
137
176
|
}
|
|
@@ -141,7 +180,12 @@ async function runFallback(session, { path: target, matcher, searchType, filePat
|
|
|
141
180
|
const nameGlob = searchType === 'files' ? globToRegExp(fileNameGlob(session.pattern)) : null;
|
|
142
181
|
const nameLiteral = searchType === 'files' && !/[*?[\]{}]/.test(session.pattern) ? session.pattern : null;
|
|
143
182
|
let collected = 0;
|
|
144
|
-
await walk(target, {
|
|
183
|
+
await walk(target, {
|
|
184
|
+
includeHidden,
|
|
185
|
+
skipDirectories: includeIgnored ? new Set() : SKIP_DIRECTORIES,
|
|
186
|
+
stopped: () => session.status !== 'running' || collected >= maxResults,
|
|
187
|
+
onWarning: warning => rememberSearchWarning(session, warning),
|
|
188
|
+
}, async file => {
|
|
145
189
|
if (session.status !== 'running' || collected >= maxResults) return;
|
|
146
190
|
if (fileGlobs.length && !fileGlobs.some(glob => glob.test(path.basename(file)))) return;
|
|
147
191
|
if (searchType === 'files') {
|
|
@@ -152,10 +196,22 @@ async function runFallback(session, { path: target, matcher, searchType, filePat
|
|
|
152
196
|
appendSearchResults(session, [displayPath(file)]);
|
|
153
197
|
return;
|
|
154
198
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
199
|
+
let handle;
|
|
200
|
+
try {
|
|
201
|
+
handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
202
|
+
} catch (error) {
|
|
203
|
+
rememberSearchWarning(session, error);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
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
|
+
}
|
|
214
|
+
if (looksBinary(buffer)) return;
|
|
159
215
|
const lines = splitLines(buffer.toString('utf8'));
|
|
160
216
|
for (let index = 0; index < lines.length; index += 1) {
|
|
161
217
|
if (session.status !== 'running' || collected >= maxResults) return;
|
|
@@ -202,6 +258,7 @@ export async function startSearchTool(args) {
|
|
|
202
258
|
return text([
|
|
203
259
|
`searchId: ${session.id} · type: ${searchType} · status: ${status} · results so far: ${session.results.length}`,
|
|
204
260
|
`pattern: ${pattern} · path: ${displayPath(target)}`,
|
|
261
|
+
session.warning ? `warning: ${session.warning}` : '',
|
|
205
262
|
initial.join('\n'),
|
|
206
263
|
session.results.length > initial.length ? `… ${session.results.length - initial.length} more results buffered; use get_more_search_results with searchId ${session.id}` : '',
|
|
207
264
|
].filter(Boolean).join('\n'));
|
|
@@ -226,6 +283,7 @@ export async function getMoreSearchResultsTool(args) {
|
|
|
226
283
|
const status = session.error ? `failed: ${session.error}` : session.status;
|
|
227
284
|
return text([
|
|
228
285
|
`searchId: ${session.id} · status: ${status} · results ${start}-${end} of ${total}`,
|
|
286
|
+
session.warning ? `warning: ${session.warning}` : '',
|
|
229
287
|
session.results.slice(start, end).join('\n'),
|
|
230
288
|
].filter(Boolean).join('\n'));
|
|
231
289
|
}
|