@remcp/runtime 0.2.127 → 0.2.129

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.129 — 2026-10-02
4
+
5
+ - Linux Desktop capability discovery now requires an active `DISPLAY` or `WAYLAND_DISPLAY`, preventing headless hosts from advertising desktop actions just because helper binaries are installed.
6
+ - `launch_app` requires `desktop_session`; `open_path` and `reveal_path` require `desktop_open`, which additionally checks `xdg-open` on Linux.
7
+ - AT-SPI/Wayland portal probes are skipped entirely on headless Linux. Browser/CDP and non-desktop capability groups remain independent.
8
+ - Tool names and MCP schemas remain unchanged.
9
+
10
+ ## 0.2.128 — 2026-10-02
11
+
12
+ - Linux `power_action` is advertised only when both `loginctl` and `systemctl` exist, matching the full lock/sleep/restart/shutdown schema instead of exposing a partially unusable tool.
13
+ - Linux `network listeners` keeps `ss -lntup` as the preferred backend and changes the `netstat` fallback from `-an` to `-lntu`, excluding established/non-listening connections.
14
+ - Tool names and schemas remain unchanged; dynamic capability updates continue to use `tools/list_changed`.
15
+
3
16
  ## 0.2.127 — 2026-10-01
4
17
 
5
18
  - macOS `network listeners` now returns only TCP `LISTEN` rows from the native network table instead of all active connections.
package/README.md CHANGED
@@ -77,7 +77,10 @@ system tools such as qpdf/poppler when present. PDF text reading uses the built-
77
77
  A running device can advertise a smaller capability-aware subset; the runtime declares
78
78
  `tools.listChanged`, emits `notifications/tools/list_changed` when dynamic capabilities change, and the
79
79
  agent forwards the refreshed tool names to the hosted relay. Browser/CDP tools are the primary dynamic
80
- example: they are enabled only while the runtime can use a supported loopback browser endpoint.
80
+ example: they are enabled only while the runtime can use a supported loopback browser endpoint. On Linux,
81
+ Desktop capabilities additionally require an active graphical session (`DISPLAY` or `WAYLAND_DISPLAY`):
82
+ headless hosts do not advertise window/UI/input/clipboard/display/screenshot/launch/open/reveal/notification
83
+ operations merely because desktop helper binaries happen to be installed.
81
84
 
82
85
  The generated hosted reference covers the current **10 hosted production definitions**, all model-visible
83
86
  and routing to the 83 granular runtime operations described above; the optional widget-enabled surface is 15.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.127",
3
+ "version": "0.2.129",
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",
@@ -534,7 +534,7 @@ export const extendedToolDefinitions = [
534
534
  ),
535
535
  define('list_windows', 'List windows', 'List visible top-level desktop windows with stable-enough identifiers, PID, application name, title, bounds, and the monitor containing the largest part of each window when display geometry is available.', o({ include_monitor:b('Enrich each window with monitor and monitor_index; default true.') }), readOnlyLive, desktopHandlers.list_windows, ['windows']),
536
536
  define('window_action', 'Window action', 'Focus, minimize, maximize, restore, move, resize, or close a top-level desktop window selected by id, pid, application name, or title. Native GNOME Wayland focus uses AT-SPI; compositor window-manager actions use the consent-backed XDG RemoteDesktop input path.', o({ action:e(['focus','minimize','maximize','restore','move','resize','move_resize','close']), ...windowSelector, x:n(), y:n(), width:n(), height:n(), ...inputBackendSelector }, ['action']), mutating, desktopHandlers.window_action, ['windows']),
537
- define('launch_app', 'Launch application', 'Launch a desktop application or executable without invoking a shell. On Windows, executable/path launch falls back to Start Menu/UWP app discovery by friendly name. cwd is confined by the ReMCP filesystem policy and is only accepted when the platform can guarantee the child working directory. Optionally wait until a matching top-level window exists.', o({ app:s('Application name, friendly Start Menu name, or executable path.'), path:s('Alias for app.'), args:{ type:'array', items:{type:'string'} }, cwd:s('Optional permitted working directory for direct executable launch.'), wait_for_window:s('Optional application/title substring that must appear after launch.'), wait_timeout_ms:n('Maximum wait for wait_for_window; default 10000, maximum 120000.') }), openMutatingNonDestructive, desktopHandlers.launch_app),
537
+ define('launch_app', 'Launch application', 'Launch a desktop application or executable without invoking a shell. On Windows, executable/path launch falls back to Start Menu/UWP app discovery by friendly name. cwd is confined by the ReMCP filesystem policy and is only accepted when the platform can guarantee the child working directory. Optionally wait until a matching top-level window exists.', o({ app:s('Application name, friendly Start Menu name, or executable path.'), path:s('Alias for app.'), args:{ type:'array', items:{type:'string'} }, cwd:s('Optional permitted working directory for direct executable launch.'), wait_for_window:s('Optional application/title substring that must appear after launch.'), wait_timeout_ms:n('Maximum wait for wait_for_window; default 10000, maximum 120000.') }), openMutatingNonDestructive, desktopHandlers.launch_app, ['desktop_session']),
538
538
  define('ui_snapshot', 'UI accessibility snapshot', 'Read the active desktop accessibility/UI Automation tree with compact labels, semantic hierarchy, bounds, state and suggested actions. Default scope is the active application/window; scope=desktop requests the full desktop tree. Explicit pid/app/window_title scopes override the active default. browser_dom asks the OS accessibility provider for the web-document subtree when available.', o({ scope:e(['active','desktop'],'Default active. Use desktop only when cross-application discovery is required.'), pid:n('Optional owning process id scope.'), app:s('Optional application/process name substring scope.'), window_title:s('Optional top-level accessibility window/frame title scope.'), browser_dom:b('Prefer the native browser document accessibility subtree instead of browser chrome when supported.'), semantic_tree:b('Include compact semantic hierarchy text; default true.'), max_nodes:n('Maximum nodes; default 500, maximum 5000.'), max_depth:n('Maximum traversal depth; default 8, maximum 32.') }), readOnlyLive, desktopHandlers.ui_snapshot, ['ui']),
539
539
  define('ui_find', 'Find UI elements', 'Find native accessible desktop elements by label/id/name/role/AutomationId. Reuses the latest five-second semantic snapshot for fast repeated targeting; refresh=true forces a new OS accessibility capture.', o({ ...uiSelector, refresh:b('Force a fresh accessibility capture instead of the short-lived snapshot cache.'), limit:n('Maximum matches; default 20.'), max_nodes:n(), max_depth:n() }), readOnlyLive, desktopHandlers.ui_find, ['ui']),
540
540
  define('ui_action', 'Act on UI element', 'Act on a native accessibility/UI Automation element by id, label or semantic selector. Supports invoke/focus/value/toggle/selection, expand-collapse, range values and scroll-into-view before falling back to coordinates.', o({ action:e(['click','invoke','focus','set_value','select','toggle','expand','collapse','scroll_into_view','set_range_value','add_to_selection','remove_from_selection']), ...uiSelector, value:{description:'New text or numeric range value.'} }, ['action']), openMutating, desktopHandlers.ui_action, ['ui']),
@@ -547,8 +547,8 @@ export const extendedToolDefinitions = [
547
547
  define('clipboard', 'Clipboard', 'Read, write, or clear the system text clipboard using native operating-system clipboard facilities.', o({ action:e(['read','write','clear']), text:s('Text to write.') }, ['action']), mutating, desktopHandlers.clipboard, ['clipboard']),
548
548
  define('display_inventory', 'Display inventory', 'List connected displays/monitors and their geometry and primary-display status using native display tools.', o(), readOnlyLive, desktopHandlers.display_inventory, ['displays']),
549
549
  define('screenshot_region', 'Screenshot region', 'Capture a desktop rectangle, a selected top-level window, or a selected monitor and return it as an inline PNG. This avoids full-desktop screenshots when only one UI area matters.', o({ x:n(), y:n(), width:n(), height:n(), window_id:s('Window id returned by list_windows.'), pid:n(), app:s(), title:s(), monitor:s('Monitor name substring, or "primary".'), monitor_index:n('Zero-based monitor index from display_inventory.'), padding:n('Optional pixels added around the resolved rectangle; 0..200.') }), readOnlyLive, desktopHandlers.screenshot_region, ['screen_region']),
550
- define('open_path', 'Open path', 'Open a permitted local file or directory in its system-associated desktop application. Filesystem confinement is checked before opening.', o({ path:s('File or directory to open.') }, ['path']), additive, desktopHandlers.open_path),
551
- define('reveal_path', 'Reveal path', 'Reveal a permitted local file or directory in Finder, Explorer, or the Linux file manager after applying ReMCP filesystem confinement.', o({ path:s('File or directory to reveal.') }, ['path']), additive, desktopHandlers.reveal_path),
550
+ define('open_path', 'Open path', 'Open a permitted local file or directory in its system-associated desktop application. Filesystem confinement is checked before opening.', o({ path:s('File or directory to open.') }, ['path']), additive, desktopHandlers.open_path, ['desktop_open']),
551
+ define('reveal_path', 'Reveal path', 'Reveal a permitted local file or directory in Finder, Explorer, or the Linux file manager after applying ReMCP filesystem confinement.', o({ path:s('File or directory to reveal.') }, ['path']), additive, desktopHandlers.reveal_path, ['desktop_open']),
552
552
  define('notification', 'Desktop notification', 'Show a native desktop notification or user-visible popup on the paired computer.', o({ title:s(), message:s('Notification message.'), timeout_seconds:n() }, ['message']), additive, desktopHandlers.notification, ['notifications']),
553
553
 
554
554
  define('browser_tabs', 'Browser tabs', 'List debuggable Chrome, Edge, or Chromium page targets from a loopback-only Chrome DevTools Protocol endpoint.', o({ ...browserTarget }), readOnlyLive, browserHandlers.browser_tabs, ['browser_cdp']),
@@ -598,6 +598,30 @@ async function linuxHasPyAtSpi() {
598
598
  return pyAtSpiCache.value;
599
599
  }
600
600
 
601
+ export function desktopShellCapabilitySnapshot({
602
+ platform = process.platform,
603
+ env = process.env,
604
+ commandExistsFn = commandExists,
605
+ } = {}) {
606
+ const nativeDesktop = platform === 'win32' || platform === 'darwin';
607
+ const linuxDesktop = platform === 'linux'
608
+ && Boolean(String(env?.DISPLAY || '').trim() || String(env?.WAYLAND_DISPLAY || '').trim());
609
+ const desktopSession = nativeDesktop || linuxDesktop;
610
+ return {
611
+ desktop_session:desktopSession,
612
+ desktop_open:desktopSession && (nativeDesktop || commandExistsFn('xdg-open')),
613
+ };
614
+ }
615
+
616
+ export function powerCapabilitySnapshot({
617
+ platform = process.platform,
618
+ commandExistsFn = commandExists,
619
+ } = {}) {
620
+ if (platform === 'win32' || platform === 'darwin') return true;
621
+ if (platform !== 'linux') return false;
622
+ return commandExistsFn('loginctl') && commandExistsFn('systemctl');
623
+ }
624
+
601
625
  export function documentCapabilitySnapshot({
602
626
  platform = process.platform,
603
627
  commandExistsFn = commandExists,
@@ -617,23 +641,26 @@ export async function capabilitySnapshot() {
617
641
  const win = process.platform === 'win32';
618
642
  const mac = process.platform === 'darwin';
619
643
  const linux = process.platform === 'linux';
620
- const pyAtSpi = linux ? await linuxHasPyAtSpi() : false;
621
- const wayland = linux && isWaylandSession();
644
+ const desktopShellCapabilities = desktopShellCapabilitySnapshot();
645
+ const desktopSession = desktopShellCapabilities.desktop_session;
646
+ const pyAtSpi = linux && desktopSession ? await linuxHasPyAtSpi() : false;
647
+ const wayland = linux && desktopSession && isWaylandSession();
622
648
  const gnomeRecord = wayland ? await gnomeScreencastSupported() : false;
623
649
  const portalInput = wayland && waylandPortalCandidate();
624
- const x11Input = linux && !wayland && (commandExists('wdotool') || commandExists('xdotool'));
650
+ const x11Input = linux && desktopSession && !wayland && (commandExists('wdotool') || commandExists('xdotool'));
625
651
  const browserCdp = browserRemoteEnabled() && await browserControlAvailable(undefined, 500);
626
652
  const documentCapabilities = documentCapabilitySnapshot();
627
653
  return {
628
- windows: win || mac || commandExists('wdotool') || commandExists('wmctrl') || (wayland && pyAtSpi),
629
- ui: win || mac || pyAtSpi,
630
- input: win || mac || portalInput || x11Input || (wayland && commandExists('wtype')),
631
- pointer: win || mac || portalInput || x11Input,
632
- drag: win || mac || portalInput || x11Input,
633
- clipboard: win || mac || commandExists('wl-paste') || commandExists('xclip') || commandExists('xsel'),
634
- displays: win || mac || (wayland && linux) || commandExists('wlr-randr') || commandExists('xrandr'),
635
- screen_region: win || mac || commandExists('grim') || commandExists('import') || (isWaylandSession() && commandExists('ffmpeg')),
636
- notifications: win || mac || commandExists('notify-send'),
654
+ ...desktopShellCapabilities,
655
+ windows: win || mac || (desktopSession && (commandExists('wdotool') || commandExists('wmctrl') || (wayland && pyAtSpi))),
656
+ ui: win || mac || (desktopSession && pyAtSpi),
657
+ input: win || mac || (desktopSession && (portalInput || x11Input || (wayland && commandExists('wtype')))),
658
+ pointer: win || mac || (desktopSession && (portalInput || x11Input)),
659
+ drag: win || mac || (desktopSession && (portalInput || x11Input)),
660
+ clipboard: win || mac || (desktopSession && (commandExists('wl-paste') || commandExists('xclip') || commandExists('xsel'))),
661
+ displays: win || mac || (desktopSession && ((wayland && linux) || commandExists('wlr-randr') || commandExists('xrandr'))),
662
+ screen_region: win || mac || (desktopSession && (commandExists('grim') || commandExists('import') || (isWaylandSession() && commandExists('ffmpeg')))),
663
+ notifications: win || mac || (desktopSession && commandExists('notify-send')),
637
664
  browser_cdp: browserCdp,
638
665
  browser_evaluate: browserCdp && (process.env.NODE_ENV !== 'production' || ['1', 'true', 'yes', 'on'].includes(String(process.env.REMCP_BROWSER_ALLOW_EVALUATE || '').toLowerCase())),
639
666
  services: win || mac || commandExists('systemctl'),
@@ -641,7 +668,7 @@ export async function capabilitySnapshot() {
641
668
  logs: win || mac || commandExists('journalctl'),
642
669
  apps: win || mac || commandExists('dpkg-query') || commandExists('rpm'),
643
670
  audio: win || mac || commandExists('wpctl') || commandExists('pactl') || commandExists('amixer'),
644
- power: win || mac || commandExists('systemctl') || commandExists('loginctl'),
671
+ power: powerCapabilitySnapshot(),
645
672
  record: recordScreenAvailable({ platform:process.platform, wayland, gnomeSupported:gnomeRecord }),
646
673
  ...documentCapabilities,
647
674
  };
@@ -127,6 +127,12 @@ async function connectivityTest(host, port, timeoutMs) {
127
127
  });
128
128
  }
129
129
 
130
+ export function linuxListenerBackend({ commandExistsFn = commandExists } = {}) {
131
+ if (commandExistsFn('ss')) return { command:'ss', args:['-lntup'] };
132
+ if (commandExistsFn('netstat')) return { command:'netstat', args:['-lntu'] };
133
+ return null;
134
+ }
135
+
130
136
  export async function networkTool(args) {
131
137
  const action = requireEnum(args.action || 'summary', 'action', ['summary','interfaces','dns','routes','listeners','test']);
132
138
  if (action === 'interfaces') return jsonResult(os.networkInterfaces());
@@ -154,8 +160,8 @@ export async function networkTool(args) {
154
160
  if (commandExists('route')) return text((await runFile('route', ['-n'], { label:'routes' })).stdout);
155
161
  unavailable('Route inventory', 'ip or route is required');
156
162
  }
157
- if (commandExists('ss')) return text((await runFile('ss', ['-lntup'], { label:'listeners', allowFailure:true })).stdout);
158
- if (commandExists('netstat')) return text((await runFile('netstat', ['-an'], { label:'listeners' })).stdout);
163
+ const listenerBackend = linuxListenerBackend();
164
+ if (listenerBackend) return text((await runFile(listenerBackend.command, listenerBackend.args, { label:'listeners', allowFailure:true })).stdout);
159
165
  unavailable('Listener inventory', 'ss or netstat is required');
160
166
  }
161
167