@remcp/runtime 0.2.113 → 0.2.114

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.114 — 2026-09-27
4
+
5
+ - `extract_archive` writes each regular-file overlay into an exclusive no-follow temporary inode beside the destination and commits it with atomic `rename()`, removing the check-then-truncate filesystem race while preserving existing-file overlay semantics and unrelated destination contents.
6
+ - Runtime tool names, input schemas, and output schemas are unchanged from 0.2.113.
7
+
3
8
  ## 0.2.113 — 2026-09-26
4
9
 
5
10
  - No runtime tool names, schemas, or device-side tool implementations change in this release. The runtime package version remains in lockstep with the hosted/client release while relay heartbeat, reconnect admission, telemetry cadence, gateway capacity, and production load-safety are hardened.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.113",
3
+ "version": "0.2.114",
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",
@@ -1542,16 +1542,17 @@ async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, st
1542
1542
  if (state.maxBytes !== undefined && sourceInfo.size > state.maxBytes - currentBytes) {
1543
1543
  fail(`Archive contents exceed the ${state.maxBytes}-byte extraction limit`);
1544
1544
  }
1545
- const existing = await lstat(destinationPath).catch(() => null);
1546
- if (existing && (!existing.isFile() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
1545
+ // Never validate a pathname and then truncate it: another local process could replace that entry
1546
+ // between the check and open. Build a brand-new inode beside the destination and commit it with
1547
+ // rename(), which replaces the directory entry atomically without following a raced symlink or
1548
+ // truncating a hard-linked file.
1549
+ const temporary = path.join(
1550
+ path.dirname(destinationPath),
1551
+ `.${path.basename(destinationPath)}.remcp-${randomUUID()}.tmp`,
1552
+ );
1547
1553
  let output;
1548
1554
  try {
1549
- try {
1550
- output = await open(destinationPath, WRITE_TRUNCATE_NOFOLLOW, mode);
1551
- } catch (error) {
1552
- if (['ELOOP', 'ENOTDIR', 'EISDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
1553
- throw error;
1554
- }
1555
+ output = await open(temporary, WRITE_CREATE_NOFOLLOW, mode);
1555
1556
  const outputInfo = await output.stat();
1556
1557
  if (!outputInfo.isFile()) fail('Archive contains a link or special file; archive destination is unsafe');
1557
1558
  const buffer = Buffer.allocUnsafe(1024 * 1024);
@@ -1570,12 +1571,15 @@ async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, st
1570
1571
  }
1571
1572
  offset += bytesRead;
1572
1573
  }
1573
- await output.truncate(offset);
1574
1574
  await output.sync();
1575
1575
  await output.chmod(mode);
1576
+ await output.close();
1577
+ output = null;
1578
+ await rename(temporary, destinationPath);
1576
1579
  state.bytes = currentBytes + offset;
1577
1580
  } finally {
1578
1581
  if (output) await output.close().catch(() => {});
1582
+ await unlink(temporary).catch(() => {});
1579
1583
  }
1580
1584
  }
1581
1585