@bubstack/moe-glass 0.1.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.
Files changed (62) hide show
  1. package/README.md +29 -0
  2. package/agents/browser-user.md +105 -0
  3. package/dist/LICENSE +25 -0
  4. package/dist/index.d.ts +9 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +22517 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/payload.d.ts +214 -0
  9. package/dist/payload.d.ts.map +1 -0
  10. package/dist/payload.js +325 -0
  11. package/dist/payload.js.map +1 -0
  12. package/package.json +59 -0
  13. package/skills/browsing/COMMANDLINE-USAGE.md +595 -0
  14. package/skills/browsing/EXAMPLES.md +717 -0
  15. package/skills/browsing/README.md +55 -0
  16. package/skills/browsing/SKILL.md +478 -0
  17. package/skills/browsing/chrome-ws +1021 -0
  18. package/skills/browsing/chrome-ws-lib.js +461 -0
  19. package/skills/browsing/host-override.js +98 -0
  20. package/skills/browsing/lib/browser-bridge.js +175 -0
  21. package/skills/browsing/lib/browser-session.js +137 -0
  22. package/skills/browsing/lib/capture.js +499 -0
  23. package/skills/browsing/lib/cdp-router.js +72 -0
  24. package/skills/browsing/lib/cdp-utils.js +18 -0
  25. package/skills/browsing/lib/chrome-launcher-helpers.js +374 -0
  26. package/skills/browsing/lib/chrome-process.js +464 -0
  27. package/skills/browsing/lib/console-logging.js +70 -0
  28. package/skills/browsing/lib/cookies.js +17 -0
  29. package/skills/browsing/lib/dialogs-render.js +154 -0
  30. package/skills/browsing/lib/dialogs-router.js +117 -0
  31. package/skills/browsing/lib/dialogs.js +254 -0
  32. package/skills/browsing/lib/element-selector.js +91 -0
  33. package/skills/browsing/lib/evaluation.js +85 -0
  34. package/skills/browsing/lib/extraction.js +55 -0
  35. package/skills/browsing/lib/file-upload.js +56 -0
  36. package/skills/browsing/lib/html-diff.js +122 -0
  37. package/skills/browsing/lib/key-definitions.js +149 -0
  38. package/skills/browsing/lib/keyboard-input.js +288 -0
  39. package/skills/browsing/lib/mouse.js +423 -0
  40. package/skills/browsing/lib/navigation.js +272 -0
  41. package/skills/browsing/lib/page-scripts/dom-summary.js +31 -0
  42. package/skills/browsing/lib/page-scripts/markdown.js +85 -0
  43. package/skills/browsing/lib/page-scripts/permission-shim.js +80 -0
  44. package/skills/browsing/lib/page-session.js +106 -0
  45. package/skills/browsing/lib/profile-lock.js +179 -0
  46. package/skills/browsing/lib/screenshot.js +171 -0
  47. package/skills/browsing/lib/select-option.js +99 -0
  48. package/skills/browsing/lib/session-state.js +66 -0
  49. package/skills/browsing/lib/tabs.js +144 -0
  50. package/skills/browsing/lib/viewport.js +103 -0
  51. package/skills/browsing/lib/websocket-client.js +162 -0
  52. package/skills/browsing/package.json +11 -0
  53. package/skills/browsing/test-chrome-args.js +81 -0
  54. package/skills/browsing/test-cookies.js +21 -0
  55. package/skills/browsing/test-e2e.sh +51 -0
  56. package/skills/browsing/test-extract.sh +17 -0
  57. package/skills/browsing/test-interact.sh +11 -0
  58. package/skills/browsing/test-navigate.sh +9 -0
  59. package/skills/browsing/test-raw.sh +8 -0
  60. package/skills/browsing/test-tabs.sh +15 -0
  61. package/skills/browsing/test-viewport.js +27 -0
  62. package/skills/browsing/test-wait.sh +9 -0
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Pure helpers used by the Chrome launcher: HTTP probing, profile path
3
+ * resolution, meta.json read/write, port allocation, and Chrome flag list
4
+ * construction. None of these touch session state — every input is passed
5
+ * explicitly. Kept together because they share no dependency on the rest
6
+ * of chrome-ws-lib.
7
+ */
8
+
9
+ const http = require('http');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const net = require('net');
14
+
15
+ // HTTP request to Chrome's DevTools endpoint at an explicit host:port.
16
+ // Used for probing arbitrary ports before settling on activePort.
17
+ async function chromeHttpAt(host, port, urlPath, method = 'GET') {
18
+ return new Promise((resolve, reject) => {
19
+ const options = { hostname: host, port, path: urlPath, method };
20
+
21
+ const req = http.request(options, (res) => {
22
+ let data = '';
23
+ res.on('data', chunk => data += chunk);
24
+ res.on('end', () => {
25
+ if (!data) { resolve({}); return; }
26
+ try { resolve(JSON.parse(data)); }
27
+ catch (_e) { resolve({ message: data }); }
28
+ });
29
+ });
30
+
31
+ req.on('error', reject);
32
+ req.end();
33
+ });
34
+ }
35
+
36
+ function getXdgCacheHome() {
37
+ if (process.env.XDG_CACHE_HOME) {
38
+ return process.env.XDG_CACHE_HOME;
39
+ }
40
+
41
+ const platform = os.platform();
42
+ const homeDir = os.homedir();
43
+
44
+ if (platform === 'darwin') {
45
+ return path.join(homeDir, 'Library', 'Caches');
46
+ } else if (platform === 'win32') {
47
+ return process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
48
+ } else {
49
+ return path.join(homeDir, '.cache');
50
+ }
51
+ }
52
+
53
+ function getChromeProfileDir(profileName = 'moe-glass') {
54
+ return path.join(getXdgCacheHome(), 'moe', 'browser-profiles', profileName);
55
+ }
56
+
57
+ // --- Per-profile meta.json ---
58
+ //
59
+ // Each profile gets a sibling meta.json file next to its data directory:
60
+ // ~/.cache/moe/browser-profiles/moe-glass/ ← profile data
61
+ // ~/.cache/moe/browser-profiles/moe-glass.meta.json ← port/pid tracking
62
+ //
63
+ // Enables: reconnection across sessions, parallel Chrome instances per
64
+ // profile, and collision detection.
65
+
66
+ function getProfileMetaPath(profileName = 'moe-glass') {
67
+ return path.join(getXdgCacheHome(), 'moe', 'browser-profiles', `${profileName}.meta.json`);
68
+ }
69
+
70
+ function readProfileMeta(profileName = 'moe-glass') {
71
+ try {
72
+ const data = fs.readFileSync(getProfileMetaPath(profileName), 'utf8');
73
+ return JSON.parse(data);
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function writeProfileMeta(profileName, data) {
80
+ const metaPath = getProfileMetaPath(profileName);
81
+ fs.mkdirSync(path.dirname(metaPath), { recursive: true });
82
+ fs.writeFileSync(metaPath, JSON.stringify(data, null, 2) + '\n');
83
+ }
84
+
85
+ function clearProfileMeta(profileName) {
86
+ try {
87
+ fs.unlinkSync(getProfileMetaPath(profileName));
88
+ } catch {
89
+ // Already absent — nothing to do
90
+ }
91
+ }
92
+
93
+ // Check if a port has a live Chrome DevTools instance, optionally verify PID.
94
+ async function isPortAlive(host, port, expectedPid = null) {
95
+ try {
96
+ const data = await chromeHttpAt(host, port, '/json/version');
97
+ if (!data || !data.Browser) return false;
98
+ if (expectedPid) {
99
+ try { process.kill(expectedPid, 0); } // signal 0 = existence check
100
+ catch { return false; }
101
+ }
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ // Probe whether a port is free (no listener) using a temporary TCP server.
109
+ // "Free" means free on BOTH IPv4 and IPv6 — Chrome may bind ::1 only on
110
+ // some macOS configurations, and a port bound on ::1 still appears free
111
+ // from a 127.0.0.1 probe. Without checking both, we'd start a second
112
+ // Chrome that races the first for the same port number on different
113
+ // stacks, with non-deterministic answers to /json HTTP requests.
114
+ function isPortFreeOn(host, port) {
115
+ return new Promise((resolve) => {
116
+ const server = net.createServer();
117
+ // Resolve with the OS error code so the caller can tell "port in use"
118
+ // (EADDRINUSE) apart from "this loopback/address family isn't available
119
+ // here at all" (EADDRNOTAVAIL / EAFNOSUPPORT) — very different signals.
120
+ server.once('error', (err) => resolve({ free: false, code: err.code }));
121
+ server.once('listening', () => { server.close(() => resolve({ free: true })); });
122
+ server.listen(port, host);
123
+ });
124
+ }
125
+
126
+ // Pure decision over the IPv4 and IPv6 loopback probe results. A port is free
127
+ // only if IPv4 loopback is free. The IPv6 probe is a race-guard for hosts where
128
+ // Chrome may bind ::1 only (some macOS configs) — but an UNAVAILABLE IPv6
129
+ // loopback (e.g. a container with net.ipv6.conf.lo.disable_ipv6=1, where every
130
+ // ::1 bind returns EADDRNOTAVAIL) is NOT a port conflict and must not veto the
131
+ // port. Only a genuine in-use signal on ::1 vetoes. Exported for testing.
132
+ function portFreeFromProbes(v4, v6) {
133
+ if (!v4.free) return false;
134
+ if (v6.free) return true;
135
+ if (v6.code === 'EADDRNOTAVAIL' || v6.code === 'EAFNOSUPPORT') return true;
136
+ return false;
137
+ }
138
+
139
+ async function isPortFree(port) {
140
+ const v4 = await isPortFreeOn('127.0.0.1', port);
141
+ if (!v4.free) return false;
142
+ const v6 = await isPortFreeOn('::1', port);
143
+ return portFreeFromProbes(v4, v6);
144
+ }
145
+
146
+ // Port range tried sequentially, starting at 9222 for backward compat.
147
+ const PORT_RANGE_START = 9222;
148
+ const PORT_RANGE_END = 12111;
149
+
150
+ // Find first available port in range. Defaults span the full PORT_RANGE.
151
+ async function findAvailablePort(start = PORT_RANGE_START, end = PORT_RANGE_END) {
152
+ for (let port = start; port <= end; port++) {
153
+ if (await isPortFree(port)) return port;
154
+ }
155
+ throw new Error(`No available port in range ${start}-${end}`);
156
+ }
157
+
158
+ // Find the PID of the process holding `port`, or null if none.
159
+ // Uses platform-native tools — lsof on macOS/Linux, netstat on Windows.
160
+ // Returns null on any failure (parsing, missing tool, no listener).
161
+ function findPidOnPort(port) {
162
+ const { execFileSync } = require('child_process');
163
+ // Guard against a non-numeric or out-of-range port before using it.
164
+ const portNum = Number(port);
165
+ if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) {
166
+ return null;
167
+ }
168
+ try {
169
+ if (process.platform === 'darwin' || process.platform === 'linux') {
170
+ // argv form, no shell: the port can never be shell-interpreted.
171
+ const out = execFileSync('lsof', [`-ti:${portNum}`, '-sTCP:LISTEN'], {
172
+ encoding: 'utf8',
173
+ stdio: ['ignore', 'pipe', 'ignore']
174
+ }).trim();
175
+ if (!out) return null;
176
+ const first = out.split('\n')[0];
177
+ const pid = parseInt(first, 10);
178
+ return Number.isFinite(pid) ? pid : null;
179
+ }
180
+ if (process.platform === 'win32') {
181
+ // execFileSync can't express the old `netstat -ano | findstr :PORT`
182
+ // pipeline (pipes need a shell), so filter in JS instead: LISTENING
183
+ // lines whose local-address column ends with exactly `:PORT`.
184
+ const out = execFileSync('netstat', ['-ano'], {
185
+ encoding: 'utf8',
186
+ stdio: ['ignore', 'pipe', 'ignore']
187
+ });
188
+ const portSuffix = `:${portNum}`;
189
+ const lines = out.split(/\r?\n/).filter(l => {
190
+ if (!/LISTENING/i.test(l)) return false;
191
+ const cols = l.trim().split(/\s+/);
192
+ return cols.length >= 2 && cols[1].endsWith(portSuffix);
193
+ });
194
+ if (!lines.length) return null;
195
+ const cols = lines[0].trim().split(/\s+/);
196
+ const pid = parseInt(cols[cols.length - 1], 10);
197
+ return Number.isFinite(pid) ? pid : null;
198
+ }
199
+ } catch (_e) {
200
+ return null;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ // Scan running processes for a Chrome holding our profile's lock.
206
+ // Used to adopt orphan Chrome instances (meta.json missing/stale).
207
+ // Returns { pid, port } for first match, or null.
208
+ //
209
+ // Scans ps output for Chrome processes with:
210
+ // --user-data-dir=<our profileDir> AND --remote-debugging-port=<N>
211
+ // Skips Chrome Helper processes (renderer, GPU, etc).
212
+ function findOrphanChromeForProfile(profileName) {
213
+ const { execSync } = require('child_process');
214
+ try {
215
+ const profileDir = getChromeProfileDir(profileName);
216
+ let psOutput;
217
+
218
+ if (process.platform === 'darwin' || process.platform === 'linux') {
219
+ // ps auxw: full command line per process
220
+ psOutput = execSync('ps auxw', {
221
+ encoding: 'utf8',
222
+ stdio: ['ignore', 'pipe', 'ignore']
223
+ });
224
+ } else if (process.platform === 'win32') {
225
+ // Windows: use wmic to list processes with their full command line
226
+ psOutput = execSync('wmic process list full', {
227
+ encoding: 'utf8',
228
+ stdio: ['ignore', 'pipe', 'ignore']
229
+ });
230
+ } else {
231
+ return null; // Unsupported platform
232
+ }
233
+
234
+ const lines = psOutput.split('\n');
235
+ for (const line of lines) {
236
+ // Skip empty lines and Chrome Helper processes (rendering, GPU, etc)
237
+ if (!line.trim() || line.includes('Chrome Helper') || line.includes('chrome.exe --type=')) {
238
+ continue;
239
+ }
240
+
241
+ // Must contain our profile dir
242
+ if (!line.includes(profileDir)) {
243
+ continue;
244
+ }
245
+
246
+ // Must contain --remote-debugging-port
247
+ const portMatch = line.match(/--remote-debugging-port=(\d+)/);
248
+ if (!portMatch || !portMatch[1]) {
249
+ continue;
250
+ }
251
+
252
+ const port = parseInt(portMatch[1], 10);
253
+
254
+ // Extract PID: position varies by platform, but it's early in the line.
255
+ // macOS/Linux: "USER PID ..." — PID is second field after spaces
256
+ // Windows wmic: "ProcessId=..." or first numeric field
257
+ let pid;
258
+ if (process.platform === 'darwin' || process.platform === 'linux') {
259
+ const fields = line.split(/\s+/);
260
+ if (fields.length >= 2) {
261
+ pid = parseInt(fields[1], 10);
262
+ }
263
+ } else if (process.platform === 'win32') {
264
+ const pidMatch = line.match(/ProcessId=(\d+)|^(\d+)\s/);
265
+ if (pidMatch) {
266
+ pid = parseInt(pidMatch[1] || pidMatch[2], 10);
267
+ }
268
+ }
269
+
270
+ if (Number.isFinite(pid) && Number.isFinite(port)) {
271
+ return { pid, port };
272
+ }
273
+ }
274
+
275
+ return null;
276
+ } catch (_e) {
277
+ // ps or wmic failed, no process info available
278
+ return null;
279
+ }
280
+ }
281
+
282
+ // Chrome's sandbox cannot work as root, and usually not inside containers
283
+ // (no user namespaces), so --no-sandbox is required there. Everywhere else
284
+ // the sandbox stays on: this browser navigates to agent-chosen URLs, so
285
+ // exploit containment matters. Params are injectable for tests; defaults
286
+ // read the real environment.
287
+ function sandboxDisableNeeded({
288
+ uid = (process.getuid ? process.getuid() : null),
289
+ dockerEnv = fs.existsSync('/.dockerenv'),
290
+ cgroup = readInitCgroup(),
291
+ } = {}) {
292
+ if (uid === 0) return true;
293
+ if (dockerEnv) return true;
294
+ if (cgroup && /docker|kubepods|containerd|lxc/.test(cgroup)) return true;
295
+ return false;
296
+ }
297
+
298
+ function readInitCgroup() {
299
+ try {
300
+ return fs.readFileSync('/proc/1/cgroup', 'utf8');
301
+ } catch (_e) {
302
+ return null; // not Linux, or unreadable — no container signal
303
+ }
304
+ }
305
+
306
+ function buildChromeArgs({ chosenPort, chromeUserDataDir, chromeHeadless, noSandbox = sandboxDisableNeeded() }) {
307
+ const args = [
308
+ `--remote-debugging-port=${chosenPort}`,
309
+ `--user-data-dir=${chromeUserDataDir}`,
310
+ '--no-first-run',
311
+ '--no-default-browser-check',
312
+ '--disable-search-engine-choice-screen',
313
+ '--password-store=basic',
314
+ '--use-mock-keychain',
315
+ '--disable-background-networking',
316
+ '--disable-background-timer-throttling',
317
+ '--disable-backgrounding-occluded-windows',
318
+ '--disable-breakpad',
319
+ '--disable-client-side-phishing-detection',
320
+ '--disable-component-update',
321
+ '--disable-default-apps',
322
+ '--disable-dev-shm-usage',
323
+ '--disable-extensions',
324
+ '--disable-features=Translate,TranslateUI,OptimizationHints',
325
+ '--disable-hang-monitor',
326
+ '--disable-ipc-flooding-protection',
327
+ '--disable-popup-blocking',
328
+ '--disable-prompt-on-repost',
329
+ '--disable-sync',
330
+ '--force-color-profile=srgb',
331
+ '--metrics-recording-only',
332
+ '--safebrowsing-disable-auto-update',
333
+ '--disable-blink-features=AutomationControlled'
334
+ ];
335
+
336
+ if (noSandbox) {
337
+ args.push('--no-sandbox');
338
+ }
339
+
340
+ if (chromeHeadless) {
341
+ args.push('--headless=new');
342
+ }
343
+
344
+ // CHROME_EXTRA_ARGS: whitespace-separated extra flags to append, e.g. for
345
+ // software WebGL in headless containers:
346
+ // CHROME_EXTRA_ARGS="--use-gl=angle --use-angle=swiftshader-webgl --enable-unsafe-swiftshader"
347
+ const extraArgs = process.env.CHROME_EXTRA_ARGS;
348
+ if (extraArgs) {
349
+ const tokens = extraArgs.split(/\s+/).filter(Boolean);
350
+ args.push(...tokens);
351
+ }
352
+
353
+ return args;
354
+ }
355
+
356
+ module.exports = {
357
+ PORT_RANGE_START,
358
+ PORT_RANGE_END,
359
+ chromeHttpAt,
360
+ getXdgCacheHome,
361
+ getChromeProfileDir,
362
+ getProfileMetaPath,
363
+ readProfileMeta,
364
+ writeProfileMeta,
365
+ clearProfileMeta,
366
+ isPortAlive,
367
+ isPortFree,
368
+ portFreeFromProbes,
369
+ findAvailablePort,
370
+ findPidOnPort,
371
+ findOrphanChromeForProfile,
372
+ buildChromeArgs,
373
+ sandboxDisableNeeded,
374
+ };