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 +11 -4
- package/src/bridge.js +68 -0
- package/src/executor.js +463 -4
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promethios-bridge",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Run Promethios agent frameworks locally on your computer with full file, terminal, and
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
|