@remcp/runtime 0.2.18 → 0.2.20

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.20
4
+
5
+ - `set_config_value`: a model may change this runtime’s own preferences (telemetry opt-out, read
6
+ and buffer line limits, result size) while it is running; the change applies immediately and is
7
+ saved to runtime.json. Access roots, blocked commands, the command guardrail, the shell and the
8
+ write limit stay with the person at this computer and are refused by the tool.
9
+
3
10
  ## 0.2.16
4
11
 
5
12
  - Mark cursor-consuming `read_process_output` as non-idempotent so clients do not assume retries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "First-party ReMCP local device runtime: file, search, terminal and process tools over MCP for computers paired with ReMCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/catalog.mjs CHANGED
@@ -3,6 +3,7 @@ import { searchToolHandlers } from './tools/search.mjs';
3
3
  import { terminalToolHandlers } from './tools/terminal.mjs';
4
4
  import { systemToolHandlers } from './tools/system.mjs';
5
5
  import { statsToolHandlers } from './tools/stats.mjs';
6
+ import { configToolHandlers } from './tools/config.mjs';
6
7
 
7
8
  // Every tool answers with a text result (image tools add an image part as well), so the
8
9
  // declared output schema is the same shape everywhere and clients can rely on it.
@@ -737,6 +738,23 @@ export const toolDefinitions = [
737
738
  annotations: readOnly,
738
739
  handler: statsToolHandlers.get_runtime_stats,
739
740
  },
741
+ {
742
+ name: 'set_config_value',
743
+ title: 'Change runtime setting',
744
+ description: 'Change one of this runtime’s own preferences on this computer: telemetryEnabled, maxReadLines, maxBufferedLines or maxOutputBytes. The change applies immediately and is saved to runtime.json. Access roots, blocked commands, the command guardrail, the shell and the write limit cannot be set through MCP — they stay with the person at this computer.',
745
+ inputSchema: {
746
+ type: 'object',
747
+ properties: {
748
+ key: { type: 'string', description: 'telemetryEnabled (true/false), maxReadLines, maxBufferedLines or maxOutputBytes.' },
749
+ value: { description: 'New value: a boolean for telemetryEnabled, a number for the limits.' },
750
+ },
751
+ required: ['key', 'value'],
752
+ additionalProperties: false,
753
+ },
754
+ // Preference only, and the same call twice leaves the same state.
755
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
756
+ handler: configToolHandlers.set_config_value,
757
+ },
740
758
  ];
741
759
 
742
760
  export function advertisedTools() {
package/src/config.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
- import { readFileSync } from 'node:fs';
3
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
4
  import { expandHome } from './util.mjs';
5
5
 
6
6
  const configDir = process.env.REMCP_RUNTIME_CONFIG_DIR || path.join(os.homedir(), '.config', 'remcp');
@@ -97,6 +97,61 @@ export const runtimeConfig = Object.freeze({
97
97
  telemetryEnabled,
98
98
  });
99
99
 
100
+ // --- settings a model may change, and nothing else ------------------------------------------------
101
+ //
102
+ // Desktop Commander lets a model rewrite any of its own configuration, including the directories it
103
+ // may touch and the commands it must refuse. On ReMCP those two decide what the computer exposes, so
104
+ // they stay with the person at the computer: `allowedRoots`, `blockedCommands`, `dangerousCommands`
105
+ // (the command guardrail), `defaultShell`, `maxWriteBytes` and `name` are not settable from a tool.
106
+ //
107
+ // What is settable is a preference and two context limits — telemetry opt-out, the read and buffer
108
+ // line limits, and the result size — all of which the person can also change in runtime.json. The
109
+ // values apply immediately (the tool handlers read them through liveConfig) and are written back to
110
+ // runtime.json so they survive a restart.
111
+ const SETTABLE = Object.freeze({
112
+ telemetryEnabled: { type: 'boolean' },
113
+ maxReadLines: { type: 'integer', min: 1, max: 100_000 },
114
+ maxBufferedLines: { type: 'integer', min: 1, max: 1_000_000 },
115
+ maxOutputBytes: { type: 'integer', min: 1024, max: HARD_OUTPUT_CEILING_BYTES },
116
+ });
117
+ export const settableKeys = Object.freeze(Object.keys(SETTABLE));
118
+
119
+ const live = new Map();
120
+
121
+ // Every read of a settable value goes through here, so a change applies to the next call.
122
+ export function liveConfig(key) {
123
+ return live.has(key) ? live.get(key) : runtimeConfig[key];
124
+ }
125
+
126
+ export function validateConfigValue(key, raw) {
127
+ const rule = SETTABLE[key];
128
+ if (!rule) {
129
+ throw new Error(`Unsupported setting: ${key || '(empty)'}. Settable here: ${settableKeys.join(', ')}. Access roots, blocked commands, the command guardrail, the shell and write limits are changed by the person at this computer.`);
130
+ }
131
+ if (rule.type === 'boolean') {
132
+ if (typeof raw === 'boolean') return raw;
133
+ if (typeof raw === 'string') return booleanValue(raw, null) ?? (() => { throw new Error(`${key} must be true or false`); })();
134
+ throw new Error(`${key} must be true or false`);
135
+ }
136
+ const value = Math.trunc(Number(raw));
137
+ if (!Number.isFinite(value)) throw new Error(`${key} must be a number`);
138
+ if (value < rule.min || value > rule.max) throw new Error(`${key} must be between ${rule.min} and ${rule.max}`);
139
+ return value;
140
+ }
141
+
142
+ export function applyLiveConfig(key, value) {
143
+ live.set(key, value);
144
+ }
145
+
146
+ // Writes only the changed key back, with the file mode the rest of the runtime expects.
147
+ export function persistConfigValue(key, value) {
148
+ const file = readConfigFile();
149
+ const next = { ...file, [key]: value };
150
+ mkdirSync(runtimeConfigDir, { recursive: true, mode: 0o700 });
151
+ writeFileSync(runtimeConfigPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
152
+ return runtimeConfigPath;
153
+ }
154
+
100
155
  // A configuration the user cannot read is not a configuration we should quietly ignore:
101
156
  // it is how allowedRoots and an opt-out silently disappear.
102
157
  export function configurationError() {
package/src/sessions.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import process from 'node:process';
2
- import { runtimeConfig } from './config.mjs';
2
+ import { liveConfig, runtimeConfig } from './config.mjs';
3
3
 
4
4
  const processSessions = new Map();
5
5
  const searchSessions = new Map();
@@ -13,7 +13,7 @@ const MAX_PARTIAL_BYTES = 64 * 1024;
13
13
  const MAX_BUFFERED_CHARS = 8 * 1024 * 1024;
14
14
 
15
15
  function trimBuffer(session) {
16
- const overflow = session.lines.length - runtimeConfig.maxBufferedLines;
16
+ const overflow = session.lines.length - liveConfig('maxBufferedLines');
17
17
  if (overflow > 0) {
18
18
  const removed = session.lines.splice(0, overflow);
19
19
  for (const line of removed) session.bufferedChars -= line.length;
package/src/telemetry.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import process from 'node:process';
2
- import { runtimeConfig } from './config.mjs';
2
+ import { liveConfig, runtimeConfig } from './config.mjs';
3
3
  import { VERSION } from './version.mjs';
4
4
 
5
5
  // Event fields are whitelisted: an event can never carry a file path, a command
@@ -20,7 +20,7 @@ const FLUSH_INTERVAL_MS = 15_000;
20
20
  const FLUSH_THRESHOLD = 20;
21
21
 
22
22
  const state = {
23
- enabled: runtimeConfig.telemetryEnabled,
23
+ enabled: liveConfig('telemetryEnabled'),
24
24
  buffer: [],
25
25
  sink: null,
26
26
  timer: null,
@@ -39,8 +39,10 @@ const state = {
39
39
  toolCounts: new Map(),
40
40
  };
41
41
 
42
+ // Live: a runtime setting changed through set_config_value applies to the next call, not the next
43
+ // restart.
42
44
  export function telemetryEnabled() {
43
- return state.enabled;
45
+ return liveConfig('telemetryEnabled');
44
46
  }
45
47
 
46
48
  export function telemetryStatus() {
@@ -97,7 +99,7 @@ export function recordEvent(event, detail = {}) {
97
99
  if (name === 'policy_block') state.counters.policyBlocks += 1;
98
100
  if (name === 'session_started') state.counters.sessionsStarted += 1;
99
101
  if (name === 'write_denied') state.counters.writeDenials += 1;
100
- if (!state.enabled) return;
102
+ if (!telemetryEnabled()) return;
101
103
  if (state.buffer.length >= BUFFER_LIMIT) {
102
104
  state.dropped += 1;
103
105
  return;
@@ -0,0 +1,31 @@
1
+ import { applyLiveConfig, liveConfig, persistConfigValue, runtimeConfig, settableKeys, validateConfigValue } from '../config.mjs';
2
+ import { text } from '../util.mjs';
3
+
4
+ // The one write into this runtime's own configuration, and a deliberately narrow one.
5
+ //
6
+ // Desktop Commander exposes its whole config object to the model, including the directories it may
7
+ // touch and the commands it must refuse. Those two decide what this computer exposes, so they stay
8
+ // with the person at the computer: only the telemetry opt-out and the read/buffer/output limits can be
9
+ // changed here, and the change is written back to runtime.json so it survives a restart.
10
+ export async function setConfigValueTool(args = {}) {
11
+ const key = String(args.key || '').trim();
12
+ const value = validateConfigValue(key, args.value);
13
+ applyLiveConfig(key, value);
14
+ const file = persistConfigValue(key, value);
15
+ return text(JSON.stringify({
16
+ ok: true,
17
+ key,
18
+ value,
19
+ applied: 'immediately',
20
+ savedTo: file,
21
+ note: `Effective now and after a restart. Settable through MCP: ${settableKeys.join(', ')}. Access roots, blocked commands, the command guardrail, the shell and the write limit stay with the person at this computer.`,
22
+ effective: Object.fromEntries(settableKeys.map(name => [name, liveConfig(name)])),
23
+ }, null, 2));
24
+ }
25
+
26
+ export const configToolHandlers = {
27
+ set_config_value: args => setConfigValueTool(args),
28
+ };
29
+
30
+ // Kept so a future tool can report the settable surface without duplicating the list.
31
+ export const configSurface = Object.freeze({ settableKeys, roots: runtimeConfig.allowedRoots });
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto';
5
5
  import { constants, createReadStream } from 'node:fs';
6
6
  import { access, chmod, chown, copyFile, cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
7
7
  import { pipeline } from 'node:stream/promises';
8
- import { runtimeConfig } from '../config.mjs';
8
+ import { liveConfig, runtimeConfig } from '../config.mjs';
9
9
  import { diffStats, unifiedDiff } from '../diff.mjs';
10
10
  import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
11
11
  import { countEvent, recordEvent } from '../telemetry.mjs';
@@ -97,7 +97,7 @@ export async function readFileTool(args) {
97
97
  const { content, encoding, eol } = await readTextFile(absolute);
98
98
  const lines = splitLines(content);
99
99
  const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
100
- const length = clampInteger(args.length, runtimeConfig.maxReadLines, 1, 10000);
100
+ const length = clampInteger(args.length, liveConfig('maxReadLines'), 1, 10000);
101
101
  const { start, end, slice } = pageLines(lines, offset, length);
102
102
  const notes = `${encoding === 'utf8' ? '' : ` ${encoding}`}${eol === '\r\n' ? ' CRLF' : ''}`;
103
103
  const header = lines.length
@@ -121,7 +121,7 @@ export async function readMultipleFilesTool(args) {
121
121
  try {
122
122
  const { content } = await readTextFile(absolute);
123
123
  const lines = splitLines(content);
124
- const limit = runtimeConfig.maxReadLines;
124
+ const limit = liveConfig('maxReadLines');
125
125
  const slice = lines.slice(0, limit);
126
126
  const suffix = lines.length > limit ? `\n… ${lines.length - limit} more lines truncated` : '';
127
127
  sections.push(`${displayPath(absolute)}:\n${slice.join('\n')}${suffix}`);
@@ -506,7 +506,7 @@ export async function readFilesTool(args) {
506
506
  const root = await resolveSafePath(args.path || '.');
507
507
  const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? args.pattern.trim() : '**/*';
508
508
  const maxFiles = clampInteger(args.max_files, 100, 1, 500);
509
- const maxLinesPerFile = clampInteger(args.max_lines_per_file, runtimeConfig.maxReadLines, 1, 20000);
509
+ const maxLinesPerFile = clampInteger(args.max_lines_per_file, liveConfig('maxReadLines'), 1, 20000);
510
510
  const includeIgnored = args.include_ignored === true;
511
511
  const matcher = globToRegExp(pattern);
512
512
  const rootInfo = await stat(root).catch(() => null);
@@ -1,5 +1,5 @@
1
1
  import process from 'node:process';
2
- import { describeConfig, runtimeConfig } from '../config.mjs';
2
+ import { describeConfig, liveConfig, runtimeConfig } from '../config.mjs';
3
3
  import { dangerousPatternIds } from '../policy.mjs';
4
4
  import { listProcessSessions, listSearchSessions } from '../sessions.mjs';
5
5
  import { telemetryStatus } from '../telemetry.mjs';
@@ -29,9 +29,9 @@ export async function getRuntimeInfoTool() {
29
29
  remoteFeatureFlags: telemetry.remoteFeatureFlags,
30
30
  },
31
31
  limits: {
32
- maxOutputBytes: runtimeConfig.maxOutputBytes,
33
- maxReadLines: runtimeConfig.maxReadLines,
34
- maxBufferedLines: runtimeConfig.maxBufferedLines,
32
+ maxOutputBytes: liveConfig('maxOutputBytes'),
33
+ maxReadLines: liveConfig('maxReadLines'),
34
+ maxBufferedLines: liveConfig('maxBufferedLines'),
35
35
  maxWriteBytes: runtimeConfig.maxWriteBytes,
36
36
  maxConcurrentConnections: 1,
37
37
  },
package/src/util.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import { realpath } from 'node:fs/promises';
4
- import { runtimeConfig } from './config.mjs';
4
+ import { liveConfig, runtimeConfig } from './config.mjs';
5
5
 
6
6
  export class ToolError extends Error {}
7
7
 
@@ -111,7 +111,7 @@ function frameBytes(text) {
111
111
  }
112
112
 
113
113
  export function truncate(text, maxBytes) {
114
- const limit = maxBytes || runtimeConfig.maxOutputBytes;
114
+ const limit = maxBytes || liveConfig('maxOutputBytes');
115
115
  const value = String(text);
116
116
  if (Buffer.byteLength(value, 'utf8') <= limit && frameBytes(value) <= limit) return value;
117
117
  // Shrink until the escaped frame fits, so the escaped size is what the caller gets is bounded.
@@ -191,7 +191,7 @@ export function pageLines(lines, offset, length) {
191
191
  return { start: total - count, end: total, slice: lines.slice(total - count) };
192
192
  }
193
193
  const start = Math.min(requested, total);
194
- const end = Math.min(start + Math.max(1, Math.trunc(length || runtimeConfig.maxReadLines)), total);
194
+ const end = Math.min(start + Math.max(1, Math.trunc(length || liveConfig('maxReadLines'))), total);
195
195
  return { start, end, slice: lines.slice(start, end) };
196
196
  }
197
197