@remcp/runtime 0.2.138 → 0.2.140

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.140 — 2026-10-02
4
+
5
+ - Windows `create_archive` / `extract_archive` are enabled when the native inbox `tar.exe` (libarchive/bsdtar) backend is available.
6
+ - Windows ZIP creation/extraction uses the same validated tar backend as tar-family archives, preserving traversal, link/special-file, expanded-size, staging, post-extraction, and atomic-commit safety checks.
7
+ - Archive member names are normalized to portable forward slashes before backend invocation.
8
+ - `read_binary` now sizes each returned base64 chunk against the live output cap so the JSON envelope remains parseable under reduced per-call limits; `nextOffsetBytes` reflects the actual returned prefix.
9
+ - Public Windows release CI exercises tar.gz, ZIP, and option-like filename round trips. Fully capable Windows now advertises all 83 runtime tools.
10
+
11
+ ## 0.2.139 — 2026-10-02
12
+
13
+ - `create_archive` and `extract_archive` are now advertised on macOS in addition to Linux.
14
+ - The tar verbose-output parser accepts native bsdtar month/day/time rows that omit the year for current-year entries, while retaining the existing archive expanded-size safety checks.
15
+ - Public macOS release validation performs a real archive create/extract round trip. Windows archive tools remain intentionally hidden until a separately validated backend exists.
16
+ - Tool names and MCP schemas are unchanged; macOS parity rises from 81 to 83 advertised tools on a fully capable host.
17
+
3
18
  ## 0.2.138 — 2026-10-02
4
19
 
5
20
  - `browser_tabs`, `browser_snapshot`, `browser_find`, and `browser_wait` now set `openWorldHint=true` while retaining `readOnlyHint=true` and `idempotentHint=true`.
package/README.md CHANGED
@@ -40,7 +40,7 @@ support and emits `notifications/tools/list_changed` when that capability set ch
40
40
  | --- | --- |
41
41
  | Read | `read_file`, `read_files`, `read_multiple_files`, `read_image`, `read_binary`, `list_directory`, `get_file_info`, `hash_file`, `diff_files` |
42
42
  | Write / edit | `write_file`, `write_files`, `write_binary`, `apply_patch`, `set_permissions`, `edit_block`, `replace_lines`, `replace_in_files` |
43
- | Organise | `create_directory`, `move_file`, `copy_file`, `copy_paths`, `move_paths`, `move_to_trash`, `create_archive`, `extract_archive` |
43
+ | Organise | `create_directory`, `move_file`, `copy_file`, `copy_paths`, `move_paths`, `move_to_trash`, `create_archive`, `extract_archive` (Linux/macOS; tar/zip backends are validated before use) |
44
44
  | Delete | `delete_path`, `delete_paths` |
45
45
  | Transfer | `read_binary` / `write_binary` stream any file as base64 chunks in both directions; `create_archive` / `extract_archive` move whole trees |
46
46
  | Screen | `take_screenshot` returns the desktop as an image on Linux, macOS, and Windows |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.138",
3
+ "version": "0.2.140",
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",
package/src/catalog.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { fileToolHandlers } from './tools/files.mjs';
1
+ import { archiveBackendAvailable, fileToolHandlers } from './tools/files.mjs';
2
2
  import { searchToolHandlers } from './tools/search.mjs';
3
3
  import { terminalToolHandlers } from './tools/terminal.mjs';
4
4
  import { systemToolHandlers } from './tools/system.mjs';
@@ -830,12 +830,18 @@ export const toolDefinitions = [
830
830
  },
831
831
  ];
832
832
 
833
- export function coreToolSupported(definition) {
834
- return !['create_archive', 'extract_archive'].includes(definition.name) || process.platform === 'linux';
833
+ export function coreToolSupported(definition, {
834
+ platform = process.platform,
835
+ archiveAvailable = platform === 'win32' ? archiveBackendAvailable({ platform }) : true,
836
+ } = {}) {
837
+ if (!['create_archive', 'extract_archive'].includes(definition.name)) return true;
838
+ if (platform === 'linux' || platform === 'darwin') return true;
839
+ return platform === 'win32' && archiveAvailable === true;
835
840
  }
836
841
 
837
842
  export function supportedCoreTools() {
838
- return toolDefinitions.filter(coreToolSupported);
843
+ const archiveAvailable = process.platform === 'win32' ? archiveBackendAvailable({ platform:process.platform }) : true;
844
+ return toolDefinitions.filter(definition => coreToolSupported(definition, { platform:process.platform, archiveAvailable }));
839
845
  }
840
846
 
841
847
  export function advertisedTools() {
@@ -12,7 +12,7 @@ import { describeFilesystemFailure } from '../permissions.mjs';
12
12
  import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
13
13
  import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
14
14
  import { countEvent, recordEvent } from '../telemetry.mjs';
15
- import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text, throwIfCancelled } from '../util.mjs';
15
+ import { clampInteger, decodeText, displayPath, fail, fitsOutput, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text, throwIfCancelled } from '../util.mjs';
16
16
 
17
17
  const MAX_INLINE_FILE_BYTES = 20 * 1024 * 1024;
18
18
  // An image travels base64-encoded, which costs a third more bytes. The agent's stdio transport holds
@@ -785,16 +785,33 @@ export async function readBinaryTool(args) {
785
785
  try {
786
786
  const buffer = Buffer.alloc(end - start);
787
787
  if (buffer.length) await handle.read(buffer, 0, buffer.length, start);
788
- const payload = JSON.stringify({
789
- path: displayPath(absolute),
790
- size: info.size,
791
- offsetBytes: start,
792
- lengthBytes: buffer.length,
793
- nextOffsetBytes: end < info.size ? end : null,
794
- complete: end >= info.size,
795
- encoding: 'base64',
796
- data: buffer.toString('base64'),
797
- });
788
+ const renderPayload = lengthBytes => {
789
+ const actualEnd = start + lengthBytes;
790
+ return JSON.stringify({
791
+ path: displayPath(absolute),
792
+ size: info.size,
793
+ offsetBytes: start,
794
+ lengthBytes,
795
+ nextOffsetBytes: actualEnd < info.size ? actualEnd : null,
796
+ complete: actualEnd >= info.size,
797
+ encoding: 'base64',
798
+ data: buffer.subarray(0, lengthBytes).toString('base64'),
799
+ });
800
+ };
801
+ let returnedLength = buffer.length;
802
+ let payload = renderPayload(returnedLength);
803
+ if (!fitsOutput(payload)) {
804
+ let low = 0;
805
+ let high = returnedLength;
806
+ while (low < high) {
807
+ const candidate = Math.ceil((low + high) / 2);
808
+ if (fitsOutput(renderPayload(candidate))) low = candidate;
809
+ else high = candidate - 1;
810
+ }
811
+ returnedLength = low;
812
+ payload = renderPayload(returnedLength);
813
+ if (!fitsOutput(payload)) fail('Runtime output limit is too small to return read_binary metadata');
814
+ }
798
815
  return text(payload);
799
816
  } finally {
800
817
  await opened.close();
@@ -1449,17 +1466,16 @@ function archiveExpandedSize(lines, pattern, label, expectedEntries = null) {
1449
1466
  return total;
1450
1467
  }
1451
1468
 
1452
- function tarEntrySize(line) {
1469
+ export function tarEntrySize(line) {
1453
1470
  const tokens = String(line || '').trim().split(/\s+/);
1454
1471
  for (const index of [2, 3, 4]) {
1455
1472
  const size = Number(tokens[index]);
1456
1473
  if (!Number.isSafeInteger(size) || size < 0) continue;
1457
1474
  const tail = tokens.slice(index + 1);
1458
1475
  const gnuDate = /^\d{4}-\d{2}-\d{2}$/.test(tail[0] || '');
1459
- const bsdDate = /^[A-Z][a-z]{2}$/.test(tail[0] || '')
1460
- && /^\d{1,2}$/.test(tail[1] || '')
1461
- && ((/^\d{2}:\d{2}(?::\d{2})?$/.test(tail[2] || '') && /^\d{4}$/.test(tail[3] || ''))
1462
- || /^\d{4}$/.test(tail[2] || ''));
1476
+ const bsdMonthDay = /^[A-Z][a-z]{2}$/.test(tail[0] || '') && /^\d{1,2}$/.test(tail[1] || '');
1477
+ const bsdTime = /^\d{2}:\d{2}(?::\d{2})?$/.test(tail[2] || '');
1478
+ const bsdDate = bsdMonthDay && (bsdTime || /^\d{4}$/.test(tail[2] || ''));
1463
1479
  if (gnuDate || bsdDate) return size;
1464
1480
  }
1465
1481
  return null;
@@ -1776,14 +1792,31 @@ async function moveArchiveFile(source, destination) {
1776
1792
  }
1777
1793
  }
1778
1794
 
1779
- function archiveTool() {
1780
- const probe = (name, versionArgs = ['--version']) => {
1781
- const result = spawnSync(name, versionArgs, { encoding: 'utf8' });
1782
- return !result.error && result.status === 0 ? name : null;
1783
- };
1795
+ function archiveCommandProbe(name, versionArgs = ['--version']) {
1796
+ const result = spawnSync(name, versionArgs, { encoding: 'utf8' });
1797
+ return !result.error && result.status === 0 ? name : null;
1798
+ }
1799
+
1800
+ function archiveTool({ platform = process.platform, probe = archiveCommandProbe } = {}) {
1801
+ const tarName = platform === 'win32' ? 'tar.exe' : 'tar';
1802
+ // Windows ships libarchive/bsdtar as tar.exe. It can create/read ZIP as well as tar-family
1803
+ // archives, so keeping one backend avoids introducing a second extraction/safety path.
1804
+ if (platform === 'win32') return { tar: probe(tarName), zip:null, unzip:null };
1784
1805
  // Info-ZIP unzip (the default on Debian/Ubuntu) treats --version as an invalid combination and
1785
1806
  // exits 10 even though the binary is healthy. Its portable version probe is -v.
1786
- return { tar: probe('tar'), zip: probe('zip'), unzip: probe('unzip', ['-v']) };
1807
+ return { tar: probe(tarName), zip: probe('zip'), unzip: probe('unzip', ['-v']) };
1808
+ }
1809
+
1810
+ export function archiveBackendAvailable({ platform = process.platform, probe = archiveCommandProbe } = {}) {
1811
+ if (platform === 'win32') return Boolean(archiveTool({ platform, probe }).tar);
1812
+ return platform === 'linux' || platform === 'darwin';
1813
+ }
1814
+
1815
+ export function zipArchiveBackendAvailable({ platform = process.platform, probe = archiveCommandProbe } = {}) {
1816
+ const tools = archiveTool({ platform, probe });
1817
+ if (platform === 'win32') return Boolean(tools.tar);
1818
+ if (platform === 'linux' || platform === 'darwin') return Boolean(tools.zip && tools.unzip);
1819
+ return false;
1787
1820
  }
1788
1821
 
1789
1822
  export async function createArchiveTool(args, extra = {}) {
@@ -1802,7 +1835,10 @@ export async function createArchiveTool(args, extra = {}) {
1802
1835
  const baseDir = path.dirname(resolved[0]);
1803
1836
  const names = resolved.map(entry => path.relative(baseDir, entry));
1804
1837
  if (names.some(name => !name || name.startsWith('..') || path.isAbsolute(name))) fail('Archive sources must share one parent directory');
1805
- const archiveNames = names.map(name => name.startsWith('-') ? `./${name}` : name);
1838
+ const archiveNames = names.map(name => {
1839
+ const portable = name.split(path.sep).join('/');
1840
+ return portable.startsWith('-') ? `./${portable}` : portable;
1841
+ });
1806
1842
  const suffix = format === 'zip' ? '.zip' : format === 'tar' ? '.tar' : '.tar.gz';
1807
1843
  const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-archive-'));
1808
1844
  if (resolved.some(entry => !path.relative(entry, stagingDirectory).startsWith('..') && !path.isAbsolute(path.relative(entry, stagingDirectory)))) {
@@ -1823,9 +1859,15 @@ export async function createArchiveTool(args, extra = {}) {
1823
1859
  await copyTreeContents(resolved[index], snapshotPath, snapshotState);
1824
1860
  }
1825
1861
  if (format === 'zip') {
1826
- if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
1827
- const result = spawnSync(tools.zip, ['-r', '-q', '-y', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1828
- if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1862
+ if (process.platform === 'win32') {
1863
+ if (!tools.tar) fail('Windows archive backend is unavailable: tar.exe is required');
1864
+ const result = spawnSync(tools.tar, ['-a', '-c', '-f', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1865
+ if (result.status !== 0) fail(`tar.exe ZIP creation failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1866
+ } else {
1867
+ if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
1868
+ const result = spawnSync(tools.zip, ['-r', '-q', '-y', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1869
+ if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1870
+ }
1829
1871
  } else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
1830
1872
  if (!tools.tar) fail('tar is not installed on this device');
1831
1873
  const flags = format === 'tar' ? '-cf' : '-czf';
@@ -1881,7 +1923,7 @@ export async function extractArchiveTool(args) {
1881
1923
  await recoverExtractionArtifacts(parent, destination, backupPrefix);
1882
1924
  const existingDestination = await lstat(destination).catch(() => null);
1883
1925
  if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
1884
- if (format === 'zip') {
1926
+ if (format === 'zip' && process.platform !== 'win32') {
1885
1927
  if (!tools.unzip) fail('unzip is not installed on this device');
1886
1928
  const listing = spawnSync(tools.unzip, ['-Z', '-1', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1887
1929
  if (listing.error || listing.status !== 0) fail(`Could not inspect ZIP archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
@@ -1896,7 +1938,7 @@ export async function extractArchiveTool(args) {
1896
1938
  const result = spawnSync(tools.unzip, ['-o', '-q', archiveSnapshot, '-d', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1897
1939
  if (result.status !== 0) fail(`unzip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1898
1940
  } else {
1899
- if (!tools.tar) fail('tar is not installed on this device');
1941
+ if (!tools.tar) fail(process.platform === 'win32' ? 'Windows archive backend is unavailable: tar.exe is required' : 'tar is not installed on this device');
1900
1942
  const listFlags = format === 'tar.gz' ? '-tzf' : format === 'tar.bz2' ? '-tjf' : format === 'tar.xz' ? '-tJf' : '-tf';
1901
1943
  const listing = spawnSync(tools.tar, [listFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1902
1944
  if (listing.error || listing.status !== 0) fail(`Could not inspect archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
package/src/util.mjs CHANGED
@@ -136,10 +136,16 @@ function frameBytes(text) {
136
136
  return Buffer.byteLength(JSON.stringify(String(text)), 'utf8');
137
137
  }
138
138
 
139
+ export function fitsOutput(text, maxBytes) {
140
+ const limit = maxBytes || liveConfig('maxOutputBytes');
141
+ const value = String(text);
142
+ return Buffer.byteLength(value, 'utf8') <= limit && frameBytes(value) <= limit;
143
+ }
144
+
139
145
  export function truncate(text, maxBytes) {
140
146
  const limit = maxBytes || liveConfig('maxOutputBytes');
141
147
  const value = String(text);
142
- if (Buffer.byteLength(value, 'utf8') <= limit && frameBytes(value) <= limit) return value;
148
+ if (fitsOutput(value, limit)) return value;
143
149
  // Shrink until the escaped frame fits, so the escaped size is what the caller gets is bounded.
144
150
  let size = Math.min(Buffer.byteLength(value, 'utf8'), limit);
145
151
  let rendered = '';