@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 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 `warn`: the command runs and the result carries a note
103
- when it matches the catastrophic list (`mkfs`, raw device writes, repartitioning, host power
104
- control, fork bombs, recursive root deletion). `allow` removes even the note, `block` refuses
105
- before running, and `blockedCommands` adds your own deny list;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.108",
3
+ "version": "0.2.110",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- if (targetId) await session.send('Target.closeTarget', { targetId }, '', 2000).catch(() => {});
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
- const existing = await session.send('Target.getTargets').catch(() => ({ targetInfos:[] }));
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(() => null);
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
- const target = { ...created, webSocketDebuggerUrl: validatedTargetWebSocketUrl(created, endpoint) };
599
- const session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
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').catch(() => url);
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) productionRecord.schedule();
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
- await session.send('Fetch.disable').catch(() => {});
647
- session.close();
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').catch(() => target.url);
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) record.schedule();
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 { existsSync } from 'node:fs';
7
- import { stat } from 'node:fs/promises';
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 destination = args.destination ? await resolveSafePath(args.destination, 'destination') : path.join(os.tmpdir(), `remcp-screen-${Date.now()}.mp4`);
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
- if (wayland && commandExists('wf-recorder') && commandExists('timeout')) {
306
- const result = await runFile('timeout', ['--signal=INT', `${seconds}s`, 'wf-recorder', '-f',destination,'-r',String(fps),'-c','libx264'], { label:'screen recording', timeout:(seconds+10)*1000, allowFailure:true });
307
- if (![0, 124, 130].includes(Number(result.code))) throw new Error(result.stderr.trim() || `wf-recorder exited ${result.code}`);
308
- } else {
309
- const ffmpeg = resolveRecordScreenFfmpeg();
310
- if (wayland) {
311
- unavailable('Screen recording', 'wf-recorder and timeout are required on Wayland');
312
- }
313
- if (process.platform === 'linux' && !String(process.env.DISPLAY || '').trim()) {
314
- unavailable('Screen recording', 'an active X11 DISPLAY is required on Linux X11');
315
- }
316
- if (!ffmpeg) {
317
- if (process.platform === 'darwin') unavailable('Screen recording', 'ffmpeg is required on macOS');
318
- if (process.platform === 'win32') unavailable('Screen recording', 'ffmpeg is required on Windows');
319
- unavailable('Screen recording', 'ffmpeg is required on X11');
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
- let argv;
322
- if (process.platform === 'win32') argv=['-y','-f','gdigrab','-framerate',String(fps),'-i','desktop','-t',String(seconds),'-pix_fmt','yuv420p',destination];
323
- else if (process.platform === 'darwin') {
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
- else argv=['-y','-f','x11grab','-framerate',String(fps),'-i',process.env.DISPLAY,'-t',String(seconds),'-pix_fmt','yuv420p',destination];
328
- await runFile(ffmpeg, argv, { label:'screen recording', timeout:(seconds+20)*1000, maxBuffer:8*1024*1024 });
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 root = path.parse(filePath).root;
52
- const parts = filePath.slice(root.length).split(path.sep).filter(Boolean);
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
- for (const part of parts.slice(0, -1)) {
61
- const handle = await open(path.join(current, part), DOCUMENT_DIRECTORY_FLAGS);
62
- const info = await handle.stat();
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 closeDirectories(); }
61
+ finally { await parent.close().catch(() => {}); }
76
62
  },
77
63
  };
78
64
  } catch (error) {
79
- await closeDirectories();
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
- if (!descriptorAnchorsAvailable()) {
86
- if (create) await mkdir(directory, { recursive:true, mode:0o700 });
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) {