@remcp/remcp 0.2.24 → 0.2.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "ReMCP device client: pair a computer with ReMCP and run the outbound-only agent that hosts the local MCP runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "README.md"
19
19
  ],
20
20
  "scripts": {
21
- "check": "node --check bin/remcp.mjs && node --check src/cli.mjs && node --check src/agent.mjs && node --check src/runtime.mjs && node --check src/version.mjs && node --check src/npm.mjs && node --check src/macos-permissions.mjs",
21
+ "check": "node --check bin/remcp.mjs && node --check src/cli.mjs && node --check src/agent.mjs && node --check src/runtime.mjs && node --check src/version.mjs && node --check src/npm.mjs && node --check src/fs-access.mjs",
22
22
  "test": "node --test test/*.test.mjs"
23
23
  },
24
24
  "dependencies": {
package/src/cli.mjs CHANGED
@@ -8,7 +8,7 @@ import { localRuntimeEntry, runAgent, supervisorRestart } from './agent.mjs';
8
8
  import { npmVersion, resolveNpm } from './npm.mjs';
9
9
  import { isRuntimeSpecFor, normalizeRuntime } from './runtime.mjs';
10
10
  import { PACKAGE_NAME, VERSION } from './version.mjs';
11
- import { probeMacosFolderAccess } from './macos-permissions.mjs';
11
+ import { probeFilesystemAccess } from './fs-access.mjs';
12
12
 
13
13
  const home = os.homedir();
14
14
  const configDir = process.env.REMCP_CONFIG_DIR || path.join(home, '.config', 'remcp');
@@ -417,6 +417,16 @@ async function diagnoseLocalRuntime(cfg) {
417
417
 
418
418
  // Reads the version a freshly installed global package reports, so an update that installed
419
419
  // nothing (wrong prefix, npm cache, permissions) is reported instead of assumed successful.
420
+ // The roots the runtime is allowed to work in, as the person configured them. An empty list means
421
+ // "the whole file system", so the doctor probes the home directory instead of guessing a root.
422
+ function runtimeAllowedRoots() {
423
+ try {
424
+ const configured = JSON.parse(fs.readFileSync(runtimeConfigFile, 'utf8')).allowedRoots;
425
+ if (Array.isArray(configured) && configured.length) return configured.map(root => String(root).replace(/^~/, os.homedir()));
426
+ } catch {}
427
+ return [os.homedir()];
428
+ }
429
+
420
430
  function installedVersion(packageName) {
421
431
  const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
422
432
  if (prefix.error || prefix.status !== 0) return null;
@@ -480,6 +490,11 @@ export async function main(argv = process.argv.slice(2)) {
480
490
  deviceInitiated = true;
481
491
  paired = await pairWithDeviceCode(server, flags);
482
492
  }
493
+ if (paired.moved_from_another_account) {
494
+ console.log('This computer was paired to another ReMCP account. That device was revoked and this machine now belongs to the account you approved.');
495
+ } else if (paired.movedFromUid) {
496
+ console.log('This computer was paired to another ReMCP account. That device was revoked and this machine now belongs to the account you approved.');
497
+ }
483
498
  const config = {
484
499
  serverUrl: server,
485
500
  deviceId: paired.deviceId || paired.device_id,
@@ -576,11 +591,12 @@ export async function main(argv = process.argv.slice(2)) {
576
591
  // the runtime, so the failure is visible here instead of only as "runtime not running".
577
592
  if (command === 'doctor') {
578
593
  report.diagnosis = await diagnoseLocalRuntime(cfg);
579
- // A Mac can pass every other check and still be unable to write to Desktop: macOS answers EACCES
580
- // and the model only ever sees the errno. This is where the person can see it before ChatGPT
581
- // does, with the grant that fixes it named for this exact binary.
582
- const filesystem = await probeMacosFolderAccess();
583
- if (filesystem.supported) report.diagnosis.filesystem = filesystem;
594
+ // A computer can pass every other check and still be unable to write where the tools work:
595
+ // macOS answers EACCES for Desktop until TCC is granted, Windows has Controlled folder access,
596
+ // Linux answers EACCES for a folder this user does not own. The probe writes and removes a
597
+ // temporary file in each allowed root, so the doctor reports what actually happens rather than
598
+ // what the permission bits claim, and names the fix for this platform and this binary.
599
+ report.diagnosis.filesystem = await probeFilesystemAccess({ roots: runtimeAllowedRoots() });
584
600
  }
585
601
  console.log(JSON.stringify(report, null, 2));
586
602
  if (command === 'doctor' && report.diagnosis.verdict !== 'ok') process.exitCode = 1;
@@ -0,0 +1,88 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { readdir, rm, writeFile } from 'node:fs/promises';
4
+
5
+ // macOS gates Desktop, Documents, Downloads and iCloud Drive behind TCC. A paired Mac can be online,
6
+ // healthy and answering tools, and still fail every write into Desktop with `EACCES: permission
7
+ // denied` — the failure people actually report, because the workspace and the model only ever see the
8
+ // errno. `remcp doctor` is the one place that can look at the machine itself, so it reports which of
9
+ // those folders this process may use.
10
+ //
11
+ // The probe is read-only on purpose: reading a directory is what TCC gates, so a directory that
12
+ // cannot be listed is a directory that cannot be written either, and listing one cannot change it.
13
+ export const MACOS_PROTECTED_FOLDERS = Object.freeze(['Desktop', 'Documents', 'Downloads']);
14
+
15
+ export function macosPermissionHint(execPath = process.execPath) {
16
+ return `macOS is blocking one or more protected folders. Grant access by hand: System Settings → Privacy & Security → Full Disk Access → + → ${execPath}, then restart the agent with \`remcp start\`. Folders outside Desktop, Documents, Downloads and iCloud Drive need no new permission.`;
17
+ }
18
+
19
+ export function windowsPermissionHint(execPath = process.execPath) {
20
+ return `Windows is refusing the write. Allow it under Windows Security → Virus & threat protection → Ransomware protection → Allow an app through Controlled folder access (${execPath}), check the read-only attribute of the file, and restart the agent with \`remcp start\`.`;
21
+ }
22
+
23
+ export function linuxPermissionHint() {
24
+ return 'The write was denied by the file system. Check the owner and mode of the folder and its parents (chown/chmod), or choose a path this user owns; folders under /root or another account need root, which ReMCP deliberately does not use.';
25
+ }
26
+
27
+ export function filesystemAccessHint(platform = process.platform, execPath = process.execPath) {
28
+ if (platform === 'darwin') return macosPermissionHint(execPath);
29
+ if (platform === 'win32') return windowsPermissionHint(execPath);
30
+ return linuxPermissionHint();
31
+ }
32
+
33
+ // Can this process actually write where the runtime is allowed to work? The macOS folders above are
34
+ // one answer; this is the other one, and it is the same question on every platform. A temporary file
35
+ // is created and removed again, which is the only honest test — `access(W_OK)` reports what the
36
+ // permission bits say, not what the sandbox, TCC or Controlled folder access will allow.
37
+ export async function probeWritableRoots({ roots = [], create = writeFile, remove = rm, pid = process.pid } = {}) {
38
+ const results = [];
39
+ for (const root of roots) {
40
+ const probe = path.join(String(root), `.remcp-write-probe-${pid}`);
41
+ try {
42
+ await create(probe, '');
43
+ await remove(probe, { force: true });
44
+ results.push({ path: String(root), state: 'ok' });
45
+ } catch (error) {
46
+ const code = typeof error?.code === 'string' ? error.code : 'unknown';
47
+ results.push({ path: String(root), state: code === 'ENOENT' ? 'missing' : 'denied', code });
48
+ }
49
+ }
50
+ return results;
51
+ }
52
+
53
+ // One shape for `remcp doctor` on every platform: which places the tools may write to, and what to do
54
+ // when one of them says no.
55
+ export async function probeFilesystemAccess({ platform = process.platform, home = os.homedir(), roots = [], ...probeOptions } = {}) {
56
+ const folders = platform === 'darwin' ? (await probeMacosFolderAccess({ platform, home, ...probeOptions })).folders : [];
57
+ const writable = await probeWritableRoots({ roots: [...new Set(roots.filter(Boolean))], ...probeOptions });
58
+ const denied = [...folders.filter(folder => folder.state === 'denied').map(folder => folder.name), ...writable.filter(root => root.state === 'denied').map(root => root.path)];
59
+ const missing = platform === 'darwin' ? folders.filter(folder => folder.state === 'missing').map(folder => folder.name) : [];
60
+ return {
61
+ platform,
62
+ folders,
63
+ roots: writable,
64
+ ...(denied.length ? { denied, hint: filesystemAccessHint(platform) } : {}),
65
+ ...(missing.length ? { missing } : {}),
66
+ };
67
+ }
68
+
69
+ export async function probeMacosFolderAccess({ platform = process.platform, home = os.homedir(), list = readdir } = {}) {
70
+ if (platform !== 'darwin' || !home) return { supported: false, folders: [] };
71
+ const candidates = MACOS_PROTECTED_FOLDERS.map(name => ({ name, path: path.join(home, name) }));
72
+ // iCloud Drive only exists when it is switched on; a missing folder is not a permission problem.
73
+ candidates.push({ name: 'iCloud Drive', path: path.join(home, 'Library', 'Mobile Documents') });
74
+ const folders = [];
75
+ for (const candidate of candidates) {
76
+ try {
77
+ await list(candidate.path);
78
+ folders.push({ ...candidate, state: 'ok' });
79
+ } catch (error) {
80
+ const code = typeof error?.code === 'string' ? error.code : '';
81
+ if (code === 'ENOENT') folders.push({ ...candidate, state: 'missing' });
82
+ else if (code === 'EACCES' || code === 'EPERM') folders.push({ ...candidate, state: 'denied', code });
83
+ else folders.push({ ...candidate, state: 'error', code: code || 'unknown' });
84
+ }
85
+ }
86
+ const denied = folders.filter(folder => folder.state === 'denied');
87
+ return { supported: true, folders, ...(denied.length ? { denied: denied.map(folder => folder.name), hint: macosPermissionHint() } : {}) };
88
+ }