@agentprojectcontext/apx 1.10.4 → 1.11.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.
@@ -0,0 +1,424 @@
1
+ // daemon/tools/browser.js
2
+ // Puppeteer-backed browser automation tools for APX.
3
+ //
4
+ // Logic adapted from the puppeteer-server MCP server
5
+ // (github.com/tecnomanu/puppeteer-server) — ensureBrowser with security args,
6
+ // docker/npx detection, in-page console capture during evaluate, screenshot
7
+ // with selector + size limits, deep-merge of launch options.
8
+ //
9
+ // Puppeteer is loaded lazily — the headless Chromium is only spawned when a
10
+ // browser_* tool is actually called. HTTP-only fetching lives in fetch.js
11
+ // (no Chromium needed).
12
+ //
13
+ // Endpoints (mounted at /tools/browser by api.js):
14
+ // POST /navigate { url, launch_options?, allow_dangerous? }
15
+ // POST /screenshot { selector?, full_page?, width?, height?, encoded? }
16
+ // POST /click { selector }
17
+ // POST /type { selector, text, clear? }
18
+ // POST /select { selector, value }
19
+ // POST /hover { selector }
20
+ // POST /evaluate { code }
21
+ // POST /get_text { selector? }
22
+ // POST /get_content { selector? } // raw innerHTML
23
+ // POST /wait_for_selector { selector, timeout? }
24
+ // POST /close {}
25
+ // GET /status
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Shared Puppeteer state
29
+ // ---------------------------------------------------------------------------
30
+
31
+ let _browser = null;
32
+ let _page = null;
33
+ let _puppeteer = null;
34
+ let _previousLaunchOptions = null;
35
+ const _consoleLogs = [];
36
+
37
+ const MAX_SCREENSHOT_BYTES = 2 * 1024 * 1024; // 2MB
38
+ const MAX_CONTENT_CHARS = 1 * 1024 * 1024; // 1MB
39
+
40
+ // Args we always pass for stability + reduced attack surface.
41
+ const SECURITY_ARGS = [
42
+ "--no-first-run",
43
+ "--no-default-browser-check",
44
+ "--disable-default-apps",
45
+ "--disable-extensions",
46
+ "--disable-plugins",
47
+ "--disable-sync",
48
+ "--disable-translate",
49
+ "--disable-background-networking",
50
+ "--disable-component-extensions-with-background-pages",
51
+ ];
52
+
53
+ // Args that reduce security — only allowed when allow_dangerous=true or
54
+ // ALLOW_DANGEROUS=true in env (kept for Docker / CI).
55
+ const DANGEROUS_ARGS = [
56
+ "--no-sandbox",
57
+ "--disable-setuid-sandbox",
58
+ "--single-process",
59
+ "--disable-web-security",
60
+ "--ignore-certificate-errors",
61
+ "--disable-features=IsolateOrigins",
62
+ "--disable-site-isolation-trials",
63
+ "--allow-running-insecure-content",
64
+ "--disable-dev-shm-usage",
65
+ "--remote-debugging-port",
66
+ "--remote-debugging-address",
67
+ ];
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Utilities
71
+ // ---------------------------------------------------------------------------
72
+
73
+ function deepMerge(target, source) {
74
+ if (typeof target !== "object" || target === null) return source;
75
+ if (typeof source !== "object" || source === null) return source;
76
+
77
+ const out = { ...target };
78
+ for (const key of Object.keys(source)) {
79
+ const t = target[key];
80
+ const s = source[key];
81
+ if (Array.isArray(t) && Array.isArray(s)) {
82
+ // For args/ignoreDefaultArgs: dedupe by flag prefix, prefer source.
83
+ if (key === "args" || key === "ignoreDefaultArgs") {
84
+ const sourcePrefixes = new Set(s.map(a => String(a).split("=")[0]));
85
+ const kept = t.filter(a => !(String(a).startsWith("--") && sourcePrefixes.has(String(a).split("=")[0])));
86
+ out[key] = [...new Set([...kept, ...s])];
87
+ } else {
88
+ out[key] = [...new Set([...t, ...s])];
89
+ }
90
+ } else if (s && typeof s === "object" && !Array.isArray(s) && key in target) {
91
+ out[key] = deepMerge(t, s);
92
+ } else {
93
+ out[key] = s;
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ async function loadPuppeteer() {
100
+ if (_puppeteer) return _puppeteer;
101
+ const mod =
102
+ (await import("puppeteer").catch(() => null)) ||
103
+ (await import("puppeteer-core").catch(() => null));
104
+ if (!mod) return null;
105
+ _puppeteer = mod.default ?? mod;
106
+ return _puppeteer;
107
+ }
108
+
109
+ function checkDangerous(args, allowDangerous) {
110
+ if (!Array.isArray(args)) return;
111
+ const found = args.filter(a => DANGEROUS_ARGS.some(d => String(a).startsWith(d)));
112
+ if (found.length && !allowDangerous && process.env.ALLOW_DANGEROUS !== "true") {
113
+ throw new Error(
114
+ `Dangerous browser args detected: ${found.join(", ")}. ` +
115
+ `Pass allow_dangerous=true or set ALLOW_DANGEROUS=true to override.`
116
+ );
117
+ }
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Browser lifecycle
122
+ // ---------------------------------------------------------------------------
123
+
124
+ async function ensureBrowser({ launch_options, allow_dangerous } = {}) {
125
+ const pup = await loadPuppeteer();
126
+ if (!pup) throw new Error("Puppeteer not installed. Run: npm install puppeteer");
127
+
128
+ let envOptions = {};
129
+ try {
130
+ envOptions = JSON.parse(process.env.PUPPETEER_LAUNCH_OPTIONS || "{}");
131
+ } catch (e) {
132
+ console.warn("[browser] could not parse PUPPETEER_LAUNCH_OPTIONS:", e.message);
133
+ }
134
+
135
+ const merged = deepMerge(envOptions, launch_options || {});
136
+ if (merged?.args) checkDangerous(merged.args, allow_dangerous);
137
+
138
+ // If launch options changed, recycle the browser.
139
+ const optsChanged = launch_options && JSON.stringify(launch_options) !== JSON.stringify(_previousLaunchOptions);
140
+ if (_browser && (!_browser.connected || optsChanged)) {
141
+ await _browser.close().catch(() => {});
142
+ _browser = null;
143
+ _page = null;
144
+ }
145
+ _previousLaunchOptions = launch_options ?? _previousLaunchOptions;
146
+
147
+ if (_browser && _browser.connected) {
148
+ return _page && !_page.isClosed() ? _page : (_page = (await _browser.pages())[0] || await _browser.newPage());
149
+ }
150
+
151
+ const baseSecure = [...SECURITY_ARGS, "--disable-gpu", "--no-zygote"];
152
+ const npxConfig = {
153
+ headless: "new",
154
+ args: baseSecure,
155
+ defaultViewport: { width: 1280, height: 800 },
156
+ };
157
+ const dockerConfig = {
158
+ headless: "new",
159
+ args: [...baseSecure, "--no-sandbox", "--single-process", "--disable-dev-shm-usage"],
160
+ defaultViewport: { width: 1280, height: 800 },
161
+ };
162
+ const baseConfig = process.env.DOCKER_CONTAINER ? dockerConfig : npxConfig;
163
+ const finalConfig = deepMerge(baseConfig, merged);
164
+
165
+ _browser = await pup.launch(finalConfig);
166
+ _browser.on("disconnected", () => { _browser = null; _page = null; });
167
+
168
+ const pages = await _browser.pages();
169
+ _page = pages[0] || await _browser.newPage();
170
+ await _page.setUserAgent(
171
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
172
+ );
173
+
174
+ // Capture page console output to a ring buffer.
175
+ _page.on("console", msg => {
176
+ const entry = `[${msg.type()}] ${msg.text()}`;
177
+ _consoleLogs.push(entry);
178
+ if (_consoleLogs.length > 500) _consoleLogs.splice(0, _consoleLogs.length - 500);
179
+ });
180
+
181
+ return _page;
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Tool implementations
186
+ // ---------------------------------------------------------------------------
187
+
188
+ export async function browser_navigate({ url, launch_options, allow_dangerous } = {}) {
189
+ if (!url) throw new Error("url required");
190
+ const page = await ensureBrowser({ launch_options, allow_dangerous });
191
+ const response = await page.goto(url, { waitUntil: "networkidle2", timeout: 30000 });
192
+ return {
193
+ ok: true,
194
+ url: page.url(),
195
+ status: response?.status() ?? null,
196
+ title: await page.title(),
197
+ };
198
+ }
199
+
200
+ export async function browser_screenshot({ selector, full_page = false, width, height, encoded = false } = {}) {
201
+ const page = await ensureBrowser();
202
+ if (width || height) {
203
+ await page.setViewport({
204
+ width: Math.min(width ?? 1280, 1920),
205
+ height: Math.min(height ?? 800, 1080),
206
+ });
207
+ }
208
+
209
+ const target = selector ? await page.$(selector) : null;
210
+ if (selector && !target) throw new Error(`Element not found: ${selector}`);
211
+
212
+ const buf = target
213
+ ? await target.screenshot({ type: "png", encoding: "base64" })
214
+ : await page.screenshot({ type: "png", encoding: "base64", fullPage: !!full_page });
215
+
216
+ const size = Buffer.from(String(buf), "base64").length;
217
+ if (size > MAX_SCREENSHOT_BYTES) {
218
+ throw new Error(`Screenshot too large: ${Math.round(size / 1024)}KB (max ${Math.round(MAX_SCREENSHOT_BYTES / 1024)}KB)`);
219
+ }
220
+
221
+ return {
222
+ ok: true,
223
+ url: page.url(),
224
+ format: "png",
225
+ bytes: size,
226
+ base64: buf,
227
+ data_uri: encoded ? `data:image/png;base64,${buf}` : undefined,
228
+ };
229
+ }
230
+
231
+ export async function browser_click({ selector } = {}) {
232
+ if (!selector) throw new Error("selector required");
233
+ const page = await ensureBrowser();
234
+ await page.waitForSelector(selector, { timeout: 10000 });
235
+ await page.click(selector);
236
+ await page.waitForNetworkIdle({ timeout: 5000 }).catch(() => {});
237
+ return { ok: true, selector, url: page.url() };
238
+ }
239
+
240
+ export async function browser_type({ selector, text, clear = true } = {}) {
241
+ if (!selector) throw new Error("selector required");
242
+ if (text === undefined) throw new Error("text required");
243
+ const page = await ensureBrowser();
244
+ await page.waitForSelector(selector, { timeout: 10000 });
245
+ await page.focus(selector);
246
+ if (clear) {
247
+ await page.keyboard.down("Control");
248
+ await page.keyboard.press("KeyA");
249
+ await page.keyboard.up("Control");
250
+ await page.keyboard.press("Backspace");
251
+ }
252
+ await page.type(selector, String(text), { delay: 20 });
253
+ return { ok: true, selector, typed: String(text).length };
254
+ }
255
+
256
+ export async function browser_select({ selector, value } = {}) {
257
+ if (!selector) throw new Error("selector required");
258
+ if (value === undefined) throw new Error("value required");
259
+ const page = await ensureBrowser();
260
+ await page.waitForSelector(selector, { timeout: 10000 });
261
+ await page.select(selector, String(value));
262
+ return { ok: true, selector, value };
263
+ }
264
+
265
+ export async function browser_hover({ selector } = {}) {
266
+ if (!selector) throw new Error("selector required");
267
+ const page = await ensureBrowser();
268
+ await page.waitForSelector(selector, { timeout: 10000 });
269
+ await page.hover(selector);
270
+ return { ok: true, selector };
271
+ }
272
+
273
+ export async function browser_evaluate({ code } = {}) {
274
+ if (!code) throw new Error("code required");
275
+ const page = await ensureBrowser();
276
+
277
+ // Install in-page console capture so evaluated code's logs come back.
278
+ await page.evaluate(() => {
279
+ window.__apxHelper = { logs: [], orig: { ...console } };
280
+ for (const m of ["log", "info", "warn", "error", "debug"]) {
281
+ console[m] = (...a) => {
282
+ window.__apxHelper.logs.push(`[${m}] ${a.map(x => {
283
+ try { return typeof x === "string" ? x : JSON.stringify(x); } catch { return String(x); }
284
+ }).join(" ")}`);
285
+ window.__apxHelper.orig[m](...a);
286
+ };
287
+ }
288
+ });
289
+
290
+ let result, error;
291
+ try {
292
+ // eslint-disable-next-line no-new-func
293
+ result = await page.evaluate(new Function(code));
294
+ } catch (e) {
295
+ error = e.message;
296
+ }
297
+
298
+ const logs = await page.evaluate(() => {
299
+ Object.assign(console, window.__apxHelper.orig);
300
+ const out = window.__apxHelper.logs;
301
+ delete window.__apxHelper;
302
+ return out;
303
+ });
304
+
305
+ if (error) throw new Error(`evaluate failed: ${error}\nlogs:\n${logs.join("\n")}`);
306
+ return { ok: true, result, logs };
307
+ }
308
+
309
+ export async function browser_get_text({ selector } = {}) {
310
+ const page = await ensureBrowser();
311
+ const text = await page.evaluate((sel) => {
312
+ const root = sel ? document.querySelector(sel) : document.body;
313
+ if (!root) return null;
314
+ const clone = root.cloneNode(true);
315
+ for (const tag of ["script", "style", "nav", "header", "footer", "noscript"]) {
316
+ for (const el of clone.querySelectorAll(tag)) el.remove();
317
+ }
318
+ return clone.innerText || clone.textContent || "";
319
+ }, selector ?? null);
320
+ if (text === null) throw new Error(`Element not found: ${selector}`);
321
+ const cleaned = text.replace(/\n{3,}/g, "\n\n").trim();
322
+ return {
323
+ ok: true,
324
+ url: page.url(),
325
+ title: await page.title(),
326
+ text: cleaned,
327
+ chars: cleaned.length,
328
+ };
329
+ }
330
+
331
+ export async function browser_get_content({ selector } = {}) {
332
+ const page = await ensureBrowser();
333
+ let content = selector
334
+ ? await page.$eval(selector, el => el.innerHTML).catch(() => null)
335
+ : await page.content();
336
+ if (content === null) throw new Error(`Element not found: ${selector}`);
337
+
338
+ let truncated = false;
339
+ if (content.length > MAX_CONTENT_CHARS) {
340
+ content = content.slice(0, MAX_CONTENT_CHARS) + "\n[TRUNCATED]";
341
+ truncated = true;
342
+ }
343
+ return {
344
+ ok: true,
345
+ url: page.url(),
346
+ selector: selector ?? null,
347
+ chars: content.length,
348
+ truncated,
349
+ html: content,
350
+ };
351
+ }
352
+
353
+ export async function browser_wait_for_selector({ selector, timeout = 30000 } = {}) {
354
+ if (!selector) throw new Error("selector required");
355
+ const page = await ensureBrowser();
356
+ await page.waitForSelector(selector, { timeout });
357
+ return { ok: true, selector };
358
+ }
359
+
360
+ export async function browser_close() {
361
+ if (_browser) {
362
+ await _browser.close().catch(() => {});
363
+ _browser = null;
364
+ _page = null;
365
+ _consoleLogs.length = 0;
366
+ }
367
+ return { ok: true };
368
+ }
369
+
370
+ export async function browserStatus() {
371
+ const pup = await loadPuppeteer();
372
+ return {
373
+ puppeteer_available: !!pup,
374
+ browser_open: !!(_browser && _browser.connected),
375
+ current_url: (_page && !_page.isClosed()) ? _page.url() : null,
376
+ console_log_count: _consoleLogs.length,
377
+ };
378
+ }
379
+
380
+ export function getConsoleLogs(limit = 100) {
381
+ return _consoleLogs.slice(-limit);
382
+ }
383
+
384
+ // Graceful shutdown — best-effort close on process exit.
385
+ for (const sig of ["SIGINT", "SIGTERM"]) {
386
+ process.on(sig, async () => {
387
+ if (_browser) await _browser.close().catch(() => {});
388
+ });
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // Express router factory
393
+ // ---------------------------------------------------------------------------
394
+
395
+ export function buildBrowserRouter(express) {
396
+ const router = express.Router();
397
+ const wrap = fn => async (req, res) => {
398
+ try { res.json(await fn(req.body || {})); }
399
+ catch (e) { res.status(500).json({ error: e.message }); }
400
+ };
401
+
402
+ router.post("/navigate", wrap(browser_navigate));
403
+ router.post("/screenshot", wrap(browser_screenshot));
404
+ router.post("/click", wrap(browser_click));
405
+ router.post("/type", wrap(browser_type));
406
+ router.post("/select", wrap(browser_select));
407
+ router.post("/hover", wrap(browser_hover));
408
+ router.post("/evaluate", wrap(browser_evaluate));
409
+ router.post("/get_text", wrap(browser_get_text));
410
+ router.post("/get_content", wrap(browser_get_content));
411
+ router.post("/wait_for_selector", wrap(browser_wait_for_selector));
412
+ router.post("/close", wrap(browser_close));
413
+
414
+ router.get("/status", async (_req, res) => {
415
+ try { res.json(await browserStatus()); }
416
+ catch (e) { res.status(500).json({ error: e.message }); }
417
+ });
418
+ router.get("/console_logs", (req, res) => {
419
+ const limit = Number(req.query.limit) || 100;
420
+ res.json({ ok: true, logs: getConsoleLogs(limit) });
421
+ });
422
+
423
+ return router;
424
+ }
@@ -0,0 +1,138 @@
1
+ // daemon/tools/fetch.js
2
+ // Lightweight HTTP fetch tools — no Puppeteer, no Chromium. Starts in
3
+ // milliseconds. Use this when you only need to hit an HTTP endpoint
4
+ // (REST API, raw page HTML, JSON). For JS-rendered pages, real clicks,
5
+ // screenshots, etc., use tools/browser.js instead.
6
+ //
7
+ // Uses Node 18+ built-in fetch with a node-fetch fallback for older
8
+ // runtimes.
9
+ //
10
+ // Endpoints (mounted at /tools/fetch by api.js):
11
+ // POST /get { url, headers?, timeout_ms? }
12
+ // POST /post { url, body?, headers?, timeout_ms?, json? }
13
+ // POST /request { url, method?, headers?, body?, timeout_ms?, json? }
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Fetch resolver
17
+ // ---------------------------------------------------------------------------
18
+
19
+ let _fetch = null;
20
+
21
+ async function getFetch() {
22
+ if (_fetch) return _fetch;
23
+ if (typeof globalThis.fetch === "function") {
24
+ _fetch = globalThis.fetch.bind(globalThis);
25
+ return _fetch;
26
+ }
27
+ const mod = await import("node-fetch").catch(() => null);
28
+ if (!mod) throw new Error("No fetch available. Upgrade Node to >=18 or install node-fetch.");
29
+ _fetch = mod.default;
30
+ return _fetch;
31
+ }
32
+
33
+ const DEFAULT_TIMEOUT = 30000;
34
+ const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5MB
35
+
36
+ async function readBody(response, jsonHint) {
37
+ const ctype = response.headers.get("content-type") || "";
38
+ const wantsJson = jsonHint || ctype.includes("application/json");
39
+
40
+ // Use arrayBuffer so we can enforce a size cap regardless of content-type.
41
+ const ab = await response.arrayBuffer();
42
+ if (ab.byteLength > MAX_BODY_BYTES) {
43
+ return {
44
+ truncated: true,
45
+ bytes: ab.byteLength,
46
+ text: Buffer.from(ab.slice(0, MAX_BODY_BYTES)).toString("utf8") + "\n[TRUNCATED]",
47
+ json: null,
48
+ };
49
+ }
50
+ const text = Buffer.from(ab).toString("utf8");
51
+ let json = null;
52
+ if (wantsJson) {
53
+ try { json = JSON.parse(text); } catch { /* not JSON; leave as text */ }
54
+ }
55
+ return { truncated: false, bytes: ab.byteLength, text, json };
56
+ }
57
+
58
+ async function doRequest({ url, method = "GET", headers = {}, body = null, timeout_ms = DEFAULT_TIMEOUT, json = false } = {}) {
59
+ if (!url) throw new Error("url required");
60
+ const fetch = await getFetch();
61
+
62
+ const controller = new AbortController();
63
+ const t = setTimeout(() => controller.abort(), timeout_ms);
64
+
65
+ const opts = {
66
+ method: String(method).toUpperCase(),
67
+ headers: { ...headers },
68
+ signal: controller.signal,
69
+ };
70
+
71
+ if (body !== null && body !== undefined && opts.method !== "GET" && opts.method !== "HEAD") {
72
+ if (typeof body === "object" && !(body instanceof Uint8Array) && !(typeof Buffer !== "undefined" && Buffer.isBuffer?.(body))) {
73
+ opts.body = JSON.stringify(body);
74
+ if (!opts.headers["content-type"] && !opts.headers["Content-Type"]) {
75
+ opts.headers["content-type"] = "application/json";
76
+ }
77
+ } else {
78
+ opts.body = body;
79
+ }
80
+ }
81
+
82
+ try {
83
+ const r = await fetch(url, opts);
84
+ const parsed = await readBody(r, json);
85
+ const responseHeaders = {};
86
+ r.headers.forEach((v, k) => { responseHeaders[k] = v; });
87
+ return {
88
+ ok: r.ok,
89
+ status: r.status,
90
+ status_text: r.statusText,
91
+ url: r.url,
92
+ headers: responseHeaders,
93
+ bytes: parsed.bytes,
94
+ truncated: parsed.truncated,
95
+ body: parsed.text,
96
+ json: parsed.json,
97
+ };
98
+ } catch (e) {
99
+ if (e.name === "AbortError") throw new Error(`Request timeout after ${timeout_ms}ms`);
100
+ throw e;
101
+ } finally {
102
+ clearTimeout(t);
103
+ }
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Tool implementations
108
+ // ---------------------------------------------------------------------------
109
+
110
+ export async function http_get({ url, headers, timeout_ms } = {}) {
111
+ return doRequest({ url, method: "GET", headers, timeout_ms });
112
+ }
113
+
114
+ export async function http_post({ url, body, headers, timeout_ms, json } = {}) {
115
+ return doRequest({ url, method: "POST", headers, body, timeout_ms, json });
116
+ }
117
+
118
+ export async function http_request(params = {}) {
119
+ return doRequest(params);
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Express router factory
124
+ // ---------------------------------------------------------------------------
125
+
126
+ export function buildFetchRouter(express) {
127
+ const router = express.Router();
128
+ const wrap = fn => async (req, res) => {
129
+ try { res.json(await fn(req.body || {})); }
130
+ catch (e) { res.status(500).json({ error: e.message }); }
131
+ };
132
+
133
+ router.post("/get", wrap(http_get));
134
+ router.post("/post", wrap(http_post));
135
+ router.post("/request", wrap(http_request));
136
+
137
+ return router;
138
+ }