@remcp/runtime 0.2.108 → 0.2.110
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 +13 -0
- package/README.md +9 -4
- 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 +582 -210
- package/src/tools/search.mjs +84 -41
- package/src/util.mjs +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.110 — 2026-09-25
|
|
4
|
+
|
|
5
|
+
- Bulk handlers (`read_multiple_files`, `read_files`, `replace_in_files`, `write_files`, `copy_paths`, `move_paths`, `delete_paths`, `create_archive`) and the directory walk now honour client cancellation between items, so an aborted call stops at the next item boundary instead of finishing on the device; single-file and external-tool (`tar`/`unzip`) operations remain atomic and are not interrupted mid-step.
|
|
6
|
+
- A file with more than one hard link now produces one warning per path: path-based confinement cannot see a shared inode, and refusing would break backup and dotfile layouts.
|
|
7
|
+
- Runtime tool names, input schemas and output schemas are unchanged.
|
|
8
|
+
|
|
9
|
+
## 0.2.109 — 2026-09-25
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
- Linux directory walks, listings, and search sessions pin allowed roots and parent descriptors, including bulk reads and edits, so ancestor swaps cannot redirect traversal.
|
|
13
|
+
- `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.
|
|
14
|
+
- Production CDP sessions reject dead sockets, recursively guard child targets and evict failed guarded new-tab sessions. Runtime tool names and schemas are unchanged.
|
|
15
|
+
|
|
3
16
|
## 0.2.108 — 2026-09-25
|
|
4
17
|
|
|
5
18
|
- 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/README.md
CHANGED
|
@@ -99,10 +99,10 @@ UI actions and the XDG Screenshot portal remain separate from that low-level inp
|
|
|
99
99
|
- file writes replace by default (`mode: "append"` to add), moves and copies replace the destination
|
|
100
100
|
(`overwrite: false` refuses instead), `replace_in_files` applies immediately (`dry_run: true`
|
|
101
101
|
previews), and `move_to_trash` is there when you want an undo;
|
|
102
|
-
- the destructive-command guardrail defaults to `
|
|
103
|
-
when it matches the
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
- the destructive-command guardrail defaults to `block`: a catastrophic command is refused before it
|
|
103
|
+
runs when it matches the list (`mkfs`, raw device writes, repartitioning, host power control, fork
|
|
104
|
+
bombs, recursive root deletion). `warn` downgrades that to a note on the result, `allow` removes
|
|
105
|
+
even the note, and `blockedCommands` adds your own deny list;
|
|
106
106
|
- `allowedRoots` is empty, so the device reaches everything the agent's account can reach. Set it to
|
|
107
107
|
confine a device to specific directories, enforced against the resolved real path.
|
|
108
108
|
|
|
@@ -169,6 +169,11 @@ in `~/.config/remcp/runtime.json`. `--describe` always reports the current state
|
|
|
169
169
|
descriptor-bound directory traversal on Linux. macOS and Windows preserve the same bounded snapshot,
|
|
170
170
|
link/special-file rejection, canonical root confinement and atomic staging semantics, while using their
|
|
171
171
|
portable path APIs where Node does not expose directory-fd-relative open/rename/unlink primitives.
|
|
172
|
+
Two consequences are worth knowing: on macOS and Windows `set_permissions` re-resolves each path
|
|
173
|
+
and then chmods it, so a component swap between those two steps is not descriptor-bound, and a file
|
|
174
|
+
with more than one hard link (`st_nlink > 1`) is reachable from outside the allowed roots no matter
|
|
175
|
+
which path was used. The runtime warns once per hard-linked path instead of refusing it, because
|
|
176
|
+
backups and dotfile managers create those layouts legitimately.
|
|
172
177
|
- **Crash-resistant sessions.** A bogus shell, a closed stdin, or a dead parent cannot take the
|
|
173
178
|
runtime down; sessions run in their own process group so `force_terminate` stops a whole pipeline;
|
|
174
179
|
the agent restarts the runtime if it ever exits, so a device recovers instead of going silently
|
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) {
|