@remcp/runtime 0.2.135 → 0.2.136

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.136 — 2026-10-02
4
+
5
+ - `installed_apps` now normalizes Windows registry, macOS `system_profiler`, `dpkg-query`, and `rpm` inventories into one structured `data[]` contract with stable app name/version/publisher/path fields.
6
+ - Stable top-level `backend`, `count`, `returned`, and `truncated` metadata make result limits explicit and remove platform-specific text parsing from callers.
7
+ - Name filtering and de-duplication are shared across platforms. Windows no longer interpolates the filter into PowerShell.
8
+ - Public release CI exercises the live native inventory on Linux, macOS, and Windows. Tool names remain unchanged at 83; the output-schema digest changes intentionally.
9
+
3
10
  ## 0.2.135 — 2026-10-02
4
11
 
5
12
  - Linux `audio` status is normalized across `wpctl`, `pactl`, and `amixer` into structured `volume`, `muted`, `action`, and `backend` fields; mutating audio actions re-read and return the verified state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.135",
3
+ "version": "0.2.136",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -212,7 +212,19 @@ const OUTPUT_FIELDS = Object.freeze({
212
212
  service: { data:outputArray(outputObject('Service record.')), action:outputString(), name:outputString(), text:outputString() },
213
213
  event_log: { text:outputString('Bounded log output.'), data:outputArray(outputObject()) },
214
214
  network: { hostname:outputString(), interfaces:outputObject(), dns:outputArray(outputString()), servers:outputArray(outputString()), host:outputString(), port:outputNumber(), ok:outputBoolean(), latency_ms:outputNumber(), interface_count:outputNumber(), interface_count_returned:outputNumber(), address_count:outputNumber(), address_count_returned:outputNumber(), interfaces_truncated:outputBoolean(), addresses_truncated:outputBoolean(), data:outputArray(outputObject()) },
215
- installed_apps: { data:outputArray(outputObject('Installed app/package with name/version/path where available.')) },
215
+ installed_apps: {
216
+ data:outputArray(outputRecord({
217
+ name:outputString('Installed application/package name.'),
218
+ version:{ type:['string','null'], description:'Installed version when available.' },
219
+ publisher:{ type:['string','null'], description:'Publisher/maintainer/vendor when available.' },
220
+ path:{ type:['string','null'], description:'Installation path when available.' },
221
+ signed_by:outputString('Optional macOS signing identity summary.'),
222
+ }, 'Normalized installed app/package record.')),
223
+ backend:outputString('Native inventory backend used on this platform.'),
224
+ count:outputNumber('Total matching records before the requested limit.'),
225
+ returned:outputNumber('Number of records returned in data.'),
226
+ truncated:outputBoolean('True when count is larger than returned because of the requested limit.'),
227
+ },
216
228
  environment: { platform:outputString(), arch:outputString(), hostname:outputString(), release:outputString(), shell:outputString(), path:outputString(), node:outputString(), env:outputObject() },
217
229
  audio: { volume:outputNumber(), muted:outputBoolean(), action:outputString(), backend:outputString(), text:outputString() },
218
230
  power_action: { text:outputString(), action:outputString() },
@@ -218,26 +218,90 @@ export async function networkTool(args) {
218
218
  unavailable('Listener inventory', 'ss or netstat is required');
219
219
  }
220
220
 
221
+ function installedAppString(value) {
222
+ const normalized = value == null ? '' : String(value).trim();
223
+ return normalized || null;
224
+ }
225
+
226
+ export function normalizeInstalledApp(row = {}) {
227
+ const name = installedAppString(row.name ?? row.DisplayName ?? row._name);
228
+ if (!name) return null;
229
+ const signedBy = installedAppString(row.signed_by);
230
+ return {
231
+ name,
232
+ version:installedAppString(row.version ?? row.DisplayVersion),
233
+ publisher:installedAppString(row.publisher ?? row.Publisher),
234
+ path:installedAppString(row.path ?? row.InstallLocation),
235
+ ...(signedBy ? { signed_by:signedBy } : {}),
236
+ };
237
+ }
238
+
239
+ export function parseInstalledAppsTsv(output) {
240
+ return String(output || '').split(/\r?\n/).filter(Boolean).map(line => {
241
+ const [name = '', version = '', publisher = '', pathValue = ''] = line.split('\t');
242
+ return normalizeInstalledApp({ name, version, publisher, path:pathValue });
243
+ }).filter(Boolean);
244
+ }
245
+
246
+ export function installedAppsPayload(rows, { backend, filter = null, limit = 1000 } = {}) {
247
+ const wanted = optionalString(filter)?.toLowerCase() || null;
248
+ const boundedLimit = clamp(limit, 1000, 1, 10_000);
249
+ const seen = new Set();
250
+ const normalized = [];
251
+ for (const raw of Array.isArray(rows) ? rows : (rows ? [rows] : [])) {
252
+ const app = normalizeInstalledApp(raw);
253
+ if (!app) continue;
254
+ if (wanted && !app.name.toLowerCase().includes(wanted)) continue;
255
+ const key = [app.name.toLowerCase(), app.version || '', app.path || ''].join('\u0000');
256
+ if (seen.has(key)) continue;
257
+ seen.add(key);
258
+ normalized.push(app);
259
+ }
260
+ normalized.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity:'base' })
261
+ || String(left.version || '').localeCompare(String(right.version || ''), undefined, { sensitivity:'base' }));
262
+ const data = normalized.slice(0, boundedLimit);
263
+ return {
264
+ data,
265
+ backend:String(backend || 'unknown'),
266
+ count:normalized.length,
267
+ returned:data.length,
268
+ truncated:normalized.length > data.length,
269
+ };
270
+ }
271
+
272
+ export function windowsInstalledAppsPowerShell() {
273
+ return [
274
+ "$ErrorActionPreference='Stop'",
275
+ "$paths=@('HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*')",
276
+ "$items=foreach($path in $paths){Get-ItemProperty -Path $path -ErrorAction SilentlyContinue}",
277
+ "$items|Where-Object{$_.DisplayName}|ForEach-Object{[PSCustomObject]@{name=[string]$_.DisplayName;version=[string]$_.DisplayVersion;publisher=[string]$_.Publisher;path=[string]$_.InstallLocation}}|ConvertTo-Json -Compress",
278
+ ].join(';');
279
+ }
280
+
221
281
  export async function installedApps(args) {
222
282
  const limit = clamp(args.limit, 1000, 1, 10_000);
223
283
  const filter = optionalString(args.filter);
224
284
  if (process.platform === 'win32') {
225
- const where = filter ? ` | Where-Object { $_.DisplayName -like '*${escapePowerShellSingle(filter)}*' }` : '';
226
- const script = `$paths=@('HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*');Get-ItemProperty $paths -ErrorAction SilentlyContinue${where}|Where-Object{$_.DisplayName}|Select-Object -First ${limit} DisplayName,DisplayVersion,Publisher,InstallLocation|Sort-Object DisplayName -Unique|ConvertTo-Json -Compress`;
227
- return text((await runPowerShell(script, { label:'installed apps', timeout:30_000 })).stdout.trim() || '[]');
285
+ const { stdout } = await runPowerShell(windowsInstalledAppsPowerShell(), { label:'installed apps', timeout:30_000, maxBuffer:32*1024*1024 });
286
+ let parsed;
287
+ try {
288
+ parsed = JSON.parse(stdout.trim() || '[]');
289
+ } catch {
290
+ throw new Error('Windows installed-app inventory returned invalid JSON');
291
+ }
292
+ return jsonResult(installedAppsPayload(parsed, { backend:'windows-registry', filter, limit }));
228
293
  }
229
294
  if (process.platform === 'darwin') {
230
295
  const parsed = JSON.parse((await runFile('/usr/sbin/system_profiler', ['SPApplicationsDataType','-json'], { label:'installed apps', timeout:60_000, maxBuffer:64*1024*1024 })).stdout);
231
- const apps = (parsed.SPApplicationsDataType || []).filter(app => !filter || String(app._name || '').toLowerCase().includes(filter.toLowerCase())).slice(0, limit);
232
- return jsonResult(apps.map(app => ({ name:app._name, version:app.version || null, path:app.path || null, signed_by:app.signed_by || null })));
296
+ return jsonResult(installedAppsPayload(parsed.SPApplicationsDataType || [], { backend:'system_profiler', filter, limit }));
233
297
  }
234
298
  if (commandExists('dpkg-query')) {
235
299
  const { stdout } = await runFile('dpkg-query', ['-W','-f=${binary:Package}\t${Version}\t${Maintainer}\n'], { label:'installed apps', maxBuffer:32*1024*1024 });
236
- return text(stdout.split('\n').filter(Boolean).filter(line => !filter || line.toLowerCase().includes(filter.toLowerCase())).slice(0, limit).join('\n'));
300
+ return jsonResult(installedAppsPayload(parseInstalledAppsTsv(stdout), { backend:'dpkg-query', filter, limit }));
237
301
  }
238
302
  if (commandExists('rpm')) {
239
303
  const { stdout } = await runFile('rpm', ['-qa','--qf','%{NAME}\t%{VERSION}-%{RELEASE}\t%{VENDOR}\n'], { label:'installed apps', maxBuffer:32*1024*1024 });
240
- return text(stdout.split('\n').filter(Boolean).filter(line => !filter || line.toLowerCase().includes(filter.toLowerCase())).slice(0, limit).join('\n'));
304
+ return jsonResult(installedAppsPayload(parseInstalledAppsTsv(stdout), { backend:'rpm', filter, limit }));
241
305
  }
242
306
  unavailable('Installed application inventory', 'dpkg-query or rpm is required');
243
307
  }