@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 +13 -1
- package/package.json +2 -2
- package/src/agent.mjs +34 -13
- package/src/cli.mjs +72 -22
- package/src/fs-access.mjs +88 -0
- package/src/macos-permissions.mjs +38 -0
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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', '
|
|
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
|
-
//
|
|
520
|
+
// The machine survives a reboot afterwards. Nothing about the credential
|
|
458
521
|
// changes: the same revocable token is used either way.
|
|
459
|
-
|
|
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
|
+
}
|