@remcp/remcp 0.2.105 → 0.2.107

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.105",
3
+ "version": "0.2.107",
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",
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { spawn, spawnSync } from 'node:child_process';
@@ -29,15 +29,28 @@ function globalNodeModules() {
29
29
  return String(result.stdout || '').trim();
30
30
  }
31
31
 
32
+ function resolveRuntimeEntry(base, runtime) {
33
+ const packageRoot = path.resolve(base, ...runtime.packageName.split('/'));
34
+ const candidate = path.resolve(packageRoot, ...runtime.entry.split(/[\\/]+/));
35
+ const relative = path.relative(packageRoot, candidate);
36
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Runtime entry resolves outside its package');
37
+ if (!existsSync(candidate)) return null;
38
+ const realPackageRoot = realpathSync(packageRoot);
39
+ const realCandidate = realpathSync(candidate);
40
+ const realRelative = path.relative(realPackageRoot, realCandidate);
41
+ if (realRelative.startsWith('..') || path.isAbsolute(realRelative) || !lstatSync(realCandidate).isFile()) throw new Error('Runtime entry is not a regular package file');
42
+ return realCandidate;
43
+ }
44
+
32
45
  export function localRuntimeEntry(runtimeValue) {
33
46
  const runtime = normalizeRuntime(runtimeValue);
34
47
  const localModules = packageLocalNodeModules();
35
48
  if (localModules) {
36
- const sibling = path.join(localModules, ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
37
- if (existsSync(sibling)) return sibling;
49
+ const sibling = resolveRuntimeEntry(localModules, runtime);
50
+ if (sibling) return sibling;
38
51
  }
39
- const candidate = path.join(globalNodeModules(), ...runtime.packageName.split('/'), ...runtime.entry.split(/[\\/]+/));
40
- if (!existsSync(candidate)) throw new Error('ReMCP local runtime is not installed. Run `remcp install`.');
52
+ const candidate = resolveRuntimeEntry(globalNodeModules(), runtime);
53
+ if (!candidate) throw new Error('ReMCP local runtime is not installed. Run `remcp install`.');
41
54
  return candidate;
42
55
  }
43
56
 
package/src/agent.mjs CHANGED
@@ -47,10 +47,12 @@ const RUNTIME_TOOLS_RETRY_MAX_MS = 30_000;
47
47
  // timeout while the device keeps working invisibly.
48
48
  const CALL_TIMEOUT_MARGIN_MS = 10_000;
49
49
 
50
- function normalizedServerUrl(value) {
50
+ function normalizedServerUrl(value, { allowInsecure = false } = {}) {
51
51
  const url = new URL(String(value || ''));
52
52
  if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('serverUrl must use http or https');
53
53
  if (url.username || url.password) throw new Error('serverUrl must not embed credentials');
54
+ const loopback = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname.toLowerCase());
55
+ if (url.protocol === 'http:' && !loopback && !allowInsecure) throw new Error('Remote serverUrl must use https');
54
56
  url.hash = '';
55
57
  url.search = '';
56
58
  return url.href.replace(/\/$/, '');
@@ -88,7 +90,7 @@ function deviceMetrics(extra = {}) {
88
90
  }
89
91
 
90
92
  export async function runAgent(options) {
91
- const serverUrl = normalizedServerUrl(options.serverUrl);
93
+ const serverUrl = normalizedServerUrl(options.serverUrl, { allowInsecure: options.allowInsecureTransport === true || process.env.REMCP_ALLOW_INSECURE_TRANSPORT === 'true' });
92
94
  const deviceToken = String(options.deviceToken || '');
93
95
  const deviceId = String(options.deviceId || '');
94
96
  const deviceName = String(options.deviceName || os.hostname());
@@ -524,7 +526,7 @@ export async function runAgent(options) {
524
526
  try {
525
527
  // This request contains no local file payload; the config-derived URL is the explicitly paired relay.
526
528
  // codeql[js/file-access-to-http]
527
- const response = await fetch(`${serverUrl}/api/agent/version`, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS) });
529
+ const response = await fetch(`${serverUrl}/api/agent/version`, { redirect: 'error', signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS) });
528
530
  if (!response.ok) return;
529
531
  const advertised = await response.json();
530
532
  const minimum = advertised.minimum;
@@ -29,6 +29,18 @@ export function openInBrowser(url) {
29
29
  } catch {}
30
30
  }
31
31
 
32
+ export function assertSecureServerUrl(value, { allowInsecure = false } = {}) {
33
+ let url;
34
+ try { url = new URL(String(value || '')); } catch { throw new Error('server must be a valid URL'); }
35
+ if (!['https:', 'http:'].includes(url.protocol)) throw new Error('server must use http or https');
36
+ if (url.username || url.password) throw new Error('server must not embed credentials');
37
+ const loopback = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname.toLowerCase());
38
+ if (url.protocol === 'http:' && !loopback && !allowInsecure) throw new Error('Remote server must use https');
39
+ url.hash = '';
40
+ url.search = '';
41
+ return url.href.replace(/\/$/, '');
42
+ }
43
+
32
44
  const TRANSIENT_FETCH_RETRY_DELAY_MS = 150;
33
45
 
34
46
  async function fetchWithTransientRetry(url, options) {
@@ -48,7 +60,9 @@ async function fetchWithTransientRetry(url, options) {
48
60
  // browser while signed in, and this process collects the credential by polling. The device never
49
61
  // sees a browser session or an account password.
50
62
  export async function pairWithDeviceCode(server, flags) {
63
+ server = assertSecureServerUrl(server, { allowInsecure: flags?.['allow-insecure-transport'] === true });
51
64
  const authorization = await fetchWithTransientRetry(`${server}/oauth/device_authorization`, {
65
+ redirect: 'error',
52
66
  method: 'POST',
53
67
  headers: { 'content-type': 'application/json' },
54
68
  body: JSON.stringify({
@@ -73,6 +87,7 @@ export async function pairWithDeviceCode(server, flags) {
73
87
  await sleep(intervalMs);
74
88
  const response = await fetchWithTransientRetry(`${server}/oauth/token`, {
75
89
  method: 'POST',
90
+ redirect: 'error',
76
91
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
77
92
  body: new URLSearchParams({
78
93
  grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
@@ -33,6 +33,7 @@ function npmGlobalInstallArgs(specs, { preferOnline = false } = {}) {
33
33
  ...(preferOnline ? ['--prefer-online'] : []),
34
34
  '--no-audit',
35
35
  '--no-fund',
36
+ '--ignore-scripts',
36
37
  '--loglevel=error',
37
38
  ];
38
39
  }
@@ -9,6 +9,7 @@ import { isRuntimeSpecFor, normalizeRuntime } from '../runtime.mjs';
9
9
  import { PACKAGE_NAME, VERSION } from '../version.mjs';
10
10
 
11
11
  import { loadConfig, saveConfig } from './config.mjs';
12
+ import { assertSecureServerUrl } from './connect.mjs';
12
13
  import { installedVersion } from './doctor.mjs';
13
14
  import { linuxServiceFile, macServiceFile, officialOrigin } from './env.mjs';
14
15
  import { currentInstallationInfo, ensureMacCliCommand, ensureServiceIfRecorded, installationVersionsAtCliPath, npmGlobalUpdate, persistentServiceExpected, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, syncKnownInstallations } from './service.mjs';
@@ -48,11 +49,15 @@ export async function resolveUpdateTargets({
48
49
  env = process.env,
49
50
  fetchImpl = globalThis.fetch,
50
51
  }) {
52
+ const validatedServerUrl = assertSecureServerUrl(cfg.serverUrl, {
53
+ allowInsecure: cfg.allowInsecureTransport === true || flags['allow-insecure-transport'] === true || env.REMCP_ALLOW_INSECURE_TRANSPORT === 'true',
54
+ });
55
+ const validatedCfg = { ...cfg, serverUrl: validatedServerUrl };
51
56
  const requestedRuntime = typeof flags.runtime === 'string' ? flags.runtime.trim() : '';
52
57
  const requestedClient = typeof flags.client === 'string' ? flags.client.trim() : '';
53
58
  const latestClient = `${PACKAGE_NAME}@latest`;
54
59
  const currentClient = `${PACKAGE_NAME}@${VERSION}`;
55
- const trusted = runtimeTrustAllowed(cfg, flags, env);
60
+ const trusted = runtimeTrustAllowed(validatedCfg, flags, env);
56
61
 
57
62
  let clientSpec = latestClient;
58
63
  if (requestedClient) {
@@ -63,28 +68,28 @@ export async function resolveUpdateTargets({
63
68
  if (requestedRuntime) {
64
69
  // Only `<configured package>@<semver>` is installable: an alias, a git/URL/file spec, a tag or
65
70
  // a range would run code the user never agreed to.
66
- if (!isRuntimeSpecFor(cfg.runtime.packageName, requestedRuntime)) {
67
- throw new Error(`--runtime must be ${cfg.runtime.packageName}@<version>`);
71
+ if (!isRuntimeSpecFor(validatedCfg.runtime.packageName, requestedRuntime)) {
72
+ throw new Error(`--runtime must be ${validatedCfg.runtime.packageName}@<version>`);
68
73
  }
69
74
  if (!trusted) {
70
- throw new Error(`This machine was paired without trusting ${cfg.serverUrl} to choose a runtime version. Re-run with --trust-runtime if you trust that server.`);
75
+ throw new Error(`This machine was paired without trusting ${validatedCfg.serverUrl} to choose a runtime version. Re-run with --trust-runtime if you trust that server.`);
71
76
  }
72
77
  const runtimeSpec = normalizeRuntime({
73
78
  kind: 'npm',
74
- packageName: cfg.runtime.packageName,
79
+ packageName: validatedCfg.runtime.packageName,
75
80
  packageSpec: requestedRuntime,
76
- entry: cfg.runtime.entry,
81
+ entry: validatedCfg.runtime.entry,
77
82
  }).packageSpec;
78
- if (requestedClient && !releasePairMatches(clientSpec, cfg.runtime.packageName, runtimeSpec)) {
83
+ if (requestedClient && !releasePairMatches(clientSpec, validatedCfg.runtime.packageName, runtimeSpec)) {
79
84
  throw new Error('The client and first-party runtime must use the same exact release version.');
80
85
  }
81
- if (!requestedClient && cfg.runtime.packageName === '@remcp/runtime' && specVersion(cfg.runtime.packageName, runtimeSpec) !== VERSION) {
86
+ if (!requestedClient && validatedCfg.runtime.packageName === '@remcp/runtime' && specVersion(validatedCfg.runtime.packageName, runtimeSpec) !== VERSION) {
82
87
  throw new Error(`--runtime without --client must match the running client version ${VERSION}`);
83
88
  }
84
89
  return {
85
90
  clientSpec: requestedClient ? clientSpec : currentClient,
86
91
  runtimeSpec,
87
- persistRuntime: runtimeSpec !== cfg.runtime.packageSpec,
92
+ persistRuntime: runtimeSpec !== validatedCfg.runtime.packageSpec,
88
93
  installable: true,
89
94
  source: requestedClient ? 'explicit-pair' : 'explicit-runtime',
90
95
  warning: '',
@@ -100,7 +105,7 @@ export async function resolveUpdateTargets({
100
105
  if (!trusted) {
101
106
  return {
102
107
  clientSpec,
103
- runtimeSpec: cfg.runtime.packageSpec,
108
+ runtimeSpec: validatedCfg.runtime.packageSpec,
104
109
  persistRuntime: false,
105
110
  installable: true,
106
111
  source: 'configured',
@@ -109,15 +114,16 @@ export async function resolveUpdateTargets({
109
114
  }
110
115
 
111
116
  try {
112
- const versionUrl = new URL('/api/agent/version', cfg.serverUrl).toString();
117
+ const versionUrl = new URL('/api/agent/version', validatedCfg.serverUrl).toString();
113
118
  const response = await fetchImpl(versionUrl, {
114
119
  headers: { accept: 'application/json' },
120
+ redirect: 'error',
115
121
  signal: AbortSignal.timeout(UPDATE_DISCOVERY_TIMEOUT_MS),
116
122
  });
117
123
  if (!response?.ok) {
118
124
  return {
119
125
  clientSpec: currentClient,
120
- runtimeSpec: cfg.runtime.packageSpec,
126
+ runtimeSpec: validatedCfg.runtime.packageSpec,
121
127
  persistRuntime: false,
122
128
  installable: false,
123
129
  source: 'configured',
@@ -130,11 +136,11 @@ export async function resolveUpdateTargets({
130
136
  const advertisedClient = exactClientSpec(advertised?.cli);
131
137
  const clientCandidate = requestedClient || advertisedClient;
132
138
  if (!advertisedClient
133
- || !releasePairMatches(advertisedClient, cfg.runtime.packageName, runtimeCandidate)
139
+ || !releasePairMatches(advertisedClient, validatedCfg.runtime.packageName, runtimeCandidate)
134
140
  || (requestedClient && requestedClient !== advertisedClient)) {
135
141
  return {
136
142
  clientSpec: currentClient,
137
- runtimeSpec: cfg.runtime.packageSpec,
143
+ runtimeSpec: validatedCfg.runtime.packageSpec,
138
144
  persistRuntime: false,
139
145
  installable: false,
140
146
  source: 'configured',
@@ -144,14 +150,14 @@ export async function resolveUpdateTargets({
144
150
 
145
151
  const runtimeSpec = normalizeRuntime({
146
152
  kind: 'npm',
147
- packageName: cfg.runtime.packageName,
153
+ packageName: validatedCfg.runtime.packageName,
148
154
  packageSpec: runtimeCandidate,
149
- entry: cfg.runtime.entry,
155
+ entry: validatedCfg.runtime.entry,
150
156
  }).packageSpec;
151
157
  return {
152
158
  clientSpec: clientCandidate,
153
159
  runtimeSpec,
154
- persistRuntime: runtimeSpec !== cfg.runtime.packageSpec,
160
+ persistRuntime: runtimeSpec !== validatedCfg.runtime.packageSpec,
155
161
  installable: true,
156
162
  source: 'server',
157
163
  warning: '',
@@ -159,7 +165,7 @@ export async function resolveUpdateTargets({
159
165
  } catch (error) {
160
166
  return {
161
167
  clientSpec: currentClient,
162
- runtimeSpec: cfg.runtime.packageSpec,
168
+ runtimeSpec: validatedCfg.runtime.packageSpec,
163
169
  persistRuntime: false,
164
170
  installable: false,
165
171
  source: 'configured',
package/src/cli.mjs CHANGED
@@ -8,7 +8,7 @@ import { PACKAGE_NAME, VERSION } from './version.mjs';
8
8
  import { probeFilesystemAccess } from './fs-access.mjs';
9
9
 
10
10
  import { ensureMachineId, loadConfig, readJsonFile, saveConfig, setTelemetry, telemetryState, writeJsonFile } from './cli/config.mjs';
11
- import { assertRuntimeTrust, pairWithDeviceCode } from './cli/connect.mjs';
11
+ import { assertRuntimeTrust, assertSecureServerUrl, pairWithDeviceCode } from './cli/connect.mjs';
12
12
  import { diagnoseLocalRuntime, installedVersion, runtimeAllowedRoots } from './cli/doctor.mjs';
13
13
  import { configFile, npm, officialOrigin, runtimeConfigFile } from './cli/env.mjs';
14
14
  import { currentInstallationInfo, ensureMacCliCommand, installPersistentAgent, persistentServiceState, rememberCurrentInstallation, restartPersistentServiceIfInstalled, serviceInstallationInfo, uninstallPersistentService } from './cli/service.mjs';
@@ -49,16 +49,19 @@ export async function main(argv = process.argv.slice(2)) {
49
49
  // Like the desktop-app flow this mirrors: with no flags the command talks to the official
50
50
  // server, prints a code, opens the browser to approve it, and pairs. `--code` keeps working for
51
51
  // the workspace-generated command and for CI, and `--server` for self-hosted deployments.
52
- const server = String(flags.server || officialOrigin).replace(/\/$/, '');
52
+ const server = assertSecureServerUrl(flags.server || officialOrigin, { allowInsecure: flags['allow-insecure-transport'] === true });
53
53
  const code = String(flags.code || '').replace(/\s+/g, '').toUpperCase();
54
54
  assertRuntimeTrust(server, flags);
55
+
55
56
  let paired;
56
57
  let deviceInitiated = false;
57
58
  if (code) {
58
59
  const response = await fetch(`${server}/api/pair/claim`, {
59
60
  method: 'POST',
60
- headers: { 'content-type': 'application/json' },
61
- body: JSON.stringify({ code, machineId: ensureMachineId(), name: String(flags.name || os.hostname()), hostname: os.hostname(), platform: process.platform, arch: process.arch }),
61
+ redirect: 'error',
62
+ headers: { 'content-type': 'application/json' },
63
+ body: JSON.stringify({ code, machineId: ensureMachineId(), name: String(flags.name || os.hostname()), hostname: os.hostname(), platform: process.platform, arch: process.arch }),
64
+
62
65
  });
63
66
  if (!response.ok) throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
64
67
  paired = await response.json();
@@ -81,6 +84,8 @@ export async function main(argv = process.argv.slice(2)) {
81
84
  // Remember whether this machine's owner trusted the server to name a runtime version. The
82
85
  // auto-updater must not widen that decision on its own later.
83
86
  trustRuntime: Boolean(flags['trust-runtime']) || new URL(server).origin === officialOrigin,
87
+ allowInsecureTransport: flags['allow-insecure-transport'] === true,
88
+
84
89
  };
85
90
  saveConfig(config);
86
91
  const accountEmail = String(paired.account?.email || '');
@@ -194,11 +199,12 @@ export async function main(argv = process.argv.slice(2)) {
194
199
 
195
200
  if (command === 'status' || command === 'doctor') {
196
201
  const cfg = rememberCurrentInstallation(loadConfig());
202
+ const server = assertSecureServerUrl(cfg.serverUrl, { allowInsecure: cfg.allowInsecureTransport === true || process.env.REMCP_ALLOW_INSECURE_TRANSPORT === 'true' });
197
203
  const [health, advertised] = await Promise.all([
198
- fetchConfiguredServer(`${cfg.serverUrl}/health?fresh=${Date.now()}`, { cache:'no-store' })
204
+ fetchConfiguredServer(`${server}/health?fresh=${Date.now()}`, { cache:'no-store', redirect:'error' })
199
205
  .then(async response => ({ reachable:response.ok && (await response.json().catch(() => null))?.ok === true }))
200
206
  .catch(error => ({ reachable:false, error:error.message })),
201
- fetchConfiguredServer(`${cfg.serverUrl}/api/agent/version?fresh=${Date.now()}`, { cache:'no-store' })
207
+ fetchConfiguredServer(`${server}/api/agent/version?fresh=${Date.now()}`, { cache:'no-store', redirect:'error' })
202
208
  .then(response => response.ok ? response.json() : null)
203
209
  .catch(() => null),
204
210
  ]);
package/src/runtime.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
 
3
- const PACKAGE_NAME = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i;
3
+ const PACKAGE_NAME = /^(?:@[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?|[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)$/i;
4
4
  // A runtime spec is a plain published version of one package. Anything else — an npm alias
5
5
  // (`npm:@other/pkg`), a git/https/file spec, a tag, or a version range — is a way to make a device
6
6
  // install and execute code the user never agreed to, so only `name@x.y.z[-pre][+build]` is accepted.
@@ -13,7 +13,9 @@ export function normalizeRuntime(value) {
13
13
  const entry = String(value.entry || '');
14
14
  if (!PACKAGE_NAME.test(packageName)) throw new Error('Pairing server returned an invalid runtime package name');
15
15
  if (!isRuntimeSpecFor(packageName, packageSpec)) throw new Error(`Runtime package spec must be ${packageName}@<version>`);
16
- if (!entry || path.isAbsolute(entry) || entry.split(/[\\/]+/).includes('..')) throw new Error('Pairing server returned an invalid runtime entry');
16
+ const entryParts = entry.split(/[\\/]+/);
17
+ if (!entry || entry.includes('\0') || path.isAbsolute(entry) || entryParts.some(part => !part || part === '.' || part === '..')) throw new Error('Pairing server returned an invalid runtime entry');
18
+
17
19
  return { kind: 'npm', packageName, packageSpec, entry };
18
20
  }
19
21