promethios-bridge 1.3.0 → 1.5.0

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,7 +1,7 @@
1
1
  {
2
2
  "name": "promethios-bridge",
3
- "version": "1.3.0",
4
- "description": "Run Promethios agent frameworks locally on your computer with full file, terminal, and browser access.",
3
+ "version": "1.5.0",
4
+ "description": "Run Promethios agent frameworks locally on your computer with full file, terminal, browser access, and Native Framework Mode (run OpenClaw and other frameworks in their native interface via the bridge).",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "promethios-bridge": "src/cli.js"
@@ -16,7 +16,10 @@
16
16
  "agent",
17
17
  "local",
18
18
  "bridge",
19
- "framework"
19
+ "framework",
20
+ "openclaw",
21
+ "native",
22
+ "clawhub"
20
23
  ],
21
24
  "author": "Promethios <hello@promethios.ai>",
22
25
  "license": "MIT",
@@ -43,7 +46,11 @@
43
46
  "express": "^4.18.2",
44
47
  "open": "^8.4.2",
45
48
  "ora": "^5.4.1",
46
- "node-fetch": "^2.7.0"
49
+ "node-fetch": "^2.7.0",
50
+ "playwright": "^1.42.0"
51
+ },
52
+ "optionalDependencies": {
53
+ "playwright": "^1.42.0"
47
54
  },
48
55
  "engines": {
49
56
  "node": ">=18.0.0"
package/src/bridge.js CHANGED
@@ -75,6 +75,72 @@ async function startBridge({ setupToken, apiBase, port, dev }) {
75
75
  res.json({ capabilities: getSupportedCapabilities() });
76
76
  });
77
77
 
78
+ // ── Native mode: probe a local port ──────────────────────────────────────
79
+ // Called by the Promethios backend's /native/detect route.
80
+ // Returns { detected, running, port, httpOk, serviceInfo }
81
+ app.get('/native/probe', async (req, res) => {
82
+ const probePort = parseInt(req.query.port, 10) || 18789;
83
+ const framework = req.query.framework || 'unknown';
84
+ log('Native probe request for port', probePort, 'framework', framework);
85
+ try {
86
+ const result = await executeLocalTool({
87
+ toolName: 'probe_local_port',
88
+ args: { port: probePort, framework, timeout_ms: 3000 },
89
+ dev,
90
+ });
91
+ res.json(result);
92
+ } catch (err) {
93
+ res.status(500).json({ detected: false, error: err.message });
94
+ }
95
+ });
96
+
97
+ // ── Native mode: proxy HTTP requests to a local port ─────────────────────
98
+ // Called by the Promethios backend's /native/proxy/:port/* route.
99
+ // Forwards any HTTP method to localhost:<port><path> and returns the response.
100
+ app.all('/proxy/:port/*', async (req, res) => {
101
+ const proxyPort = parseInt(req.params.port, 10);
102
+ const ALLOWED_PORTS = [18789, 18790, 7788, 8080, 3000, 3001, 4000, 5000];
103
+ if (!ALLOWED_PORTS.includes(proxyPort)) {
104
+ return res.status(403).json({ error: `Port ${proxyPort} not allowed` });
105
+ }
106
+ // Reconstruct the path including query string
107
+ const proxyPath = '/' + (req.params[0] || '') + (Object.keys(req.query).length ? '?' + new URLSearchParams(req.query).toString() : '');
108
+ log('Proxy request:', req.method, proxyPort, proxyPath);
109
+ try {
110
+ const result = await executeLocalTool({
111
+ toolName: 'proxy_local_request',
112
+ args: {
113
+ port: proxyPort,
114
+ path: proxyPath,
115
+ method: req.method,
116
+ headers: req.headers,
117
+ body: req.body,
118
+ },
119
+ dev,
120
+ });
121
+ // Forward status and body back to caller
122
+ res.status(result.status || 200);
123
+ res.set('Access-Control-Allow-Origin', '*');
124
+ res.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS');
125
+ res.set('Access-Control-Allow-Headers', 'Content-Type,Authorization');
126
+ if (typeof result.data === 'string') {
127
+ res.send(result.data);
128
+ } else {
129
+ res.json(result.data);
130
+ }
131
+ } catch (err) {
132
+ res.status(502).json({ error: err.message });
133
+ }
134
+ });
135
+
136
+ // CORS preflight for proxy
137
+ app.options('/proxy/:port/*', (req, res) => {
138
+ res.set('Access-Control-Allow-Origin', '*');
139
+ res.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS');
140
+ res.set('Access-Control-Allow-Headers', 'Content-Type,Authorization');
141
+ res.sendStatus(204);
142
+ });
143
+
78
144
  await new Promise((resolve, reject) => {
79
145
  const server = app.listen(port, '127.0.0.1', () => resolve(server));
80
146
  server.on('error', reject);
@@ -290,6 +356,8 @@ function getSupportedCapabilities() {
290
356
  'terminal.readonly',
291
357
  'browser.open',
292
358
  'network.http',
359
+ 'native.probe_port',
360
+ 'native.proxy',
293
361
  ];
294
362
  }
295
363
 
package/src/executor.js CHANGED
@@ -36,6 +36,15 @@ async function executeLocalTool({ toolName, args, frameworkId, dev }) {
36
36
  if (toolName === 'local_file_write') {
37
37
  return executeLocalTool({ toolName: 'write_file', args: { path: args.path, content: args.content, encoding: args.encoding }, frameworkId, dev });
38
38
  }
39
+ if (toolName === 'local_file_read_binary') {
40
+ return executeLocalTool({ toolName: 'read_file_binary', args: { path: args.path, maxSizeBytes: args.maxSizeBytes }, frameworkId, dev });
41
+ }
42
+ if (toolName === 'local_file_upload_to_thread') {
43
+ return executeLocalTool({ toolName: 'upload_file_to_thread', args: { path: args.path, displayName: args.displayName }, frameworkId, dev });
44
+ }
45
+ if (toolName === 'local_browser_control') {
46
+ return executeLocalTool({ toolName: 'browser_control', args, frameworkId, dev });
47
+ }
39
48
 
40
49
  // ── local_execute is the built-in tool injected by the backend when the bridge
41
50
  // is connected. It uses an `action` field to dispatch to the right handler.
@@ -71,8 +80,17 @@ async function executeLocalTool({ toolName, args, frameworkId, dev }) {
71
80
  const filePath = resolveSafePath(args.path);
72
81
  log('write_file', filePath);
73
82
  const mode = args.append ? 'a' : 'w';
74
- await fs.writeFile(filePath, args.content || '', { flag: mode, encoding: 'utf8' });
75
- return { success: true, path: filePath, bytesWritten: (args.content || '').length };
83
+ // Detect base64-encoded binary content (e.g. images transferred from phone)
84
+ const content = args.content || '';
85
+ const isBase64 = args.encoding === 'base64' || /^[A-Za-z0-9+/]+=*$/.test(content.replace(/\s/g, '')) && content.length > 100 && !content.includes(' ');
86
+ if (isBase64 && args.encoding === 'base64') {
87
+ const buffer = Buffer.from(content, 'base64');
88
+ await fs.writeFile(filePath, buffer, { flag: mode });
89
+ return { success: true, path: filePath, bytesWritten: buffer.length };
90
+ } else {
91
+ await fs.writeFile(filePath, content, { flag: mode, encoding: 'utf8' });
92
+ return { success: true, path: filePath, bytesWritten: Buffer.byteLength(content, 'utf8') };
93
+ }
76
94
  }
77
95
 
78
96
  case 'list_directory': {
@@ -100,6 +118,193 @@ async function executeLocalTool({ toolName, args, frameworkId, dev }) {
100
118
  };
101
119
  }
102
120
 
121
+ // ── Browser Control (Playwright) ──────────────────────────────────────
122
+ case 'browser_control': {
123
+ // Lazy-load playwright — auto-install if missing so users never need
124
+ // to run terminal commands manually. This runs once on first use.
125
+ let playwright;
126
+ try {
127
+ playwright = require('playwright');
128
+ } catch (e) {
129
+ // Playwright not installed — install it automatically
130
+ const chalk = require('chalk');
131
+ console.log(chalk.yellow('\n Playwright not found — installing automatically (one-time setup, ~2 min)...\n'));
132
+ try {
133
+ execSync('npm install -g playwright', { stdio: 'inherit' });
134
+ execSync('npx playwright install chromium', { stdio: 'inherit' });
135
+ playwright = require('playwright');
136
+ console.log(chalk.green('\n Playwright installed. Browser automation is ready.\n'));
137
+ } catch (installErr) {
138
+ throw new Error(
139
+ 'Auto-install of Playwright failed: ' + installErr.message +
140
+ '\nPlease run manually: npm install -g playwright && npx playwright install chromium'
141
+ );
142
+ }
143
+ }
144
+
145
+ const action = args.action;
146
+ if (!action) throw new Error('action is required for browser_control');
147
+ log('browser_control', action, args.url || args.selector || '');
148
+
149
+ // We maintain a single persistent browser context per process so sessions
150
+ // (cookies / localStorage) survive across multiple tool calls.
151
+ if (!global.__playwrightBrowser) {
152
+ // Try to connect to an existing Chrome instance first (user's real profile)
153
+ // Falls back to a fresh Chromium instance if not available.
154
+ try {
155
+ // Launch Chromium with the user's real Chrome profile directory
156
+ const os = require('os');
157
+ const platform = process.platform;
158
+ let userDataDir;
159
+ if (platform === 'win32') {
160
+ userDataDir = path.join(process.env.LOCALAPPDATA || os.homedir(), 'Google', 'Chrome', 'User Data');
161
+ } else if (platform === 'darwin') {
162
+ userDataDir = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
163
+ } else {
164
+ userDataDir = path.join(os.homedir(), '.config', 'google-chrome');
165
+ }
166
+
167
+ // Use persistent context with real Chrome profile if it exists
168
+ const fsSync = require('fs');
169
+ if (fsSync.existsSync(userDataDir)) {
170
+ global.__playwrightContext = await playwright.chromium.launchPersistentContext(userDataDir, {
171
+ headless: false,
172
+ channel: 'chrome',
173
+ args: ['--no-first-run', '--disable-blink-features=AutomationControlled'],
174
+ });
175
+ } else {
176
+ // Fallback: fresh Chromium (no saved logins)
177
+ global.__playwrightBrowser = await playwright.chromium.launch({ headless: false });
178
+ global.__playwrightContext = await global.__playwrightBrowser.newContext();
179
+ }
180
+ } catch (e) {
181
+ // Final fallback: headless Chromium
182
+ global.__playwrightBrowser = await playwright.chromium.launch({ headless: true });
183
+ global.__playwrightContext = await global.__playwrightBrowser.newContext();
184
+ }
185
+ }
186
+
187
+ const context = global.__playwrightContext;
188
+
189
+ // Get or create a page
190
+ const getPage = async () => {
191
+ const pages = context.pages();
192
+ return pages.length > 0 ? pages[pages.length - 1] : await context.newPage();
193
+ };
194
+
195
+ switch (action) {
196
+ case 'navigate': {
197
+ const page = await getPage();
198
+ await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
199
+ const title = await page.title();
200
+ const url = page.url();
201
+ return { success: true, title, url };
202
+ }
203
+
204
+ case 'click': {
205
+ const page = await getPage();
206
+ if (args.selector) {
207
+ await page.click(args.selector, { timeout: 10000 });
208
+ } else if (args.text) {
209
+ await page.getByText(args.text).first().click({ timeout: 10000 });
210
+ } else {
211
+ throw new Error('click requires selector or text');
212
+ }
213
+ return { success: true };
214
+ }
215
+
216
+ case 'type': {
217
+ const page = await getPage();
218
+ await page.fill(args.selector, args.text || '', { timeout: 10000 });
219
+ return { success: true };
220
+ }
221
+
222
+ case 'press_key': {
223
+ const page = await getPage();
224
+ await page.keyboard.press(args.key || 'Enter');
225
+ return { success: true };
226
+ }
227
+
228
+ case 'read_page': {
229
+ const page = await getPage();
230
+ // Return page text content and current URL
231
+ const textContent = await page.evaluate(() => document.body.innerText);
232
+ const url = page.url();
233
+ const title = await page.title();
234
+ // Truncate to avoid overwhelming the agent
235
+ const maxChars = args.maxChars || 8000;
236
+ return {
237
+ url,
238
+ title,
239
+ text: textContent.slice(0, maxChars),
240
+ truncated: textContent.length > maxChars,
241
+ totalChars: textContent.length,
242
+ };
243
+ }
244
+
245
+ case 'screenshot': {
246
+ const page = await getPage();
247
+ const screenshotBuffer = await page.screenshot({ fullPage: !!args.fullPage });
248
+ const base64 = screenshotBuffer.toString('base64');
249
+ return {
250
+ base64,
251
+ mimeType: 'image/png',
252
+ url: page.url(),
253
+ title: await page.title(),
254
+ };
255
+ }
256
+
257
+ case 'get_html': {
258
+ const page = await getPage();
259
+ const html = await page.content();
260
+ const maxChars = args.maxChars || 20000;
261
+ return {
262
+ html: html.slice(0, maxChars),
263
+ truncated: html.length > maxChars,
264
+ url: page.url(),
265
+ };
266
+ }
267
+
268
+ case 'wait_for': {
269
+ const page = await getPage();
270
+ if (args.selector) {
271
+ await page.waitForSelector(args.selector, { timeout: args.timeout || 15000 });
272
+ } else if (args.text) {
273
+ await page.waitForFunction(
274
+ (t) => document.body.innerText.includes(t),
275
+ args.text,
276
+ { timeout: args.timeout || 15000 }
277
+ );
278
+ } else {
279
+ await page.waitForLoadState('networkidle', { timeout: args.timeout || 15000 });
280
+ }
281
+ return { success: true };
282
+ }
283
+
284
+ case 'new_tab': {
285
+ const page = await context.newPage();
286
+ if (args.url) await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
287
+ return { success: true, url: page.url() };
288
+ }
289
+
290
+ case 'close': {
291
+ // Close the browser context and clean up
292
+ if (global.__playwrightContext) {
293
+ await global.__playwrightContext.close();
294
+ delete global.__playwrightContext;
295
+ }
296
+ if (global.__playwrightBrowser) {
297
+ await global.__playwrightBrowser.close();
298
+ delete global.__playwrightBrowser;
299
+ }
300
+ return { success: true };
301
+ }
302
+
303
+ default:
304
+ throw new Error(`Unknown browser_control action: ${action}. Valid actions: navigate, click, type, press_key, read_page, screenshot, get_html, wait_for, new_tab, close`);
305
+ }
306
+ }
307
+
103
308
  // ── Terminal ──────────────────────────────────────────────────────────
104
309
  case 'run_command': {
105
310
  const cmd = args.command;
@@ -118,7 +323,94 @@ async function executeLocalTool({ toolName, args, frameworkId, dev }) {
118
323
  return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 };
119
324
  }
120
325
 
121
- // ── Browser ───────────────────────────────────────────────────────────
326
+ // ── Binary file read ────────────────────────────────────────────────────────────────────
327
+ case 'read_file_binary': {
328
+ const filePath = resolveSafePath(args.path);
329
+ log('read_file_binary', filePath);
330
+ const maxSize = args.maxSizeBytes || 10 * 1024 * 1024; // 10MB default
331
+ const stat = await fs.stat(filePath);
332
+ if (stat.size > maxSize) {
333
+ throw new Error(`File too large: ${stat.size} bytes exceeds limit of ${maxSize} bytes`);
334
+ }
335
+ const buffer = await fs.readFile(filePath);
336
+ const base64 = buffer.toString('base64');
337
+ // Detect MIME type from extension
338
+ const ext = path.extname(filePath).toLowerCase();
339
+ const mimeTypes = {
340
+ '.pdf': 'application/pdf',
341
+ '.png': 'image/png',
342
+ '.jpg': 'image/jpeg',
343
+ '.jpeg': 'image/jpeg',
344
+ '.gif': 'image/gif',
345
+ '.webp': 'image/webp',
346
+ '.bmp': 'image/bmp',
347
+ '.tiff': 'image/tiff',
348
+ '.tif': 'image/tiff',
349
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
350
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
351
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
352
+ '.doc': 'application/msword',
353
+ '.xls': 'application/vnd.ms-excel',
354
+ '.ppt': 'application/vnd.ms-powerpoint',
355
+ '.zip': 'application/zip',
356
+ '.mp4': 'video/mp4',
357
+ '.mp3': 'audio/mpeg',
358
+ };
359
+ const mimeType = mimeTypes[ext] || 'application/octet-stream';
360
+ return {
361
+ base64,
362
+ mimeType,
363
+ sizeBytes: stat.size,
364
+ fileName: path.basename(filePath),
365
+ path: filePath
366
+ };
367
+ }
368
+
369
+ // ── Upload file to thread (read locally, return base64 for cloud upload) ────────────────────
370
+ case 'upload_file_to_thread': {
371
+ const filePath = resolveSafePath(args.path);
372
+ log('upload_file_to_thread', filePath);
373
+ const maxSize = 50 * 1024 * 1024; // 50MB limit for uploads
374
+ const stat = await fs.stat(filePath);
375
+ if (stat.size > maxSize) {
376
+ throw new Error(`File too large: ${stat.size} bytes exceeds upload limit of 50MB`);
377
+ }
378
+ const buffer = await fs.readFile(filePath);
379
+ const base64 = buffer.toString('base64');
380
+ const ext = path.extname(filePath).toLowerCase();
381
+ const mimeTypes = {
382
+ '.pdf': 'application/pdf',
383
+ '.png': 'image/png',
384
+ '.jpg': 'image/jpeg',
385
+ '.jpeg': 'image/jpeg',
386
+ '.gif': 'image/gif',
387
+ '.webp': 'image/webp',
388
+ '.md': 'text/markdown',
389
+ '.txt': 'text/plain',
390
+ '.csv': 'text/csv',
391
+ '.json': 'application/json',
392
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
393
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
394
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
395
+ '.zip': 'application/zip',
396
+ '.mp4': 'video/mp4',
397
+ '.mp3': 'audio/mpeg',
398
+ };
399
+ const mimeType = mimeTypes[ext] || 'application/octet-stream';
400
+ const fileName = args.displayName || path.basename(filePath);
401
+ // Return the base64 payload — the cloud API will handle the actual upload to Firebase Storage
402
+ // and return the download URL back to the agent
403
+ return {
404
+ __upload_payload: true,
405
+ base64,
406
+ mimeType,
407
+ sizeBytes: stat.size,
408
+ fileName,
409
+ originalPath: filePath
410
+ };
411
+ }
412
+
413
+ // ── Browser ────────────────────────────────────────────────────────────────────
122
414
  case 'open_browser': {
123
415
  const url = args.url;
124
416
  if (!url || !/^https?:\/\//.test(url)) throw new Error('Valid http/https URL required');
@@ -157,6 +449,110 @@ async function executeLocalTool({ toolName, args, frameworkId, dev }) {
157
449
  };
158
450
  }
159
451
 
452
+ // ── Native Framework Detection ──────────────────────────────────────────
453
+ // probe_local_port: check if a framework's gateway is running on a local port.
454
+ // Called by the backend's /native/detect route to power the Native mode button.
455
+ case 'probe_local_port': {
456
+ const probePort = args.port || 18789;
457
+ const probeTimeout = Math.min(args.timeout_ms || 3000, 10000);
458
+ log('probe_local_port', probePort, 'timeout:', probeTimeout);
459
+
460
+ // TCP connect probe — fastest way to check if anything is listening
461
+ const net = require('net');
462
+ const isOpen = await new Promise((resolve) => {
463
+ const socket = new net.Socket();
464
+ let resolved = false;
465
+ const done = (result) => {
466
+ if (!resolved) {
467
+ resolved = true;
468
+ socket.destroy();
469
+ resolve(result);
470
+ }
471
+ };
472
+ socket.setTimeout(probeTimeout);
473
+ socket.on('connect', () => done(true));
474
+ socket.on('timeout', () => done(false));
475
+ socket.on('error', () => done(false));
476
+ socket.connect(probePort, '127.0.0.1');
477
+ });
478
+
479
+ if (!isOpen) {
480
+ return { running: false, detected: false, port: probePort, reason: 'port_closed' };
481
+ }
482
+
483
+ // Port is open — try HTTP health check to confirm it's the right service
484
+ let httpOk = false;
485
+ let serviceInfo = null;
486
+ const probePaths = ['/health', '/api/status', '/'];
487
+ for (const probePath of probePaths) {
488
+ try {
489
+ const controller = new AbortController();
490
+ const timer = setTimeout(() => controller.abort(), 2000);
491
+ const probeRes = await fetch(`http://127.0.0.1:${probePort}${probePath}`, {
492
+ signal: controller.signal,
493
+ }).catch(() => null);
494
+ clearTimeout(timer);
495
+ if (probeRes && probeRes.ok) {
496
+ httpOk = true;
497
+ try {
498
+ const text = await probeRes.text();
499
+ try { serviceInfo = JSON.parse(text); } catch { serviceInfo = { raw: text.slice(0, 200) }; }
500
+ } catch { /* ignore */ }
501
+ break;
502
+ }
503
+ } catch { /* try next path */ }
504
+ }
505
+
506
+ return {
507
+ running: true,
508
+ detected: true,
509
+ port: probePort,
510
+ httpOk,
511
+ serviceInfo,
512
+ framework: args.framework || 'unknown',
513
+ };
514
+ }
515
+
516
+ // ── Proxy request to local port ───────────────────────────────────────────
517
+ // proxy_local_request: forward an HTTP request to a local port and return
518
+ // the response. Used by the backend /native/proxy route to tunnel requests
519
+ // through the bridge to a locally-running framework gateway.
520
+ case 'proxy_local_request': {
521
+ const { port: proxyPort, path: proxyPath = '/', method = 'GET', headers = {}, body: proxyBody } = args;
522
+ const ALLOWED_PORTS = [18789, 18790, 7788, 8080, 3000, 3001, 4000, 5000];
523
+ if (!ALLOWED_PORTS.includes(Number(proxyPort))) {
524
+ throw new Error(`Port ${proxyPort} is not in the allowed list for proxy: ${ALLOWED_PORTS.join(', ')}`);
525
+ }
526
+ log('proxy_local_request', method, proxyPort, proxyPath);
527
+
528
+ const controller = new AbortController();
529
+ const timer = setTimeout(() => controller.abort(), 15000);
530
+ try {
531
+ const fetchOptions = {
532
+ method,
533
+ headers: { 'Content-Type': 'application/json', ...headers },
534
+ signal: controller.signal,
535
+ };
536
+ if (proxyBody && method !== 'GET' && method !== 'HEAD') {
537
+ fetchOptions.body = typeof proxyBody === 'string' ? proxyBody : JSON.stringify(proxyBody);
538
+ }
539
+ const proxyRes = await fetch(`http://127.0.0.1:${proxyPort}${proxyPath}`, fetchOptions);
540
+ clearTimeout(timer);
541
+ const responseText = await proxyRes.text();
542
+ let responseData;
543
+ try { responseData = JSON.parse(responseText); } catch { responseData = responseText; }
544
+ return {
545
+ status: proxyRes.status,
546
+ ok: proxyRes.ok,
547
+ data: responseData,
548
+ headers: Object.fromEntries(proxyRes.headers.entries()),
549
+ };
550
+ } catch (err) {
551
+ clearTimeout(timer);
552
+ throw new Error(`Proxy request to port ${proxyPort} failed: ${err.message}`);
553
+ }
554
+ }
555
+
160
556
  // ── Custom tool (developer-written code from framework definition) ────
161
557
  case 'custom':
162
558
  default: {
@@ -203,9 +599,72 @@ async function executeLocalTool({ toolName, args, frameworkId, dev }) {
203
599
  // Resolve a path safely — expand ~ and normalize
204
600
  // Does NOT restrict to a specific directory (user approved full access)
205
601
  // ─────────────────────────────────────────────────────────────────────────────
602
+
603
+ /**
604
+ * On Windows, the user's Desktop may live under OneDrive sync rather than the
605
+ * local profile directory. This function detects the real Desktop path by
606
+ * querying the Windows Shell folder registry key, falling back to the
607
+ * OneDrive\Desktop path, then the local profile Desktop.
608
+ *
609
+ * On macOS/Linux the standard ~/Desktop is used.
610
+ */
611
+ function resolveDesktopPath() {
612
+ if (process.platform !== 'win32') {
613
+ return path.join(require('os').homedir(), 'Desktop');
614
+ }
615
+ // Try registry first (most reliable — works even with custom Desktop locations)
616
+ try {
617
+ const { execSync } = require('child_process');
618
+ const regOut = execSync(
619
+ 'reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders" /v Desktop',
620
+ { encoding: 'utf8', timeout: 3000 }
621
+ );
622
+ const match = regOut.match(/Desktop\s+REG_(?:SZ|EXPAND_SZ)\s+(.+)/i);
623
+ if (match) {
624
+ // Expand environment variables like %USERPROFILE%
625
+ let desktopPath = match[1].trim();
626
+ desktopPath = desktopPath.replace(/%([^%]+)%/g, (_, varName) => process.env[varName] || `%${varName}%`);
627
+ if (require('fs').existsSync(desktopPath)) return desktopPath;
628
+ }
629
+ } catch { /* registry query failed, fall through */ }
630
+
631
+ // Fallback: check OneDrive Desktop first (most common on Windows 11)
632
+ const userProfile = process.env.USERPROFILE || require('os').homedir();
633
+ const oneDriveDesktop = path.join(userProfile, 'OneDrive', 'Desktop');
634
+ if (require('fs').existsSync(oneDriveDesktop)) return oneDriveDesktop;
635
+
636
+ // Final fallback: local Desktop
637
+ return path.join(userProfile, 'Desktop');
638
+ }
639
+
206
640
  function resolveSafePath(inputPath) {
207
641
  if (!inputPath) throw new Error('Path is required');
208
- const expanded = inputPath.replace(/^~/, process.env.HOME || '/home');
642
+
643
+ // Expand ~ to home directory
644
+ let expanded = inputPath.replace(/^~/, require('os').homedir());
645
+
646
+ // On Windows, expand %DESKTOP% and ~/Desktop shortcuts to the real Desktop path
647
+ // This handles the common case where the agent writes to "C:\Users\user\Desktop"
648
+ // but the actual visible Desktop is under OneDrive.
649
+ if (process.platform === 'win32') {
650
+ // Replace %DESKTOP% placeholder
651
+ expanded = expanded.replace(/%DESKTOP%/gi, resolveDesktopPath());
652
+
653
+ // If path contains \Desktop\ or ends with \Desktop, check if OneDrive Desktop exists
654
+ // and remap the local Desktop path to the OneDrive one.
655
+ const userProfile = process.env.USERPROFILE || require('os').homedir();
656
+ const localDesktop = path.join(userProfile, 'Desktop');
657
+ const realDesktop = resolveDesktopPath();
658
+ if (realDesktop !== localDesktop) {
659
+ // Normalize separators for comparison
660
+ const normalizedExpanded = expanded.replace(/\//g, '\\');
661
+ const normalizedLocal = localDesktop.replace(/\//g, '\\');
662
+ if (normalizedExpanded.toLowerCase().startsWith(normalizedLocal.toLowerCase())) {
663
+ expanded = realDesktop + expanded.slice(localDesktop.length);
664
+ }
665
+ }
666
+ }
667
+
209
668
  return path.resolve(expanded);
210
669
  }
211
670