@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.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/extended/browser.mjs +89 -22
- package/src/extended/diagnostics.mjs +88 -27
- package/src/extended/documents.mjs +11 -75
- package/src/screenshot-portal.mjs +115 -6
- package/src/sessions.mjs +4 -0
- package/src/tools/files.mjs +533 -198
- package/src/tools/search.mjs +84 -41
- package/src/util.mjs +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.109 — 2026-09-25
|
|
4
|
+
|
|
5
|
+
- Linux file reads and recursive permission changes retain descriptor-bound parent traversal for the full operation; proc-fd anchored captures remain readable without reopening the path lexically.
|
|
6
|
+
- Linux directory walks, listings, and search sessions pin allowed roots and parent descriptors, including bulk reads and edits, so ancestor swaps cannot redirect traversal.
|
|
7
|
+
- `record_screen` publishes from private staging and anchors Linux destination parents; XDG portal screenshots use no-follow copying, reject symlink source/destination paths, and no longer delete an untrusted portal-returned URI.
|
|
8
|
+
- Production CDP sessions reject dead sockets, recursively guard child targets and evict failed guarded new-tab sessions. Runtime tool names and schemas are unchanged.
|
|
9
|
+
|
|
3
10
|
## 0.2.108 — 2026-09-25
|
|
4
11
|
|
|
5
12
|
- Standalone production runtimes keep browser control disabled unless explicitly enabled; the authenticated paired agent enables its owned local runtime. IP-literal production allowlisting and persistent CDP guards cover page and worker sessions, delayed navigation, redirects, and subresources.
|
package/package.json
CHANGED
package/src/extended/browser.mjs
CHANGED
|
@@ -495,15 +495,16 @@ class CdpSession {
|
|
|
495
495
|
}
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
-
async function installBrowserRequestGuard(session) {
|
|
498
|
+
async function installBrowserRequestGuard(session, { closeRootTarget = async () => {} } = {}) {
|
|
499
499
|
const guardedSessions = new Set();
|
|
500
500
|
const childSetup = new Map();
|
|
501
|
-
const enableSession = async sessionId => {
|
|
501
|
+
const enableSession = async (sessionId, { autoAttach = false } = {}) => {
|
|
502
502
|
if (guardedSessions.has(sessionId)) return;
|
|
503
503
|
try {
|
|
504
504
|
await session.send('Network.enable', {}, sessionId, 2000);
|
|
505
505
|
await session.send('Network.setBlockedURLs', { urls:['ws://*', 'wss://*'] }, sessionId, 2000);
|
|
506
506
|
await session.send('Fetch.enable', { patterns:[{ urlPattern:'*' }] }, sessionId, 2000);
|
|
507
|
+
if (autoAttach) await session.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }, sessionId, 2000);
|
|
507
508
|
guardedSessions.add(sessionId);
|
|
508
509
|
} catch (error) {
|
|
509
510
|
await session.send('Fetch.disable', {}, sessionId, 1000).catch(() => {});
|
|
@@ -516,11 +517,18 @@ async function installBrowserRequestGuard(session) {
|
|
|
516
517
|
if (childSetup.has(childSessionId)) return childSetup.get(childSessionId);
|
|
517
518
|
const setup = (async () => {
|
|
518
519
|
try {
|
|
519
|
-
await enableSession(childSessionId);
|
|
520
|
+
await enableSession(childSessionId, { autoAttach:process.env.NODE_ENV === 'production' });
|
|
520
521
|
await session.send('Runtime.runIfWaitingForDebugger', {}, childSessionId, 2000);
|
|
521
|
-
} catch {
|
|
522
|
+
} catch (error) {
|
|
522
523
|
const targetId = params?.targetInfo?.targetId;
|
|
523
|
-
|
|
524
|
+
let closed = false;
|
|
525
|
+
if (targetId) {
|
|
526
|
+
try {
|
|
527
|
+
await session.send('Target.closeTarget', { targetId }, '', 2000);
|
|
528
|
+
closed = true;
|
|
529
|
+
} catch {}
|
|
530
|
+
}
|
|
531
|
+
if (!closed) throw new Error(`Browser child guard failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
524
532
|
}
|
|
525
533
|
})();
|
|
526
534
|
childSetup.set(childSessionId, setup);
|
|
@@ -543,16 +551,31 @@ async function installBrowserRequestGuard(session) {
|
|
|
543
551
|
})();
|
|
544
552
|
});
|
|
545
553
|
const removeAttached = session.observe('Target.attachedToTarget', params => {
|
|
546
|
-
void guardChild(params)
|
|
554
|
+
void guardChild(params).catch(async () => {
|
|
555
|
+
await closeRootTarget().catch(() => {});
|
|
556
|
+
session.close();
|
|
557
|
+
});
|
|
547
558
|
});
|
|
548
559
|
await enableSession('');
|
|
549
560
|
if (process.env.NODE_ENV === 'production') {
|
|
550
561
|
await session.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }, '', 2000);
|
|
551
|
-
|
|
562
|
+
let existing;
|
|
563
|
+
try {
|
|
564
|
+
existing = await session.send('Target.getTargets', {}, '', 2000);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
throw new Error(`Could not enumerate browser child targets safely: ${error instanceof Error ? error.message : String(error)}`);
|
|
567
|
+
}
|
|
552
568
|
for (const targetInfo of existing.targetInfos || []) {
|
|
553
569
|
if (!['worker', 'service_worker', 'shared_worker', 'iframe', 'webview'].includes(targetInfo.type)) continue;
|
|
554
|
-
const attached = await session.send('Target.attachToTarget', { targetId:targetInfo.targetId, flatten:true }, '', 2000).catch(
|
|
570
|
+
const attached = await session.send('Target.attachToTarget', { targetId:targetInfo.targetId, flatten:true }, '', 2000).catch(async error => {
|
|
571
|
+
await session.send('Target.closeTarget', { targetId:targetInfo.targetId }, '', 2000).catch(() => {});
|
|
572
|
+
throw new Error(`Could not attach browser child target safely: ${error instanceof Error ? error.message : String(error)}`);
|
|
573
|
+
});
|
|
555
574
|
if (attached?.sessionId) await guardChild({ sessionId:attached.sessionId, targetInfo });
|
|
575
|
+
else {
|
|
576
|
+
await session.send('Target.closeTarget', { targetId:targetInfo.targetId }, '', 2000).catch(() => {});
|
|
577
|
+
throw new Error('Browser child target did not expose a guard session');
|
|
578
|
+
}
|
|
556
579
|
}
|
|
557
580
|
}
|
|
558
581
|
return () => {
|
|
@@ -562,6 +585,17 @@ async function installBrowserRequestGuard(session) {
|
|
|
562
585
|
};
|
|
563
586
|
}
|
|
564
587
|
|
|
588
|
+
async function closeBrowserTarget(endpoint, targetId) {
|
|
589
|
+
if (!targetId) return false;
|
|
590
|
+
try {
|
|
591
|
+
const base = endpoint instanceof URL ? endpoint.href : endpoint;
|
|
592
|
+
await requestJson(`/json/close/${encodeURIComponent(String(targetId))}`, base, { timeout_ms:2000 });
|
|
593
|
+
return true;
|
|
594
|
+
} catch {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
565
599
|
function productionSessionKey(endpoint, target) {
|
|
566
600
|
return `${endpoint.origin}|${String(target.id)}`;
|
|
567
601
|
}
|
|
@@ -570,15 +604,19 @@ function closeProductionSession(key, record) {
|
|
|
570
604
|
if (!record) return;
|
|
571
605
|
if (record.timer) clearTimeout(record.timer);
|
|
572
606
|
record.removeGuard();
|
|
607
|
+
if (record.ownedTarget && record.targetId) {
|
|
608
|
+
record.session.send('Target.closeTarget', { targetId:record.targetId }, '', 2000).catch(() => {});
|
|
609
|
+
if (record.endpoint) void closeBrowserTarget(record.endpoint, record.targetId);
|
|
610
|
+
}
|
|
573
611
|
record.session.send('Fetch.disable').catch(() => {});
|
|
574
612
|
record.session.close();
|
|
575
613
|
if (productionBrowserSessions.get(key) === record) productionBrowserSessions.delete(key);
|
|
576
614
|
}
|
|
577
615
|
|
|
578
|
-
function retainProductionSession(key, session, removeGuard) {
|
|
616
|
+
function retainProductionSession(key, session, removeGuard, targetId, endpoint, ownedTarget = false) {
|
|
579
617
|
const existing = productionBrowserSessions.get(key);
|
|
580
618
|
if (existing) closeProductionSession(key, existing);
|
|
581
|
-
const record = { session, removeGuard, timer:null };
|
|
619
|
+
const record = { session, removeGuard, targetId, endpoint, ownedTarget, timer:null };
|
|
582
620
|
productionBrowserSessions.set(key, record);
|
|
583
621
|
const schedule = () => {
|
|
584
622
|
if (record.timer) clearTimeout(record.timer);
|
|
@@ -595,26 +633,46 @@ async function createGuardedNewTab(url, args) {
|
|
|
595
633
|
const created = await requestJson('/json/new', args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
|
|
596
634
|
if (!created?.id) throw new Error('Browser did not return a new page target');
|
|
597
635
|
const endpoint = endpointUrl(args.endpoint);
|
|
598
|
-
|
|
599
|
-
|
|
636
|
+
let target;
|
|
637
|
+
try {
|
|
638
|
+
target = { ...created, webSocketDebuggerUrl: validatedTargetWebSocketUrl(created, endpoint) };
|
|
639
|
+
} catch (error) {
|
|
640
|
+
await closeBrowserTarget(endpoint, created.id);
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
let session;
|
|
644
|
+
try {
|
|
645
|
+
session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
|
|
646
|
+
} catch (error) {
|
|
647
|
+
await closeBrowserTarget(endpoint, target.id);
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
600
650
|
let removeGuard = () => {};
|
|
601
651
|
let productionRecord = null;
|
|
602
652
|
let productionKey = '';
|
|
653
|
+
let operationFailed = false;
|
|
603
654
|
try {
|
|
604
|
-
removeGuard = await installBrowserRequestGuard(session);
|
|
655
|
+
removeGuard = await installBrowserRequestGuard(session, { closeRootTarget:() => closeBrowserTarget(endpoint, target.id) });
|
|
605
656
|
if (process.env.NODE_ENV === 'production') {
|
|
606
657
|
productionKey = productionSessionKey(endpoint, target);
|
|
607
|
-
productionRecord = retainProductionSession(productionKey, session, removeGuard);
|
|
658
|
+
productionRecord = retainProductionSession(productionKey, session, removeGuard, target.id, endpoint, true);
|
|
608
659
|
}
|
|
609
660
|
await session.send('Page.enable');
|
|
610
661
|
const loaded = session.waitFor('Page.loadEventFired', clamp(args.timeout_ms, 15_000, 500, 120_000)).catch(() => null);
|
|
611
662
|
const navigation = await session.send('Page.navigate', { url });
|
|
612
663
|
if (loaded) await loaded;
|
|
613
|
-
const finalUrl = await evaluate(session, 'location.href')
|
|
664
|
+
const finalUrl = await evaluate(session, 'location.href');
|
|
614
665
|
await assertResolvedPageUrl(finalUrl);
|
|
615
666
|
return { target, navigation, finalUrl };
|
|
667
|
+
} catch (error) {
|
|
668
|
+
operationFailed = true;
|
|
669
|
+
if (!productionRecord) await closeBrowserTarget(endpoint, target.id);
|
|
670
|
+
throw error;
|
|
616
671
|
} finally {
|
|
617
|
-
if (productionRecord)
|
|
672
|
+
if (productionRecord) {
|
|
673
|
+
if (operationFailed) closeProductionSession(productionKey, productionRecord);
|
|
674
|
+
else productionRecord.schedule();
|
|
675
|
+
}
|
|
618
676
|
else {
|
|
619
677
|
removeGuard();
|
|
620
678
|
await session.send('Fetch.disable').catch(() => {});
|
|
@@ -637,26 +695,35 @@ async function withTarget(args, callback) {
|
|
|
637
695
|
let session = record?.session;
|
|
638
696
|
let removeGuard = record?.removeGuard || (() => {});
|
|
639
697
|
if (!session) {
|
|
640
|
-
session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
|
|
641
698
|
try {
|
|
699
|
+
session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
|
|
642
700
|
removeGuard = await installBrowserRequestGuard(session);
|
|
643
|
-
if (production) record = retainProductionSession(key, session, removeGuard);
|
|
701
|
+
if (production) record = retainProductionSession(key, session, removeGuard, target.id, endpoint);
|
|
644
702
|
} catch (error) {
|
|
645
703
|
removeGuard();
|
|
646
|
-
|
|
647
|
-
|
|
704
|
+
if (session) {
|
|
705
|
+
await session.send('Fetch.disable').catch(() => {});
|
|
706
|
+
session.close();
|
|
707
|
+
}
|
|
648
708
|
throw error;
|
|
649
709
|
}
|
|
650
710
|
}
|
|
711
|
+
let operationFailed = false;
|
|
651
712
|
try {
|
|
652
|
-
const currentUrl = await evaluate(session, 'location.href')
|
|
713
|
+
const currentUrl = await evaluate(session, 'location.href');
|
|
653
714
|
await assertResolvedPageUrl(currentUrl);
|
|
654
715
|
const result = await callback(session, target);
|
|
655
716
|
const finalUrl = await evaluate(session, 'location.href').catch(() => { throw new Error('Browser page URL could not be verified after the operation'); });
|
|
656
717
|
await assertResolvedPageUrl(finalUrl);
|
|
657
718
|
return result;
|
|
719
|
+
} catch (error) {
|
|
720
|
+
operationFailed = true;
|
|
721
|
+
throw error;
|
|
658
722
|
} finally {
|
|
659
|
-
if (production && record)
|
|
723
|
+
if (production && record) {
|
|
724
|
+
if (operationFailed) closeProductionSession(key, record);
|
|
725
|
+
else record.schedule();
|
|
726
|
+
}
|
|
660
727
|
else {
|
|
661
728
|
removeGuard();
|
|
662
729
|
await session.send('Fetch.disable').catch(() => {});
|
|
@@ -3,10 +3,12 @@ import net from 'node:net';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import process from 'node:process';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { constants, existsSync } from 'node:fs';
|
|
8
|
+
import { lstat, mkdtemp, open, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
8
9
|
import { assertAllowedCommand } from '../policy.mjs';
|
|
9
10
|
import { isWaylandSession } from '../screenshot-portal.mjs';
|
|
11
|
+
import { openDirectoryPath } from '../tools/files.mjs';
|
|
10
12
|
import { resolveSafePath, text } from '../util.mjs';
|
|
11
13
|
import {
|
|
12
14
|
clamp,
|
|
@@ -297,38 +299,97 @@ export function recordScreenAvailable({
|
|
|
297
299
|
return false;
|
|
298
300
|
}
|
|
299
301
|
|
|
302
|
+
async function openRecordingParent(destination) {
|
|
303
|
+
return openDirectoryPath(path.dirname(destination));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function copyRecordingFile(source, destination) {
|
|
307
|
+
let input;
|
|
308
|
+
let output;
|
|
309
|
+
let parent;
|
|
310
|
+
let temporary;
|
|
311
|
+
try {
|
|
312
|
+
input = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
313
|
+
const sourceInfo = await input.stat();
|
|
314
|
+
if (!sourceInfo.isFile()) throw new Error('Screen recording staging path is not a regular file');
|
|
315
|
+
parent = await openRecordingParent(destination);
|
|
316
|
+
const anchoredDestination = path.join(parent.anchor, path.basename(destination));
|
|
317
|
+
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
318
|
+
if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
|
|
319
|
+
if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
|
|
320
|
+
temporary = path.join(parent.anchor, `.${path.basename(destination)}.${randomUUID()}.tmp`);
|
|
321
|
+
output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
322
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
323
|
+
let offset = 0;
|
|
324
|
+
for (;;) {
|
|
325
|
+
const { bytesRead } = await input.read(buffer, 0, buffer.length, offset);
|
|
326
|
+
if (!bytesRead) break;
|
|
327
|
+
let written = 0;
|
|
328
|
+
while (written < bytesRead) {
|
|
329
|
+
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
330
|
+
if (!result.bytesWritten) throw new Error('Could not finish writing the screen recording');
|
|
331
|
+
written += result.bytesWritten;
|
|
332
|
+
}
|
|
333
|
+
offset += bytesRead;
|
|
334
|
+
}
|
|
335
|
+
await output.sync();
|
|
336
|
+
await output.close();
|
|
337
|
+
output = null;
|
|
338
|
+
await rename(temporary, anchoredDestination);
|
|
339
|
+
temporary = null;
|
|
340
|
+
} finally {
|
|
341
|
+
if (output) await output.close().catch(() => {});
|
|
342
|
+
if (temporary) await unlink(temporary).catch(() => {});
|
|
343
|
+
if (input) await input.close().catch(() => {});
|
|
344
|
+
if (parent) await parent.close().catch(() => {});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
300
348
|
export async function recordScreen(args) {
|
|
301
349
|
const seconds = clamp(args.duration_seconds, 5, 1, 120);
|
|
302
350
|
const fps = clamp(args.fps, 15, 1, 60);
|
|
303
|
-
const
|
|
351
|
+
const requestedDestination = args.destination ? await resolveSafePath(args.destination, 'destination') : '';
|
|
352
|
+
if (requestedDestination) {
|
|
353
|
+
const existing = await lstat(requestedDestination).catch(() => null);
|
|
354
|
+
if (existing?.isSymbolicLink()) throw new Error('Screen recording destination cannot be a symbolic link');
|
|
355
|
+
if (existing && !existing.isFile()) throw new Error('Screen recording destination is not a regular file');
|
|
356
|
+
}
|
|
357
|
+
const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-screen-'));
|
|
358
|
+
const staged = path.join(stagingDirectory, `capture-${randomUUID()}.mp4`);
|
|
304
359
|
const wayland = process.platform === 'linux' && isWaylandSession();
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
360
|
+
let keepStaging = false;
|
|
361
|
+
try {
|
|
362
|
+
if (wayland && commandExists('wf-recorder') && commandExists('timeout')) {
|
|
363
|
+
const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',staged,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
|
|
364
|
+
if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
|
|
365
|
+
} else {
|
|
366
|
+
const ffmpeg = resolveRecordScreenFfmpeg();
|
|
367
|
+
if (wayland) unavailable('Screen recording', 'wf-recorder and timeout are required on Wayland');
|
|
368
|
+
if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
|
|
369
|
+
if (!ffmpeg) {
|
|
370
|
+
if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
|
|
371
|
+
if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
|
|
372
|
+
unavailable('Screen recording', 'ffmpeg is required on X11');
|
|
373
|
+
}
|
|
374
|
+
let argv;
|
|
375
|
+
if (process.platform === 'win32') argv=['-y','-f','gdigrab','-framerate',String(fps),'-i','desktop','-t',String(seconds),'-pix_fmt','yuv420p',staged];
|
|
376
|
+
else if (process.platform === 'darwin') {
|
|
377
|
+
const input = await avfoundationScreenInput(ffmpeg);
|
|
378
|
+
argv=['-y','-f','avfoundation','-framerate',String(fps),'-i',`${input}:none`,'-t',String(seconds),'-pix_fmt','yuv420p',staged];
|
|
379
|
+
} else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY,'-t',String(seconds),'-pix_fmt','yuv420p',staged];
|
|
380
|
+
await runFile(ffmpeg, argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
|
|
320
381
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const input = await avfoundationScreenInput(ffmpeg);
|
|
325
|
-
argv=['-y','-f','avfoundation','-framerate',String(fps),'-i',`${input}:none`,'-t',String(seconds),'-pix_fmt','yuv420p',destination];
|
|
382
|
+
if (requestedDestination) {
|
|
383
|
+
await copyRecordingFile(staged, requestedDestination);
|
|
384
|
+
await rm(stagingDirectory, { recursive:true, force:true });
|
|
326
385
|
}
|
|
327
|
-
|
|
328
|
-
|
|
386
|
+
const output = requestedDestination || staged;
|
|
387
|
+
const info = await stat(output);
|
|
388
|
+
keepStaging = !requestedDestination;
|
|
389
|
+
return jsonResult({ path:output, bytes:info.size, duration_seconds:seconds, format:path.extname(output).slice(1) || 'mp4' });
|
|
390
|
+
} finally {
|
|
391
|
+
if (!keepStaging) await rm(stagingDirectory, { recursive:true, force:true }).catch(() => {});
|
|
329
392
|
}
|
|
330
|
-
const info = await stat(destination);
|
|
331
|
-
return jsonResult({ path:destination, bytes:info.size, duration_seconds:seconds, format:path.extname(destination).slice(1) || 'mp4' });
|
|
332
393
|
}
|
|
333
394
|
|
|
334
395
|
export const diagnosticHandlers = {
|
|
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { constants } from 'node:fs';
|
|
5
5
|
import { copyFile, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { readDocxText, readPdfText } from '../documents.mjs';
|
|
7
|
+
import { openDirectoryPath } from '../tools/files.mjs';
|
|
7
8
|
import { resolveSafePath, text } from '../util.mjs';
|
|
8
9
|
import {
|
|
9
10
|
clamp,
|
|
@@ -30,7 +31,6 @@ const MAX_OOXML_ENTRIES = 10_000;
|
|
|
30
31
|
const MAX_OOXML_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
|
|
31
32
|
const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
|
|
32
33
|
const DOCUMENT_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
|
|
33
|
-
const DOCUMENT_DIRECTORY_FLAGS = constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
|
|
34
34
|
|
|
35
35
|
function descriptorAnchorsAvailable() {
|
|
36
36
|
return process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
|
|
@@ -48,93 +48,29 @@ async function openDocumentSource(filePath) {
|
|
|
48
48
|
throw error;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
if (!parts.length) throw new Error('Document path must be a regular file');
|
|
54
|
-
const directories = [];
|
|
55
|
-
let current = root;
|
|
56
|
-
const closeDirectories = async () => {
|
|
57
|
-
for (const handle of directories.reverse()) await handle.close().catch(() => {});
|
|
58
|
-
};
|
|
51
|
+
const parent = await openDirectoryPath(path.dirname(filePath));
|
|
52
|
+
let handle;
|
|
59
53
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (!info.isDirectory()) {
|
|
64
|
-
await handle.close().catch(() => {});
|
|
65
|
-
throw new Error('Document path parent is not a directory');
|
|
66
|
-
}
|
|
67
|
-
directories.push(handle);
|
|
68
|
-
current = `/proc/self/fd/${handle.fd}`;
|
|
69
|
-
}
|
|
70
|
-
const handle = await open(path.join(current, parts.at(-1)), DOCUMENT_READ_FLAGS);
|
|
54
|
+
handle = await open(path.join(parent.anchor, path.basename(filePath)), DOCUMENT_READ_FLAGS);
|
|
55
|
+
const info = await handle.stat();
|
|
56
|
+
if (!info.isFile()) throw new Error('Document path must be a regular file');
|
|
71
57
|
return {
|
|
72
58
|
handle,
|
|
73
59
|
close: async () => {
|
|
74
60
|
try { await handle.close(); }
|
|
75
|
-
finally { await
|
|
61
|
+
finally { await parent.close().catch(() => {}); }
|
|
76
62
|
},
|
|
77
63
|
};
|
|
78
64
|
} catch (error) {
|
|
79
|
-
await
|
|
65
|
+
if (handle) await handle.close().catch(() => {});
|
|
66
|
+
await parent.close().catch(() => {});
|
|
80
67
|
throw error;
|
|
81
68
|
}
|
|
82
69
|
}
|
|
83
70
|
|
|
84
71
|
async function openDocumentDirectoryPath(directory, { create = false } = {}) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const handle = await open(directory, DOCUMENT_DIRECTORY_FLAGS);
|
|
88
|
-
try {
|
|
89
|
-
const info = await handle.stat();
|
|
90
|
-
if (!info.isDirectory()) throw new Error('Document path parent is not a directory');
|
|
91
|
-
return { handle, anchor:directory, descriptorBound:false, close: () => handle.close() };
|
|
92
|
-
} catch (error) {
|
|
93
|
-
await handle.close().catch(() => {});
|
|
94
|
-
throw error;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
const root = path.parse(directory).root;
|
|
98
|
-
const parts = directory.slice(root.length).split(path.sep).filter(Boolean);
|
|
99
|
-
if (!parts.length) {
|
|
100
|
-
const handle = await open(root, DOCUMENT_DIRECTORY_FLAGS);
|
|
101
|
-
return { anchor: `/proc/self/fd/${handle.fd}`, close: () => handle.close() };
|
|
102
|
-
}
|
|
103
|
-
const directories = [];
|
|
104
|
-
let current = root;
|
|
105
|
-
const closeDirectories = async () => {
|
|
106
|
-
for (const handle of directories.reverse()) await handle.close().catch(() => {});
|
|
107
|
-
};
|
|
108
|
-
try {
|
|
109
|
-
for (const part of parts) {
|
|
110
|
-
const candidate = path.join(current, part);
|
|
111
|
-
if (create) {
|
|
112
|
-
await mkdir(candidate, { mode:0o700 }).catch(error => {
|
|
113
|
-
if (error?.code !== 'EEXIST') throw error;
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
const handle = await open(candidate, DOCUMENT_DIRECTORY_FLAGS);
|
|
117
|
-
const info = await handle.stat();
|
|
118
|
-
if (!info.isDirectory()) {
|
|
119
|
-
await handle.close().catch(() => {});
|
|
120
|
-
throw new Error('Document path parent is not a directory');
|
|
121
|
-
}
|
|
122
|
-
directories.push(handle);
|
|
123
|
-
current = `/proc/self/fd/${handle.fd}`;
|
|
124
|
-
}
|
|
125
|
-
const handle = directories.pop();
|
|
126
|
-
if (!handle) throw new Error('Document directory path is empty');
|
|
127
|
-
return {
|
|
128
|
-
anchor: current,
|
|
129
|
-
close: async () => {
|
|
130
|
-
try { await handle.close(); }
|
|
131
|
-
finally { await closeDirectories(); }
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
} catch (error) {
|
|
135
|
-
await closeDirectories();
|
|
136
|
-
throw error;
|
|
137
|
-
}
|
|
72
|
+
const opened = await openDirectoryPath(directory, { create });
|
|
73
|
+
return { ...opened, descriptorBound:opened.anchor !== directory || process.platform === 'linux' };
|
|
138
74
|
}
|
|
139
75
|
|
|
140
76
|
async function createAnchoredTempDirectory(parent, prefix) {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
|
-
import
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { lstat, open, rename, unlink } from 'node:fs/promises';
|
|
3
6
|
import { fileURLToPath } from 'node:url';
|
|
4
7
|
|
|
5
8
|
const PORTAL_NAME = 'org.freedesktop.portal.Desktop';
|
|
@@ -133,6 +136,115 @@ function createResponseCollector(bus, dbus, initialPath, timeoutMs) {
|
|
|
133
136
|
};
|
|
134
137
|
}
|
|
135
138
|
|
|
139
|
+
async function openDirectoryNoFollow(directory) {
|
|
140
|
+
if (process.platform !== 'linux' || !constants.O_NOFOLLOW || !constants.O_DIRECTORY) {
|
|
141
|
+
const info = await lstat(directory);
|
|
142
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('screenshot path parent is not a safe directory');
|
|
143
|
+
return { anchor:directory, close:async() => {} };
|
|
144
|
+
}
|
|
145
|
+
const descriptor = directory.match(/^(\/proc\/(?:self|\d+)\/fd\/\d+)(?:\/(.*))?$/);
|
|
146
|
+
const directories = [];
|
|
147
|
+
let current;
|
|
148
|
+
try {
|
|
149
|
+
if (descriptor) {
|
|
150
|
+
const base = await open(`${descriptor[1]}/.`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
151
|
+
directories.push(base);
|
|
152
|
+
current = `/proc/self/fd/${base.fd}`;
|
|
153
|
+
} else {
|
|
154
|
+
const root = path.parse(directory).root;
|
|
155
|
+
current = root;
|
|
156
|
+
const parts = directory.slice(root.length).split(path.sep).filter(Boolean);
|
|
157
|
+
if (!parts.length) {
|
|
158
|
+
const handle = await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
159
|
+
return { anchor:`/proc/self/fd/${handle.fd}`, close:() => handle.close() };
|
|
160
|
+
}
|
|
161
|
+
for (const part of parts) {
|
|
162
|
+
const handle = await open(path.join(current, part), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
163
|
+
const info = await handle.stat();
|
|
164
|
+
if (!info.isDirectory()) {
|
|
165
|
+
await handle.close().catch(() => {});
|
|
166
|
+
throw new Error('screenshot path parent is not a safe directory');
|
|
167
|
+
}
|
|
168
|
+
directories.push(handle);
|
|
169
|
+
current = `/proc/self/fd/${handle.fd}`;
|
|
170
|
+
}
|
|
171
|
+
const handle = directories.pop();
|
|
172
|
+
return {
|
|
173
|
+
anchor:current,
|
|
174
|
+
close:async() => {
|
|
175
|
+
try { await handle.close(); }
|
|
176
|
+
finally { for (const entry of directories.reverse()) await entry.close().catch(() => {}); }
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
for (const part of (descriptor?.[2] || '').split(path.sep).filter(Boolean)) {
|
|
181
|
+
const handle = await open(path.join(current, part), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
182
|
+
const info = await handle.stat();
|
|
183
|
+
if (!info.isDirectory()) {
|
|
184
|
+
await handle.close().catch(() => {});
|
|
185
|
+
throw new Error('screenshot path parent is not a safe directory');
|
|
186
|
+
}
|
|
187
|
+
directories.push(handle);
|
|
188
|
+
current = `/proc/self/fd/${handle.fd}`;
|
|
189
|
+
}
|
|
190
|
+
const handle = directories.pop();
|
|
191
|
+
return {
|
|
192
|
+
anchor:current,
|
|
193
|
+
close:async() => {
|
|
194
|
+
try { await handle.close(); }
|
|
195
|
+
finally { for (const entry of directories.reverse()) await entry.close().catch(() => {}); }
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
} catch (error) {
|
|
199
|
+
for (const entry of directories.reverse()) await entry.close().catch(() => {});
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function copyPortalFile(source, destination) {
|
|
205
|
+
let sourceParent;
|
|
206
|
+
let destinationParent;
|
|
207
|
+
let input;
|
|
208
|
+
let output;
|
|
209
|
+
let temporary;
|
|
210
|
+
try {
|
|
211
|
+
sourceParent = await openDirectoryNoFollow(path.dirname(source));
|
|
212
|
+
destinationParent = await openDirectoryNoFollow(path.dirname(destination));
|
|
213
|
+
input = await open(path.join(sourceParent.anchor, path.basename(source)), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
214
|
+
const info = await input.stat();
|
|
215
|
+
if (!info.isFile()) throw new Error('desktop portal returned a non-regular screenshot file');
|
|
216
|
+
const destinationPath = path.join(destinationParent.anchor, path.basename(destination));
|
|
217
|
+
const existing = await lstat(destinationPath).catch(() => null);
|
|
218
|
+
if (existing?.isSymbolicLink()) throw new Error('desktop portal screenshot destination cannot be a symbolic link');
|
|
219
|
+
temporary = path.join(destinationParent.anchor, `.${path.basename(destination)}.${randomUUID()}.tmp`);
|
|
220
|
+
output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
221
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
222
|
+
let offset = 0;
|
|
223
|
+
for (;;) {
|
|
224
|
+
const { bytesRead } = await input.read(buffer, 0, buffer.length, offset);
|
|
225
|
+
if (!bytesRead) break;
|
|
226
|
+
let written = 0;
|
|
227
|
+
while (written < bytesRead) {
|
|
228
|
+
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
229
|
+
if (!result.bytesWritten) throw new Error('Could not finish copying the desktop portal screenshot');
|
|
230
|
+
written += result.bytesWritten;
|
|
231
|
+
}
|
|
232
|
+
offset += bytesRead;
|
|
233
|
+
}
|
|
234
|
+
await output.sync();
|
|
235
|
+
await output.close();
|
|
236
|
+
output = null;
|
|
237
|
+
await rename(temporary, destinationPath);
|
|
238
|
+
temporary = null;
|
|
239
|
+
} finally {
|
|
240
|
+
if (output) await output.close().catch(() => {});
|
|
241
|
+
if (temporary) await unlink(temporary).catch(() => {});
|
|
242
|
+
if (input) await input.close().catch(() => {});
|
|
243
|
+
if (destinationParent) await destinationParent.close().catch(() => {});
|
|
244
|
+
if (sourceParent) await sourceParent.close().catch(() => {});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
136
248
|
export async function capturePortalScreenshot(destination, {
|
|
137
249
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
138
250
|
loadDbus = () => import('@jellybrick/dbus-next'),
|
|
@@ -184,12 +296,9 @@ export async function capturePortalScreenshot(destination, {
|
|
|
184
296
|
const uri = results?.uri?.value;
|
|
185
297
|
if (typeof uri !== 'string') throw new Error('desktop portal returned no screenshot URI');
|
|
186
298
|
const sourceUrl = new URL(uri);
|
|
187
|
-
if (sourceUrl.protocol !== 'file:') throw new Error(
|
|
299
|
+
if (sourceUrl.protocol !== 'file:' || (sourceUrl.hostname && sourceUrl.hostname !== 'localhost')) throw new Error('desktop portal returned an unsupported file URI');
|
|
188
300
|
const source = fileURLToPath(sourceUrl);
|
|
189
|
-
await
|
|
190
|
-
// GNOME commonly writes the portal result to ~/Pictures. The runtime owns this capture, so remove
|
|
191
|
-
// that portal-created intermediate after copying it to the caller-selected temporary location.
|
|
192
|
-
if (source !== destination) await rm(source, { force: true }).catch(() => {});
|
|
301
|
+
await copyPortalFile(source, destination);
|
|
193
302
|
return destination;
|
|
194
303
|
} catch (error) {
|
|
195
304
|
const wrapped = new Error(`xdg-desktop-portal: ${cleanError(error)}`);
|
package/src/sessions.mjs
CHANGED
|
@@ -190,6 +190,7 @@ export function createSearchSession({ type, pattern, path, filePattern }) {
|
|
|
190
190
|
error: null,
|
|
191
191
|
warning: null,
|
|
192
192
|
cancel: null,
|
|
193
|
+
release: null,
|
|
193
194
|
waiters: [],
|
|
194
195
|
lastActivityAt: Date.now(),
|
|
195
196
|
};
|
|
@@ -209,6 +210,9 @@ export function finishSearchSession(session, status, error = null) {
|
|
|
209
210
|
session.error = error ? String(error) : null;
|
|
210
211
|
session.finishedAt = Date.now();
|
|
211
212
|
session.cancel = null;
|
|
213
|
+
const release = session.release;
|
|
214
|
+
session.release = null;
|
|
215
|
+
if (release) void Promise.resolve(release()).catch(() => {});
|
|
212
216
|
notify(session);
|
|
213
217
|
}
|
|
214
218
|
|