@remcp/runtime 0.2.231 → 0.2.233
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 +4 -4
- package/package.json +1 -1
- package/src/compact-catalog.mjs +1 -0
- package/src/extended/catalog.mjs +10 -0
- package/src/extended/desktop-linux-displays.mjs +12 -2
- package/src/extended/desktop-linux-session-env.mjs +87 -0
- package/src/extended/desktop-webrtc-bridge.mjs +205 -0
- package/src/tools/files-archive-actions.mjs +9 -9
- package/src/tools/files-archive-safety.mjs +1 -1
- package/src/tools/files-screenshots.mjs +2 -2
- package/src/tools/search.mjs +2 -2
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ node ./src/compact.mjs --print-tools # compact local façade for scanners/local
|
|
|
31
31
|
|
|
32
32
|
## Tools
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
87 tools are implemented in this repository: the original 44 filesystem/search/terminal/process tools
|
|
35
35
|
plus 40 computer-use, browser, diagnostics, and document tools. The release contract contains all 84;
|
|
36
36
|
the live local MCP `tools/list` omits optional platform-specific tools that the current computer cannot
|
|
37
37
|
support and emits `notifications/tools/list_changed` when that capability set changes. The stdio server is dual-era: existing 2025-era clients continue to work unchanged, while clients that negotiate MCP `2026-07-28` use `server/discover`, cache hints, unrestricted structured output validation, and `subscriptions/listen` for list-change delivery.
|
|
@@ -52,10 +52,10 @@ support and emits `notifications/tools/list_changed` when that capability set ch
|
|
|
52
52
|
| Diagnostics | `service`, `event_log`, `network`, `installed_apps`, `environment`, `audio`, `power_action`, `record_screen` — `audio` returns stable structured `volume`, `muted`, `action`, and `backend` fields across supported OS backends |
|
|
53
53
|
| Documents | `read_document`, `edit_spreadsheet`, `edit_document`, `pdf_action` |
|
|
54
54
|
|
|
55
|
-
The hosted ReMCP endpoint does not append those
|
|
55
|
+
The hosted ReMCP endpoint does not append those 87 granular operation names to its discovery list.
|
|
56
56
|
Current production runs with `REMCP_CUSTOM_WIDGETS_ENABLED=false` and exposes **10 hosted tool definitions**,
|
|
57
57
|
all model-visible: 8 device-facing façade tools plus 2 account/fleet tools. Those compact definitions route
|
|
58
|
-
to the same **
|
|
58
|
+
to the same **87 granular runtime operations** after the selected computer's live capabilities are checked.
|
|
59
59
|
If custom widgets are explicitly re-enabled, hosted discovery expands to **15 definitions** by adding
|
|
60
60
|
**5 app-only** UI helpers. Cached legacy clients can still call supported granular aliases without inflating
|
|
61
61
|
discovery for new clients.
|
|
@@ -90,7 +90,7 @@ errors. Linux `display_inventory` follows the real monitor backend chain (GNOME
|
|
|
90
90
|
backend (Wayland grim/GNOME/portal path or X11 ImageMagick), not merely because `ffmpeg` is installed.
|
|
91
91
|
|
|
92
92
|
The generated hosted reference covers the current **10 hosted production definitions**, all model-visible
|
|
93
|
-
and routing to the
|
|
93
|
+
and routing to the 87 granular runtime operations described above; the optional widget-enabled surface is 15.
|
|
94
94
|
The separate compact local stdio entrypoint advertises `read_file` plus up to seven domain tools
|
|
95
95
|
(up to 8 tools total) and keeps those groups capability-aware at runtime with `tools/list_changed`; the paired agent keeps using the capability-aware granular runtime.
|
|
96
96
|
See https://remcp.site/docs#tools and public `docs/TOOLS.md`.
|
package/package.json
CHANGED
package/src/compact-catalog.mjs
CHANGED
|
@@ -15,6 +15,7 @@ export const compactRuntimeGroups = Object.freeze({
|
|
|
15
15
|
'computer_snapshot','list_windows','ui_snapshot','ui_find','wait_for_ui','display_inventory',
|
|
16
16
|
'computer_action','window_action','launch_app','ui_action','type_text','keyboard','pointer','drag_drop','scroll',
|
|
17
17
|
'clipboard','open_path','reveal_path','notification','record_screen',
|
|
18
|
+
'desktop_webrtc_offer','desktop_webrtc_heartbeat','desktop_webrtc_stop',
|
|
18
19
|
]),
|
|
19
20
|
view_image: Object.freeze(['read_image','take_screenshot','screenshot_region']),
|
|
20
21
|
control_browser: Object.freeze([
|
package/src/extended/catalog.mjs
CHANGED
|
@@ -12,8 +12,10 @@ import { waylandPortalCandidate } from '../wayland-remote-desktop.mjs';
|
|
|
12
12
|
import { commandExists, desktopSessionAvailable, runFile } from './common.mjs';
|
|
13
13
|
import { desktopHandlers } from './desktop.mjs';
|
|
14
14
|
import { displayInventoryAvailable, regionScreenshotBackends } from './desktop-linux.mjs';
|
|
15
|
+
import { hydrateLinuxDesktopEnv } from './desktop-linux-session-env.mjs';
|
|
15
16
|
import { diagnosticHandlers, gnomeScreencastSupported, recordScreenAvailable } from './diagnostics.mjs';
|
|
16
17
|
import { documentHandlers } from './documents.mjs';
|
|
18
|
+
import { desktopWebRtcHandlers, webRtcWorkerAvailable } from './desktop-webrtc-bridge.mjs';
|
|
17
19
|
|
|
18
20
|
function define(name, title, description, inputSchema, annotations, handler, requires = []) {
|
|
19
21
|
const enriched = enrichSchemaDescriptions(inputSchema);
|
|
@@ -157,6 +159,12 @@ export const extendedToolDefinitions = [
|
|
|
157
159
|
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']),
|
|
158
160
|
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']),
|
|
159
161
|
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']),
|
|
162
|
+
define('desktop_webrtc_offer', 'WebRTC desktop offer (dev only)', 'Use this only after the device owner explicitly requests a live view-only session: negotiate one OS-consented video stream with the installed local aiortc worker. No embedded HTTP server, clipboard or input.',
|
|
163
|
+
o({session_id:s('Server-issued UUID.'),mode:e(['view'],'One-way view-only media. Input control uses the separate authenticated device RPC route.'),sdp:s('ICE-complete video-only SDP offer.')},['session_id','mode','sdp']),mutatingNonDestructive,desktopWebRtcHandlers.desktop_webrtc_offer,['webrtc_pilot']),
|
|
164
|
+
define('desktop_webrtc_heartbeat', 'WebRTC video heartbeat (dev only)', 'Use this only to extend the existing authorized WebRTC video session; it cannot start capture or grant input.',
|
|
165
|
+
o({session_id:s('Existing session UUID.')},['session_id']),mutatingNonDestructive,desktopWebRtcHandlers.desktop_webrtc_heartbeat,['webrtc_pilot']),
|
|
166
|
+
define('desktop_webrtc_stop', 'WebRTC video stop (dev only)', 'Use this when the user or host must end the matching WebRTC video session and release its peer, GStreamer stream and OS ScreenCast grant.',
|
|
167
|
+
o({session_id:s('Existing session UUID.')},['session_id']),mutatingNonDestructive,desktopWebRtcHandlers.desktop_webrtc_stop,['webrtc_pilot']),
|
|
160
168
|
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']),
|
|
161
169
|
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']),
|
|
162
170
|
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']),
|
|
@@ -250,6 +258,7 @@ export async function capabilitySnapshot() {
|
|
|
250
258
|
const win = process.platform === 'win32';
|
|
251
259
|
const mac = process.platform === 'darwin';
|
|
252
260
|
const linux = process.platform === 'linux';
|
|
261
|
+
if(linux)await hydrateLinuxDesktopEnv();
|
|
253
262
|
const desktopShellCapabilities = desktopShellCapabilitySnapshot();
|
|
254
263
|
const desktopSession = desktopShellCapabilities.desktop_session;
|
|
255
264
|
const pyAtSpi = linux && desktopSession ? await linuxHasPyAtSpi() : false;
|
|
@@ -263,6 +272,7 @@ export async function capabilitySnapshot() {
|
|
|
263
272
|
const documentCapabilities = documentCapabilitySnapshot();
|
|
264
273
|
return {
|
|
265
274
|
...desktopShellCapabilities,
|
|
275
|
+
webrtc_pilot:linux && desktopSession && webRtcWorkerAvailable(),
|
|
266
276
|
windows: win || mac || (desktopSession && (commandExists('wdotool') || commandExists('wmctrl') || (wayland && pyAtSpi))),
|
|
267
277
|
ui: win || mac || (desktopSession && pyAtSpi),
|
|
268
278
|
input: win || mac || (desktopSession && (portalInput || x11Input || (wayland && commandExists('wtype')))),
|
|
@@ -29,8 +29,8 @@ export function unwrapDbus(value) {
|
|
|
29
29
|
return value;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export async function mutterDisplayInventory() {
|
|
33
|
-
if (!isWaylandSession()) return null;
|
|
32
|
+
export async function mutterDisplayInventory({ allowUnknownSession = false } = {}) {
|
|
33
|
+
if (!allowUnknownSession && !isWaylandSession()) return null;
|
|
34
34
|
let dbus;
|
|
35
35
|
try { dbus = await import('@jellybrick/dbus-next'); } catch { return null; }
|
|
36
36
|
const bus = dbus.sessionBus();
|
|
@@ -233,6 +233,16 @@ export async function resolveLinuxDisplayInventory({
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// A systemd user agent can start before GNOME exports DISPLAY and
|
|
237
|
+
// XDG_SESSION_TYPE to its service. The authenticated user bus still
|
|
238
|
+
// exposes Mutter's real geometry; do not guess coordinates or use XTEST.
|
|
239
|
+
if (!wayland && !env.DISPLAY && !env.WAYLAND_DISPLAY
|
|
240
|
+
&& !env.XDG_SESSION_TYPE
|
|
241
|
+
&& env.DBUS_SESSION_BUS_ADDRESS && env.XDG_RUNTIME_DIR) {
|
|
242
|
+
const mutter = await mutterProbe({ allowUnknownSession:true }).catch(() => null);
|
|
243
|
+
if (Array.isArray(mutter) && mutter.length) return mutter;
|
|
244
|
+
}
|
|
245
|
+
|
|
236
246
|
if (commandExistsFn('xrandr')) {
|
|
237
247
|
const result = await runFileFn('xrandr', ['--query'], { label:'display inventory', allowFailure:true, timeout:2500, maxBuffer:4*1024*1024 });
|
|
238
248
|
if (result.code === 0) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A ReMCP user service can start before GNOME imports its graphical
|
|
2
|
+
// session into systemd --user. Restore only verified current-user Wayland
|
|
3
|
+
// environment keys; do not read arbitrary files or import secrets.
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
6
|
+
import { posix } from 'node:path';
|
|
7
|
+
import { runFile } from './common.mjs';
|
|
8
|
+
|
|
9
|
+
const SESSION_KEYS=new Set([
|
|
10
|
+
'DISPLAY','WAYLAND_DISPLAY','XDG_SESSION_TYPE','XDG_CURRENT_DESKTOP',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function sessionKeys(text) {
|
|
14
|
+
const result=Object.create(null);
|
|
15
|
+
for(const line of String(text || '').split(/\r?\n/)){
|
|
16
|
+
const idx=line.indexOf('=');
|
|
17
|
+
if(idx<1)continue;
|
|
18
|
+
const key=line.slice(0,idx);
|
|
19
|
+
if(SESSION_KEYS.has(key))result[key]=line.slice(idx+1);
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function hydrateLinuxDesktopEnv({
|
|
25
|
+
platform=process.platform,
|
|
26
|
+
uid=typeof process.getuid==='function'?process.getuid():null,
|
|
27
|
+
env=process.env,
|
|
28
|
+
runFileFn=runFile,
|
|
29
|
+
lstatFn=lstat,
|
|
30
|
+
realpathFn=realpath,
|
|
31
|
+
}={}){
|
|
32
|
+
if(platform!=='linux'||env.DISPLAY||env.WAYLAND_DISPLAY||
|
|
33
|
+
(env.XDG_SESSION_TYPE && env.XDG_SESSION_TYPE!=='wayland')||
|
|
34
|
+
!env.DBUS_SESSION_BUS_ADDRESS||!env.XDG_RUNTIME_DIR||
|
|
35
|
+
!Number.isSafeInteger(uid)||!posix.isAbsolute(env.XDG_RUNTIME_DIR)){
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
// Snapshot the authenticated session context before any awaited operation.
|
|
39
|
+
const runtimeDir=env.XDG_RUNTIME_DIR;
|
|
40
|
+
const busAddress=env.DBUS_SESSION_BUS_ADDRESS;
|
|
41
|
+
const sessionType=env.XDG_SESSION_TYPE;
|
|
42
|
+
let result;
|
|
43
|
+
try{
|
|
44
|
+
result=await runFileFn('systemctl',['--user','show-environment'],{
|
|
45
|
+
label:'user graphical session environment',allowFailure:true,
|
|
46
|
+
timeout:1500,maxBuffer:64*1024,
|
|
47
|
+
});
|
|
48
|
+
}catch{return false;}
|
|
49
|
+
if(result?.code!==0)return false;
|
|
50
|
+
const values=sessionKeys(result.stdout);
|
|
51
|
+
const socketName=values.WAYLAND_DISPLAY;
|
|
52
|
+
if(values.XDG_SESSION_TYPE!=='wayland'||
|
|
53
|
+
typeof socketName!=='string'||socketName.length<1||
|
|
54
|
+
socketName.length>64||socketName.includes('..')||
|
|
55
|
+
!/^[A-Za-z0-9_.-]+$/.test(socketName)){
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
try{
|
|
59
|
+
// A trailing slash makes lstat(path) follow a symlink on Linux. Resolve
|
|
60
|
+
// the *whole* path first to reject aliases via the final component or
|
|
61
|
+
// any parent directory; never import a display from an ambiguous root.
|
|
62
|
+
if(await realpathFn(runtimeDir)!==posix.resolve(runtimeDir))return false;
|
|
63
|
+
// The XDG runtime directory itself must be private (mode 0700),
|
|
64
|
+
// user-owned and not a symlink, in addition to the socket's owner.
|
|
65
|
+
const dir=await lstatFn(runtimeDir);
|
|
66
|
+
if(!dir.isDirectory()||dir.uid!==uid||(dir.mode&0o777)!==0o700)
|
|
67
|
+
return false;
|
|
68
|
+
const socket=await lstatFn(posix.join(runtimeDir,socketName));
|
|
69
|
+
if(!socket.isSocket()||socket.uid!==uid)return false;
|
|
70
|
+
}catch{return false;}
|
|
71
|
+
// A graphical login or session switch may complete during systemctl/lstat.
|
|
72
|
+
// Never overwrite its newer environment with an earlier snapshot.
|
|
73
|
+
if(env.DISPLAY||env.WAYLAND_DISPLAY||env.XDG_SESSION_TYPE!==sessionType||
|
|
74
|
+
env.XDG_RUNTIME_DIR!==runtimeDir||env.DBUS_SESSION_BUS_ADDRESS!==busAddress)
|
|
75
|
+
return false;
|
|
76
|
+
env.XDG_SESSION_TYPE='wayland';
|
|
77
|
+
env.WAYLAND_DISPLAY=socketName;
|
|
78
|
+
if(/^:[0-9]{1,4}(?:\.[0-9]{1,2})?$/.test(values.DISPLAY||'')){
|
|
79
|
+
env.DISPLAY=values.DISPLAY;
|
|
80
|
+
}
|
|
81
|
+
const desktop=values.XDG_CURRENT_DESKTOP;
|
|
82
|
+
if(typeof desktop==='string'&&desktop.length<=96&&
|
|
83
|
+
/^[A-Za-z0-9:_-]+$/.test(desktop)){
|
|
84
|
+
env.XDG_CURRENT_DESKTOP=desktop;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Explicitly opt-in Linux proof: ReMCP device RPC -> isolated aiortc worker
|
|
2
|
+
// via stdio, never a public/native HTTP listener. No OS permission bypass.
|
|
3
|
+
// Not an installed production runtime feature.
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
import {existsSync,lstatSync} from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import {jsonResult} from './common.mjs';
|
|
9
|
+
|
|
10
|
+
const UUID=/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
11
|
+
const MAX_SDP=60_000, MAX_LINE=70_000;
|
|
12
|
+
const MAX_IDLE_MS=45_000, MAX_AGE_MS=600_000;
|
|
13
|
+
const SOURCE=new Set(['portal','synthetic','gst-test']);
|
|
14
|
+
|
|
15
|
+
function trustedLocalFile(filename){
|
|
16
|
+
if(!filename||!path.isAbsolute(filename)||!existsSync(filename))return false;
|
|
17
|
+
try{
|
|
18
|
+
const info=lstatSync(filename);
|
|
19
|
+
return info.isFile()&&!info.isSymbolicLink()&&(info.mode&0o022)===0&&
|
|
20
|
+
(typeof process.getuid!=='function'||info.uid===process.getuid()||info.uid===0);
|
|
21
|
+
}catch{return false;}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function webRtcWorkerAvailable(env=process.env,platform=process.platform){
|
|
25
|
+
if(platform!=='linux'||env.REMCP_WEBRTC_RUNTIME_PILOT!=='1')return false;
|
|
26
|
+
if(!trustedLocalFile(env.REMCP_WEBRTC_WORKER_SCRIPT)||
|
|
27
|
+
!path.isAbsolute(env.REMCP_WEBRTC_PYTHON||''))return false;
|
|
28
|
+
const source=env.REMCP_WEBRTC_DEV_SOURCE||'portal';
|
|
29
|
+
if(!SOURCE.has(source))return false;
|
|
30
|
+
if(source!=='portal'&&env.REMCP_WEBRTC_ALLOW_TEST_SOURCE!=='1')return false;
|
|
31
|
+
return existsSync(env.REMCP_WEBRTC_PYTHON);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createDesktopWebRtcBridge({
|
|
35
|
+
env=process.env,platform=process.platform,spawnChild=spawn,
|
|
36
|
+
now=Date.now,setIntervalFn=setInterval,clearIntervalFn=clearInterval,
|
|
37
|
+
}={}){
|
|
38
|
+
let child=null, current=null, active=false, started=0, seen=0;
|
|
39
|
+
let stdout='', sequence=0, clock=null;
|
|
40
|
+
const waits=new Map();
|
|
41
|
+
|
|
42
|
+
function rejectWaits(){
|
|
43
|
+
for(const pending of waits.values()){
|
|
44
|
+
clearTimeout(pending.timer);
|
|
45
|
+
pending.reject(new Error('WebRTC worker disconnected'));
|
|
46
|
+
}
|
|
47
|
+
waits.clear();
|
|
48
|
+
}
|
|
49
|
+
function shutdown(){
|
|
50
|
+
if(clock){clearIntervalFn(clock);clock=null;}
|
|
51
|
+
const target=child;
|
|
52
|
+
child=null;current=null;active=false;stdout='';
|
|
53
|
+
rejectWaits();
|
|
54
|
+
if(target&&!target.killed){
|
|
55
|
+
try{target.stdin.end();target.kill('SIGTERM');}catch{}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function receive(chunk){
|
|
59
|
+
stdout+=chunk.toString('utf8');
|
|
60
|
+
if(Buffer.byteLength(stdout)>MAX_LINE*2)return shutdown();
|
|
61
|
+
for(let index; (index=stdout.indexOf('\n'))!==-1;){
|
|
62
|
+
const line=stdout.slice(0,index);stdout=stdout.slice(index+1);
|
|
63
|
+
if(Buffer.byteLength(line)>MAX_LINE)return shutdown();
|
|
64
|
+
let message;
|
|
65
|
+
try{message=JSON.parse(line);}catch{return shutdown();}
|
|
66
|
+
const entry=waits.get(message?.requestId);
|
|
67
|
+
if(!entry)continue;
|
|
68
|
+
waits.delete(message.requestId);
|
|
69
|
+
clearTimeout(entry.timer);
|
|
70
|
+
if(message.ok===true)entry.resolve(message);
|
|
71
|
+
else {
|
|
72
|
+
const code=['PermissionError','ValueError','WorkerFailed','Cancelled']
|
|
73
|
+
.includes(message.error)?message.error:'InvalidResponse';
|
|
74
|
+
entry.reject(new Error('WebRTC worker refused command: '+code));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function expired(){
|
|
79
|
+
return current!==null&&(now()-seen>=MAX_IDLE_MS||now()-started>=MAX_AGE_MS);
|
|
80
|
+
}
|
|
81
|
+
function send(op,sid,args={},timeout=5000){
|
|
82
|
+
if(!child||child.killed||!child.stdin.writable)
|
|
83
|
+
return Promise.reject(new Error('WebRTC worker unavailable'));
|
|
84
|
+
const requestId=++sequence;
|
|
85
|
+
const message=JSON.stringify({requestId,op,session_id:sid,...args})+'\n';
|
|
86
|
+
if(Buffer.byteLength(message)>MAX_LINE)
|
|
87
|
+
return Promise.reject(new Error('WebRTC request too large'));
|
|
88
|
+
return new Promise((resolve,reject)=>{
|
|
89
|
+
const timer=setTimeout(()=>{
|
|
90
|
+
waits.delete(requestId);
|
|
91
|
+
reject(new Error('WebRTC worker request timed out'));
|
|
92
|
+
},timeout);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
waits.set(requestId,{timer,resolve,reject});
|
|
95
|
+
try{
|
|
96
|
+
child.stdin.write(message,error=>{
|
|
97
|
+
if(!error)return;
|
|
98
|
+
if(!waits.delete(requestId))return;
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
reject(new Error('WebRTC worker pipe write failed'));
|
|
101
|
+
});
|
|
102
|
+
}catch{
|
|
103
|
+
waits.delete(requestId);
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
reject(new Error('WebRTC worker pipe write failed'));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function start(){
|
|
110
|
+
if(!webRtcWorkerAvailable(env,platform))
|
|
111
|
+
throw new Error('WebRTC capture is not installed or not enabled');
|
|
112
|
+
const source=env.REMCP_WEBRTC_DEV_SOURCE||'portal';
|
|
113
|
+
// Do not leak paired-device OAuth/agent credentials into an optional
|
|
114
|
+
// native media subprocess. Only desktop-session and Python/GStreamer
|
|
115
|
+
// environment required for OS ScreenCast may cross this boundary.
|
|
116
|
+
const allowed=[
|
|
117
|
+
'HOME','PATH','LANG','LC_ALL','LC_CTYPE','TZ',
|
|
118
|
+
'XDG_RUNTIME_DIR','XDG_CURRENT_DESKTOP','XDG_SESSION_TYPE',
|
|
119
|
+
'XDG_DATA_DIRS','DBUS_SESSION_BUS_ADDRESS','WAYLAND_DISPLAY',
|
|
120
|
+
'DISPLAY','GDK_BACKEND','GI_TYPELIB_PATH','PYTHONPATH',
|
|
121
|
+
'GST_PLUGIN_PATH','GST_PLUGIN_SYSTEM_PATH_1_0',
|
|
122
|
+
'REMCP_WEBRTC_USE_PORTAL_GRANT','REMCP_WEBRTC_ALLOW_TEST_SOURCE',
|
|
123
|
+
];
|
|
124
|
+
const workerEnv=Object.fromEntries(allowed.filter(key=>typeof env[key]==='string')
|
|
125
|
+
.map(key=>[key,env[key]]));
|
|
126
|
+
// aiortc may live in a Python venv while Ubuntu supplies GObject
|
|
127
|
+
// introspection only as a distro package. Extend the worker's import
|
|
128
|
+
// path, not the agent/system environment; never install with sudo.
|
|
129
|
+
if(source==='portal'&&platform==='linux'&&existsSync('/usr/lib/python3/dist-packages')){
|
|
130
|
+
const systemPath='/usr/lib/python3/dist-packages';
|
|
131
|
+
const existing=String(workerEnv.PYTHONPATH||'').split(path.delimiter).filter(Boolean);
|
|
132
|
+
if(!existing.includes(systemPath))existing.push(systemPath);
|
|
133
|
+
workerEnv.PYTHONPATH=existing.join(path.delimiter);
|
|
134
|
+
}
|
|
135
|
+
const worker=spawnChild(env.REMCP_WEBRTC_PYTHON,
|
|
136
|
+
[env.REMCP_WEBRTC_WORKER_SCRIPT,'--source',source],{
|
|
137
|
+
stdio:['pipe','pipe','ignore'],shell:false,windowsHide:true,
|
|
138
|
+
env:workerEnv,
|
|
139
|
+
});
|
|
140
|
+
child=worker;stdout='';
|
|
141
|
+
worker.stdout.on('data',chunk=>{if(child===worker)receive(chunk);});
|
|
142
|
+
worker.once('error',()=>{if(child===worker)shutdown();});
|
|
143
|
+
worker.once('exit',()=>{if(child===worker)shutdown();});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function offer({session_id:sid,mode,sdp},extra={}){
|
|
147
|
+
if(!UUID.test(String(sid))||mode!=='view'||typeof sdp!=='string'||
|
|
148
|
+
Buffer.byteLength(sdp)>MAX_SDP||sdp.length<100)
|
|
149
|
+
throw new Error('Invalid view-only WebRTC offer');
|
|
150
|
+
if(current!==null||child)throw new Error('Another WebRTC session is active');
|
|
151
|
+
current=sid;started=seen=now();active=false;
|
|
152
|
+
let cancelled=false;
|
|
153
|
+
const cancel=()=>{cancelled=true;shutdown();};
|
|
154
|
+
extra.signal?.addEventListener('abort',cancel,{once:true});
|
|
155
|
+
try{
|
|
156
|
+
start();
|
|
157
|
+
clock=setIntervalFn(()=>{if(expired())shutdown();},1000);
|
|
158
|
+
clock.unref?.();
|
|
159
|
+
const answer=await send('offer',sid,{sdp},19_000);
|
|
160
|
+
if(cancelled||!child||current!==sid||answer.sessionId!==sid||
|
|
161
|
+
answer.type!=='answer'||typeof answer.sdp!=='string'||
|
|
162
|
+
Buffer.byteLength(answer.sdp)>MAX_SDP||expired())
|
|
163
|
+
throw new Error('Invalid WebRTC worker answer');
|
|
164
|
+
active=true;seen=now();
|
|
165
|
+
return jsonResult({sessionId:sid,type:'answer',sdp:answer.sdp});
|
|
166
|
+
}catch(error){
|
|
167
|
+
shutdown();
|
|
168
|
+
throw error;
|
|
169
|
+
}finally{
|
|
170
|
+
extra.signal?.removeEventListener('abort',cancel);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function heartbeat({session_id:sid}){
|
|
174
|
+
if(!UUID.test(String(sid))||sid!==current||!active||expired()){
|
|
175
|
+
if(expired())shutdown();
|
|
176
|
+
throw new Error('WebRTC viewer session is not active');
|
|
177
|
+
}
|
|
178
|
+
try{
|
|
179
|
+
const reply=await send('heartbeat',sid,{},4000);
|
|
180
|
+
if(reply.sessionId!==sid||!child||expired())
|
|
181
|
+
throw new Error('Invalid WebRTC lease acknowledgement');
|
|
182
|
+
seen=now();
|
|
183
|
+
return jsonResult({ok:true,sessionId:sid});
|
|
184
|
+
}catch(error){shutdown();throw error;}
|
|
185
|
+
}
|
|
186
|
+
async function stop({session_id:sid}){
|
|
187
|
+
if(!UUID.test(String(sid))||sid!==current||!child)
|
|
188
|
+
throw new Error('WebRTC viewer session not found');
|
|
189
|
+
try{
|
|
190
|
+
const reply=await send('stop',sid,{},4000);
|
|
191
|
+
if(reply.sessionId!==sid)throw new Error('Invalid WebRTC stop acknowledgement');
|
|
192
|
+
return jsonResult({ok:true,sessionId:sid});
|
|
193
|
+
}finally{shutdown();}
|
|
194
|
+
}
|
|
195
|
+
return {offer,heartbeat,stop,shutdown,
|
|
196
|
+
get activeSession(){return current;},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const bridge=createDesktopWebRtcBridge();
|
|
201
|
+
export const desktopWebRtcHandlers={
|
|
202
|
+
desktop_webrtc_offer:(args,extra)=>bridge.offer(args,extra),
|
|
203
|
+
desktop_webrtc_heartbeat:args=>bridge.heartbeat(args),
|
|
204
|
+
desktop_webrtc_stop:args=>bridge.stop(args),
|
|
205
|
+
};
|
|
@@ -60,17 +60,17 @@ export async function createArchiveTool(args, extra = {}) {
|
|
|
60
60
|
if (format === 'zip') {
|
|
61
61
|
if (process.platform === 'win32') {
|
|
62
62
|
if (!tools.tar) fail('Windows archive backend is unavailable: tar.exe is required');
|
|
63
|
-
const result = spawnSync(tools.tar, ['-a', '-c', '-f', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
63
|
+
const result = spawnSync(tools.tar, ['-a', '-c', '-f', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024, windowsHide:true });
|
|
64
64
|
if (result.status !== 0) fail(`tar.exe ZIP creation failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
65
65
|
} else {
|
|
66
66
|
if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
|
|
67
|
-
const result = spawnSync(tools.zip, ['-r', '-q', '-y', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
67
|
+
const result = spawnSync(tools.zip, ['-r', '-q', '-y', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024, windowsHide:true });
|
|
68
68
|
if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
69
69
|
}
|
|
70
70
|
} else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
|
|
71
71
|
if (!tools.tar) fail('tar is not installed on this device');
|
|
72
72
|
const flags = format === 'tar' ? '-cf' : '-czf';
|
|
73
|
-
const result = spawnSync(tools.tar, [flags, staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
73
|
+
const result = spawnSync(tools.tar, [flags, staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024, windowsHide:true });
|
|
74
74
|
if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
75
75
|
} else {
|
|
76
76
|
fail('format must be tar, tar.gz, or zip');
|
|
@@ -124,34 +124,34 @@ export async function extractArchiveTool(args) {
|
|
|
124
124
|
if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
|
|
125
125
|
if (format === 'zip' && process.platform !== 'win32') {
|
|
126
126
|
if (!tools.unzip) fail('unzip is not installed on this device');
|
|
127
|
-
const listing = spawnSync(tools.unzip, ['-Z', '-1', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
127
|
+
const listing = spawnSync(tools.unzip, ['-Z', '-1', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024, windowsHide:true });
|
|
128
128
|
if (listing.error || listing.status !== 0) fail(`Could not inspect ZIP archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
|
|
129
129
|
const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
|
|
130
130
|
if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('ZIP archive contains an unsafe path or too many entries');
|
|
131
|
-
const details = spawnSync(tools.unzip, ['-Z', '-v', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
131
|
+
const details = spawnSync(tools.unzip, ['-Z', '-v', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024, windowsHide:true });
|
|
132
132
|
if (details.error || details.status !== 0) fail(`Could not inspect ZIP sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
|
|
133
133
|
const detailText = String(details.stdout || '');
|
|
134
134
|
if (/symbolic link|Unix file attributes \([^)]*\b12\d{4}/i.test(detailText)) fail('ZIP archive contains a symbolic link');
|
|
135
135
|
const expandedBytes = archiveExpandedSize(detailText.split(/\r?\n/), /uncompressed size:\s*(\d+)\s*bytes/i, 'ZIP archive', names.length);
|
|
136
136
|
assertArchiveExpandedSize(expandedBytes, 'ZIP archive');
|
|
137
|
-
const result = spawnSync(tools.unzip, ['-o', '-q', archiveSnapshot, '-d', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
137
|
+
const result = spawnSync(tools.unzip, ['-o', '-q', archiveSnapshot, '-d', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024, windowsHide:true });
|
|
138
138
|
if (result.status !== 0) fail(`unzip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
139
139
|
} else {
|
|
140
140
|
if (!tools.tar) fail(process.platform === 'win32' ? 'Windows archive backend is unavailable: tar.exe is required' : 'tar is not installed on this device');
|
|
141
141
|
const listFlags = format === 'tar.gz' ? '-tzf' : format === 'tar.bz2' ? '-tjf' : format === 'tar.xz' ? '-tJf' : '-tf';
|
|
142
|
-
const listing = spawnSync(tools.tar, [listFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
142
|
+
const listing = spawnSync(tools.tar, [listFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024, windowsHide:true });
|
|
143
143
|
if (listing.error || listing.status !== 0) fail(`Could not inspect archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
|
|
144
144
|
const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
|
|
145
145
|
if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('Archive contains an unsafe path or too many entries');
|
|
146
146
|
const verboseFlags = format === 'tar.gz' ? '-tvzf' : format === 'tar.bz2' ? '-tvjf' : format === 'tar.xz' ? '-tvJf' : '-tvf';
|
|
147
|
-
const details = spawnSync(tools.tar, [verboseFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
147
|
+
const details = spawnSync(tools.tar, [verboseFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024, windowsHide:true });
|
|
148
148
|
if (details.error || details.status !== 0) fail(`Could not inspect archive sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
|
|
149
149
|
const detailLines = String(details.stdout || '').split(/\r?\n/);
|
|
150
150
|
if (detailLines.some(line => /^[bclph]/.test(line))) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
151
151
|
const expandedBytes = archiveTarExpandedSize(detailLines, 'archive', names.length);
|
|
152
152
|
assertArchiveExpandedSize(expandedBytes, 'archive');
|
|
153
153
|
const flags = format === 'tar.gz' ? '-xzf' : format === 'tar.bz2' ? '-xjf' : format === 'tar.xz' ? '-xJf' : '-xf';
|
|
154
|
-
const result = spawnSync(tools.tar, [flags, archiveSnapshot, '-C', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
154
|
+
const result = spawnSync(tools.tar, [flags, archiveSnapshot, '-C', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024, windowsHide:true });
|
|
155
155
|
if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
156
156
|
}
|
|
157
157
|
await assertExtractedTree(staging);
|
|
@@ -368,7 +368,7 @@ export async function moveArchiveFile(source, destination) {
|
|
|
368
368
|
}
|
|
369
369
|
|
|
370
370
|
export function archiveCommandProbe(name, versionArgs = ['--version']) {
|
|
371
|
-
const result = spawnSync(name, versionArgs, { encoding: 'utf8' });
|
|
371
|
+
const result = spawnSync(name, versionArgs, { encoding: 'utf8', windowsHide:true });
|
|
372
372
|
return !result.error && result.status === 0 ? name : null;
|
|
373
373
|
}
|
|
374
374
|
|
|
@@ -135,8 +135,8 @@ export async function takeScreenshotTool(args) {
|
|
|
135
135
|
}
|
|
136
136
|
if (!await pathExists(file)) {
|
|
137
137
|
for (const candidate of SCREENSHOT_COMMANDS) {
|
|
138
|
-
if (spawnSync('which', [candidate.command], { encoding: 'utf8' }).status !== 0) continue;
|
|
139
|
-
const result = spawnSync(candidate.command, candidate.args(file), { encoding: 'utf8', timeout: 30000 });
|
|
138
|
+
if (spawnSync('which', [candidate.command], { encoding: 'utf8', windowsHide:true }).status !== 0) continue;
|
|
139
|
+
const result = spawnSync(candidate.command, candidate.args(file), { encoding: 'utf8', timeout: 30000, windowsHide:true });
|
|
140
140
|
if (result.status === 0 && await pathExists(file)) break;
|
|
141
141
|
attempts.push(`${candidate.command}: ${(result.stderr || '').trim() || ('exit ' + result.status)}`);
|
|
142
142
|
}
|
package/src/tools/search.mjs
CHANGED
|
@@ -27,7 +27,7 @@ function ripgrep() {
|
|
|
27
27
|
if (process.env.REMCP_RUNTIME_FORCE_FALLBACK === '1') return null;
|
|
28
28
|
if (ripgrepPath !== undefined) return ripgrepPath;
|
|
29
29
|
try {
|
|
30
|
-
const result = spawnSync('rg', ['--version'], { encoding: 'utf8' });
|
|
30
|
+
const result = spawnSync('rg', ['--version'], { encoding: 'utf8', windowsHide:true });
|
|
31
31
|
ripgrepPath = !result.error && result.status === 0 ? 'rg' : null;
|
|
32
32
|
} catch { ripgrepPath = null; }
|
|
33
33
|
return ripgrepPath;
|
|
@@ -200,7 +200,7 @@ function runRipgrep(session, { target, pattern, searchType, filePattern, ignoreC
|
|
|
200
200
|
if (contextLines) args.push('-C', String(contextLines));
|
|
201
201
|
args.push('--', pattern, searchTarget);
|
|
202
202
|
}
|
|
203
|
-
const child = spawn(ripgrep(), args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
203
|
+
const child = spawn(ripgrep(), args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide:true });
|
|
204
204
|
session.cancel = () => child.kill('SIGTERM');
|
|
205
205
|
let buffer = '';
|
|
206
206
|
let stderr = '';
|