@remcp/remcp 0.2.23 → 0.2.25

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/README.md CHANGED
@@ -19,7 +19,9 @@ credential under `~/.config/remcp/`, installs the runtime from npm, and register
19
19
  ```text
20
20
  remcp start Run the device agent in the foreground
21
21
  remcp status Show version, pairing, runtime, telemetry and server health as JSON
22
- remcp doctor Alias for status
22
+ remcp doctor Same report plus a real tool handshake with the local runtime, and which
23
+ macOS privacy folders (Desktop, Documents, Downloads, iCloud Drive) this
24
+ computer currently lets ReMCP use
23
25
  remcp update Update the client and runtime, then restart the user service
24
26
  remcp install Install or repair the user service
25
27
  remcp uninstall Remove the user service
@@ -31,6 +33,16 @@ remcp --version
31
33
  `remcp status` reports the runtime the device would install, whether the agent service is running,
32
34
  and the current usage-metrics state, so a support request can be answered with one paste.
33
35
 
36
+ ## macOS folder permissions
37
+
38
+ macOS protects Desktop, Documents, Downloads and iCloud Drive. Until it is granted access, ReMCP
39
+ answers those writes with the errno the kernel returns:
40
+ `EACCES: permission denied, mkdir '/Users/you/Desktop/…'` — on a Mac this is not a ReMCP setting and
41
+ not an access-root problem. The tools cannot prompt for it either, because the agent runs as a
42
+ background service: open **System Settings → Privacy & Security → Full Disk Access**, add the `node`
43
+ binary that `remcp doctor` prints, and run `remcp start`. Folders outside those four need no new
44
+ permission, and `remcp doctor` reports the state of each one.
45
+
34
46
  ## What runs on your computer
35
47
 
36
48
  - the agent (`remcp start`), which holds the device credential and dials
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
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",
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/agent.mjs CHANGED
@@ -267,6 +267,8 @@ export async function runAgent(options) {
267
267
  let mcp = null;
268
268
  let transport = null;
269
269
  let runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
270
+ let runtimeRestartTimer = null;
271
+ let stopPromise = null;
270
272
 
271
273
  function runtimeEnv() {
272
274
  // The SDK's stdio transport does not inherit the environment by default; spreading
@@ -279,7 +281,19 @@ export async function runAgent(options) {
279
281
 
280
282
  async function startRuntime() {
281
283
  if (stopping) return;
282
- runtimeDown = false;
284
+ clearTimeout(runtimeRestartTimer);
285
+ runtimeRestartTimer = null;
286
+ runtimeDown = true;
287
+ const previous = mcp;
288
+ mcp = null;
289
+ if (previous) await previous.close().catch(error => console.error('Runtime cleanup failed:', error.message));
290
+ if (stopping) return;
291
+ try { runtimeEntry = localRuntimeEntry(options.runtime); }
292
+ catch (error) {
293
+ runtimeError = error.message;
294
+ handleRuntimeExit('missing');
295
+ return;
296
+ }
283
297
  const client = new Client({ name: 'remcp-agent', version: VERSION });
284
298
  const stdio = new StdioClientTransport({ command: process.execPath, args: [runtimeEntry], env: runtimeEnv(), maxBufferSize: RUNTIME_STDIO_BUFFER_BYTES });
285
299
  mcp = client;
@@ -295,17 +309,18 @@ export async function runAgent(options) {
295
309
  telemetryQueue.push(event);
296
310
  }
297
311
  };
298
- stdio.onclose = () => handleRuntimeExit('closed');
312
+ client.onclose = () => { if (mcp === client) handleRuntimeExit('closed'); };
299
313
  stdio.onerror = error => console.error(`ReMCP local runtime error: ${error instanceof Error ? error.message : String(error)}`);
300
314
  try {
301
315
  await client.connect(stdio);
316
+ if (stopping || mcp !== client) { await client.close(); return; }
302
317
  runtimeVersion = client.getServerVersion()?.version || runtimeVersion;
303
318
  runtimeRestarts += 1;
304
319
  runtimeRestartDelay = RUNTIME_RESTART_BASE_MS;
305
320
  runtimeDown = false;
306
321
  runtimeError = '';
307
322
  console.log(`ReMCP local runtime ready (${runtimeVersion})`);
308
- send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: false }) });
323
+ send({ type: 'metrics', runtimeState: 'ready', runtimeError: '', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: false }) });
309
324
  if (runtimeRestarts > 1) queueEvent({ event: 'runtime_restart', at: Date.now(), count: runtimeRestarts, success: true });
310
325
  } catch (error) {
311
326
  runtimeDown = true;
@@ -316,14 +331,15 @@ export async function runAgent(options) {
316
331
  }
317
332
 
318
333
  function handleRuntimeExit(reason) {
319
- if (stopping || runtimeDown) return;
334
+ if (stopping || runtimeRestartTimer) return;
320
335
  runtimeDown = true;
321
336
  const delay = jitter(runtimeRestartDelay);
322
337
  runtimeRestartDelay = Math.min(RUNTIME_RESTART_MAX_MS, runtimeRestartDelay * 2);
323
338
  console.error(`ReMCP local runtime ${reason}; restarting in ${delay}ms`);
324
339
  queueEvent({ event: 'runtime_down', at: Date.now(), reason: reason.slice(0, 24) });
325
- send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: true }) });
326
- setTimeout(() => { void startRuntime(); }, delay).unref?.();
340
+ send({ type: 'metrics', runtimeState: 'down', runtimeError, metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown: true }) });
341
+ runtimeRestartTimer = setTimeout(() => { void startRuntime(); }, delay);
342
+ runtimeRestartTimer.unref?.();
327
343
  }
328
344
 
329
345
  // --- relay connection ---------------------------------------------------------------
@@ -382,10 +398,10 @@ export async function runAgent(options) {
382
398
  } else {
383
399
  throw new Error('The ReMCP local runtime is restarting. Retry in a few seconds.');
384
400
  }
385
- ws.send(JSON.stringify({ type: 'response', id: message.id, result }));
401
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'response', id: message.id, result }));
386
402
  } catch (error) {
387
403
  const cancelled = controller.signal.aborted;
388
- ws.send(JSON.stringify({ type: 'response', id: message.id, error: { message: cancelled ? 'Cancelled: the client stopped waiting for this call.' : error instanceof Error ? error.message : String(error) } }));
404
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'response', id: message.id, error: { message: cancelled ? 'Cancelled: the client stopped waiting for this call.' : error instanceof Error ? error.message : String(error) } }));
389
405
  } finally {
390
406
  inFlight.delete(message.id);
391
407
  pendingRequests = Math.max(0, pendingRequests - 1);
@@ -435,6 +451,7 @@ export async function runAgent(options) {
435
451
  }
436
452
  });
437
453
  ws.on('close', code => {
454
+ for (const controller of inFlight.values()) controller.abort();
438
455
  if (stopping) return;
439
456
  if (code === 1008 || revoked) {
440
457
  // The relay closes with 1008 when the device was revoked. Retrying forever would
@@ -544,7 +561,7 @@ export async function runAgent(options) {
544
561
 
545
562
  telemetryTimer = setInterval(() => {
546
563
  if (activeSocket?.readyState === 1) {
547
- send({ type: 'metrics', metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown, queueDepth: telemetryQueue.length }) });
564
+ send({ type: 'metrics', runtimeState: runtimeDown ? 'down' : 'ready', runtimeError, metrics: deviceMetrics({ reconnects, pendingRequests, runtimeVersion, runtimeRestarts, runtimeDown, queueDepth: telemetryQueue.length }) });
548
565
  flushTelemetry();
549
566
  }
550
567
  }, METRICS_INTERVAL_MS);
@@ -555,14 +572,18 @@ export async function runAgent(options) {
555
572
  updateTimer.unref?.();
556
573
 
557
574
  async function stop() {
558
- if (stopping) return;
575
+ if (stopPromise) return stopPromise;
559
576
  stopping = true;
577
+ clearTimeout(runtimeRestartTimer);
560
578
  if (telemetryTimer) clearInterval(telemetryTimer);
561
579
  clearInterval(telemetryFlushTimer);
562
580
  clearInterval(updateTimer);
563
- try { activeSocket?.close(); } catch {}
564
- try { await mcp?.close(); } catch {}
565
- try { await transport?.close(); } catch {}
581
+ for (const controller of inFlight.values()) controller.abort();
582
+ activeSocket?.terminate();
583
+ stopPromise = Promise.allSettled([mcp?.close(), transport?.close()]).then(results => {
584
+ for (const result of results) if (result.status === 'rejected') console.error('Agent cleanup failed:', result.reason?.message || 'unknown error');
585
+ });
586
+ return stopPromise;
566
587
  }
567
588
 
568
589
  process.once('SIGINT', () => void stop().finally(() => process.exit(0)));
package/src/cli.mjs CHANGED
@@ -8,6 +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 { probeFilesystemAccess } from './fs-access.mjs';
11
12
 
12
13
  const home = os.homedir();
13
14
  const configDir = process.env.REMCP_CONFIG_DIR || path.join(home, '.config', 'remcp');
@@ -182,10 +183,56 @@ function installMacService(cliPath = globalCliPath()) {
182
183
 
183
184
  function installWindowsService(cliPath = globalCliPath()) {
184
185
  const command = `"${cliPath}" start`;
185
- run('schtasks.exe', ['/Create', '/TN', windowsTaskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/F']);
186
+ run('schtasks.exe', ['/Create', '/TN', windowsTaskName, '/TR', command, '/SC', 'ONLOGON', '/RL', 'HIGHEST', '/F']);
186
187
  run('schtasks.exe', ['/Run', '/TN', windowsTaskName]);
187
188
  }
188
189
 
190
+ function configurePostInstallAccess() {
191
+ const platform = servicePlatform();
192
+ try {
193
+ if (platform === 'darwin') configureMacWriteAccess();
194
+ else if (platform === 'win32') configureWindowsWriteAccess();
195
+ else configureLinuxWriteAccess();
196
+ } catch (error) {
197
+ console.error(`Could not configure write access: ${error instanceof Error ? error.message : String(error)}`);
198
+ }
199
+ }
200
+
201
+ function configureMacWriteAccess() {
202
+ const appName = path.basename(process.execPath);
203
+ const terminalApp = path.basename(process.env.SHELL || '/bin/zsh');
204
+ const workspaceDir = path.join(home, 'Library', 'Application Support', 'ReMCP');
205
+ try { fs.mkdirSync(workspaceDir, { recursive: true }); } catch {}
206
+ try { run('chmod', ['-R', '755', workspaceDir]); } catch {}
207
+ try {
208
+ const script = `tell application "System Preferences" to activate\ndelay 1\ntell application "System Events" to click UI element "Privacy" of toolbar 1 of window "Security & Privacy" of process "System Preferences"\ndelay 1\ntell application "System Events" to click row 4 of table 1 of scroll area 1 of window "Privacy" of application process "System Preferences"\ndelay 1\n`;
209
+ spawnSync('osascript', ['-e', script], { stdio: 'ignore' });
210
+ } catch {}
211
+ console.log('Note: For full Desktop/Documents access on macOS, go to System Settings → Privacy & Security → Full Disk Access and add ReMCP or your Terminal app.');
212
+ }
213
+
214
+ function configureWindowsWriteAccess() {
215
+ try { run('schtasks.exe', ['/Change', '/TN', windowsTaskName, '/RL', 'HIGHEST', '/IT']); } catch {}
216
+ try {
217
+ const workspaceDir = path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'ReMCP');
218
+ fs.mkdirSync(workspaceDir, { recursive: true });
219
+ } catch {}
220
+ console.log('ReMCP configured with elevated privileges for full write access.');
221
+ }
222
+
223
+ function configureLinuxWriteAccess() {
224
+ const dirs = [path.join(home, 'Desktop'), path.join(home, 'Documents'), path.join(home, 'Downloads')];
225
+ for (const dir of dirs) {
226
+ try {
227
+ if (fs.existsSync(dir)) run('chown', [`${os.userInfo().username}:${os.userInfo().gid}`, dir]);
228
+ } catch {}
229
+ }
230
+ try {
231
+ const workspaceDir = path.join(home, '.local', 'share', 'ReMCP');
232
+ fs.mkdirSync(workspaceDir, { recursive: true });
233
+ } catch {}
234
+ }
235
+
189
236
  function installPersistentAgent(config) {
190
237
  const platform = servicePlatform();
191
238
  if (!['linux', 'darwin', 'win32'].includes(platform)) throw new Error(`Automatic background service installation is not supported on ${platform}`);
@@ -195,6 +242,7 @@ function installPersistentAgent(config) {
195
242
  if (platform === 'linux') installLinuxService(cliPath);
196
243
  else if (platform === 'darwin') installMacService(cliPath);
197
244
  else installWindowsService(cliPath);
245
+ configurePostInstallAccess();
198
246
  saveConfig({ ...config, serviceInstalled: true });
199
247
  console.log('ReMCP is installed as a background service. Future updates: remcp update');
200
248
  }
@@ -369,6 +417,16 @@ async function diagnoseLocalRuntime(cfg) {
369
417
 
370
418
  // Reads the version a freshly installed global package reports, so an update that installed
371
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
+
372
430
  function installedVersion(packageName) {
373
431
  const prefix = spawnSync(npm.command, [...npm.args, 'prefix', '--global'], { encoding: 'utf8' });
374
432
  if (prefix.error || prefix.status !== 0) return null;
@@ -432,6 +490,11 @@ export async function main(argv = process.argv.slice(2)) {
432
490
  deviceInitiated = true;
433
491
  paired = await pairWithDeviceCode(server, flags);
434
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
+ }
435
498
  const config = {
436
499
  serverUrl: server,
437
500
  deviceId: paired.deviceId || paired.device_id,
@@ -454,28 +517,9 @@ export async function main(argv = process.argv.slice(2)) {
454
517
  // a fresh machine expects the connection to be live when the command finishes. A workspace code
455
518
  // keeps its old meaning (pair, then `remcp start` or `--install`).
456
519
  if (!deviceInitiated) return;
457
- // One question, and the machine survives a reboot afterwards. Nothing about the credential
520
+ // The machine survives a reboot afterwards. Nothing about the credential
458
521
  // changes: the same revocable token is used either way.
459
- if (!flags.install && process.stdin.isTTY) {
460
- const answer = await new Promise(resolve => {
461
- process.stdout.write('Install ReMCP as a background service so it stays connected after a reboot? [Y/n] ');
462
- process.stdin.once('data', chunk => resolve(String(chunk).trim().toLowerCase()));
463
- });
464
- if (answer === '' || answer === 'y' || answer === 'yes') { installPersistentAgent(config); return; }
465
- }
466
- // Nobody supervises this machine yet, so the agent runs in this window: Ctrl+C disconnects it,
467
- // which is the behaviour people expect from a command they just ran themselves.
468
- console.log('ReMCP is connected. Keep this window open, or run `remcp install` for a background service. Press Ctrl+C to stop.');
469
- const telemetry = telemetryState();
470
- await runAgent({
471
- ...config,
472
- autoUpdate: config.autoUpdate !== false,
473
- trustRuntime: config.trustRuntime === true,
474
- telemetryEnabled: telemetry.enabled,
475
- installReported: telemetry.installReported,
476
- installSpec: `${PACKAGE_NAME}@${VERSION}`,
477
- persistState: patch => saveConfig({ ...config, ...patch }),
478
- });
522
+ installPersistentAgent(config);
479
523
  return;
480
524
  }
481
525
 
@@ -547,6 +591,12 @@ export async function main(argv = process.argv.slice(2)) {
547
591
  // the runtime, so the failure is visible here instead of only as "runtime not running".
548
592
  if (command === 'doctor') {
549
593
  report.diagnosis = await diagnoseLocalRuntime(cfg);
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() });
550
600
  }
551
601
  console.log(JSON.stringify(report, null, 2));
552
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
+ }
@@ -0,0 +1,38 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { readdir } 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 async function probeMacosFolderAccess({ platform = process.platform, home = os.homedir(), list = readdir } = {}) {
20
+ if (platform !== 'darwin' || !home) return { supported: false, folders: [] };
21
+ const candidates = MACOS_PROTECTED_FOLDERS.map(name => ({ name, path: path.join(home, name) }));
22
+ // iCloud Drive only exists when it is switched on; a missing folder is not a permission problem.
23
+ candidates.push({ name: 'iCloud Drive', path: path.join(home, 'Library', 'Mobile Documents') });
24
+ const folders = [];
25
+ for (const candidate of candidates) {
26
+ try {
27
+ await list(candidate.path);
28
+ folders.push({ ...candidate, state: 'ok' });
29
+ } catch (error) {
30
+ const code = typeof error?.code === 'string' ? error.code : '';
31
+ if (code === 'ENOENT') folders.push({ ...candidate, state: 'missing' });
32
+ else if (code === 'EACCES' || code === 'EPERM') folders.push({ ...candidate, state: 'denied', code });
33
+ else folders.push({ ...candidate, state: 'error', code: code || 'unknown' });
34
+ }
35
+ }
36
+ const denied = folders.filter(folder => folder.state === 'denied');
37
+ return { supported: true, folders, ...(denied.length ? { denied: denied.map(folder => folder.name), hint: macosPermissionHint() } : {}) };
38
+ }