@ricsam/r5d-browser 0.0.51 → 0.0.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -3
- package/dist/cjs/browser-runtime.cjs +295 -0
- package/dist/cjs/chrome-launcher.cjs +680 -0
- package/dist/cjs/cli-options.cjs +95 -0
- package/dist/cjs/main.cjs +172 -365
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/port-forward-manager.cjs +1 -1
- package/dist/mjs/browser-runtime.mjs +261 -0
- package/dist/mjs/chrome-launcher.mjs +637 -0
- package/dist/mjs/cli-options.mjs +60 -0
- package/dist/mjs/main.mjs +172 -365
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/port-forward-manager.mjs +1 -1
- package/dist/types/browser-runtime.d.ts +36 -0
- package/dist/types/chrome-launcher.d.ts +53 -0
- package/dist/types/cli-options.d.ts +16 -0
- package/package.json +2 -2
- package/dist/cjs/playwright-install.cjs +0 -49
- package/dist/mjs/playwright-install.mjs +0 -15
- package/dist/types/playwright-install.d.ts +0 -1
package/README.md
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
# r5d-browser
|
|
2
2
|
|
|
3
|
-
`r5d-browser` starts a dedicated, persistent
|
|
3
|
+
`r5d-browser` starts stable Google Chrome with a dedicated, persistent profile on a Mac and connects it to r5d.dev. Sign in to sites in the opened browser normally; agents control Chrome over a loopback-only debugging connection without moving the real system pointer or taking keyboard focus.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install -g @ricsam/r5d-browser
|
|
7
7
|
r5d-browser start
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Install stable [Google Chrome](https://www.google.com/chrome/) before starting the browser. `r5d-browser` resolves the executable in this order:
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
1. `--chrome-path <path>`
|
|
13
|
+
2. `R5D_BROWSER_CHROME_PATH`
|
|
14
|
+
3. `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`
|
|
15
|
+
4. `~/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`
|
|
16
|
+
|
|
17
|
+
For example:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
r5d-browser start --chrome-path "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The profile is stored under `~/.r5d/browser/chrome-profile`. The previous Chrome for Testing profile at `~/.r5d/browser/profile` is preserved but is not migrated automatically, so sign in again the first time you use the new profile. Browser binaries previously installed under `~/.r5d/browser/browsers` are also left untouched, but new versions of `r5d-browser` do not use them. Completed downloads are retained under `~/.r5d/browser/downloads`; agents can list them and copy a selected file into the active session's artifacts. That artifact is then synchronized to every connected worker. Browser screenshots are session artifacts in r5d.dev and are not retained by the browser process.
|
|
24
|
+
|
|
25
|
+
Agents can also create loopback-only TCP port forwards from this Mac to the worker bound to a chat. For example, a forward can map Chrome's `localhost:4323` to `127.0.0.1:3232` on that worker. The relay preserves raw TCP traffic, including HTTP, HTTPS, WebSockets, HMR, and SSE; it never exposes a public listener or permits an arbitrary target host.
|
|
13
26
|
|
|
14
27
|
Port forwards remain active across agent turns and r5d.dev control-connection reconnects. They are owned by the running `r5d-browser` process, so restarting that process ends them. Agents can list and stop forwards, and the user can inspect or stop them from Settings.
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var browser_runtime_exports = {};
|
|
30
|
+
__export(browser_runtime_exports, {
|
|
31
|
+
BrowserRuntime: () => BrowserRuntime
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(browser_runtime_exports);
|
|
34
|
+
var import_node_fs = __toESM(require("node:fs"), 1);
|
|
35
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
36
|
+
const MAX_DOWNLOAD_CHUNK_BYTES = 1024 * 1024;
|
|
37
|
+
class BrowserRuntime {
|
|
38
|
+
constructor(context, downloadsPath) {
|
|
39
|
+
this.context = context;
|
|
40
|
+
this.downloadsPath = downloadsPath;
|
|
41
|
+
import_node_fs.default.mkdirSync(downloadsPath, { recursive: true });
|
|
42
|
+
for (const page of context.pages()) this.track(page);
|
|
43
|
+
context.on("page", (page) => this.track(page));
|
|
44
|
+
}
|
|
45
|
+
context;
|
|
46
|
+
downloadsPath;
|
|
47
|
+
ids = /* @__PURE__ */ new WeakMap();
|
|
48
|
+
pages = /* @__PURE__ */ new Map();
|
|
49
|
+
cursor = /* @__PURE__ */ new Map();
|
|
50
|
+
downloadSaveQueue = Promise.resolve();
|
|
51
|
+
track(page) {
|
|
52
|
+
const existing = this.ids.get(page);
|
|
53
|
+
if (existing) return existing;
|
|
54
|
+
const id = crypto.randomUUID();
|
|
55
|
+
this.ids.set(page, id);
|
|
56
|
+
this.pages.set(id, page);
|
|
57
|
+
page.on("download", (download) => {
|
|
58
|
+
this.downloadSaveQueue = this.downloadSaveQueue.then(() => this.persistDownload(download)).catch((error) => {
|
|
59
|
+
process.stderr.write(`[r5d-browser] failed to save download: ${error instanceof Error ? error.message : String(error)}
|
|
60
|
+
`);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
page.once("close", () => {
|
|
64
|
+
this.pages.delete(id);
|
|
65
|
+
this.cursor.delete(id);
|
|
66
|
+
});
|
|
67
|
+
return id;
|
|
68
|
+
}
|
|
69
|
+
safeDownloadFilename(suggestedFilename) {
|
|
70
|
+
const basename = import_node_path.default.basename(suggestedFilename).replace(/[\\/\0]/g, "-").trim();
|
|
71
|
+
if (!basename || basename === "." || basename === "..") return "download";
|
|
72
|
+
const extension = import_node_path.default.extname(basename).slice(0, 30);
|
|
73
|
+
let stem = basename.slice(0, basename.length - import_node_path.default.extname(basename).length) || "download";
|
|
74
|
+
while (Buffer.byteLength(`${stem}${extension}`) > 200) stem = stem.slice(0, -1);
|
|
75
|
+
return `${stem || "download"}${extension}`;
|
|
76
|
+
}
|
|
77
|
+
availableDownloadPath(suggestedFilename) {
|
|
78
|
+
const filename = this.safeDownloadFilename(suggestedFilename);
|
|
79
|
+
const extension = import_node_path.default.extname(filename);
|
|
80
|
+
const stem = filename.slice(0, filename.length - extension.length) || "download";
|
|
81
|
+
for (let suffix = 1; ; suffix += 1) {
|
|
82
|
+
const candidate = suffix === 1 ? filename : `${stem}-${suffix}${extension}`;
|
|
83
|
+
const candidatePath = import_node_path.default.join(this.downloadsPath, candidate);
|
|
84
|
+
if (!import_node_fs.default.existsSync(candidatePath)) return candidatePath;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async persistDownload(download) {
|
|
88
|
+
const failure = await download.failure();
|
|
89
|
+
if (failure) throw new Error(failure);
|
|
90
|
+
const targetPath = this.availableDownloadPath(download.suggestedFilename());
|
|
91
|
+
await download.saveAs(targetPath);
|
|
92
|
+
process.stdout.write(`[r5d-browser] downloaded ${import_node_path.default.basename(targetPath)}
|
|
93
|
+
`);
|
|
94
|
+
}
|
|
95
|
+
downloadId(filename) {
|
|
96
|
+
return Buffer.from(filename, "utf8").toString("base64url");
|
|
97
|
+
}
|
|
98
|
+
resolveDownload(downloadId) {
|
|
99
|
+
if (typeof downloadId !== "string" || !downloadId) throw new Error("downloadId is required.");
|
|
100
|
+
const filename = Buffer.from(downloadId, "base64url").toString("utf8");
|
|
101
|
+
if (this.downloadId(filename) !== downloadId || filename !== import_node_path.default.basename(filename) || filename.includes("\0")) {
|
|
102
|
+
throw new Error("Invalid downloadId.");
|
|
103
|
+
}
|
|
104
|
+
const filePath = import_node_path.default.join(this.downloadsPath, filename);
|
|
105
|
+
let stats;
|
|
106
|
+
try {
|
|
107
|
+
stats = import_node_fs.default.lstatSync(filePath);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(`Browser download ${downloadId} no longer exists.`);
|
|
110
|
+
}
|
|
111
|
+
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Browser download is not a regular file.");
|
|
112
|
+
return { filename, filePath, stats };
|
|
113
|
+
}
|
|
114
|
+
async listDownloads() {
|
|
115
|
+
await this.downloadSaveQueue;
|
|
116
|
+
return import_node_fs.default.readdirSync(this.downloadsPath, { withFileTypes: true }).flatMap((entry) => {
|
|
117
|
+
if (!entry.isFile() || entry.name.startsWith(".")) return [];
|
|
118
|
+
const { filename, stats } = this.resolveDownload(this.downloadId(entry.name));
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
downloadId: this.downloadId(filename),
|
|
122
|
+
filename,
|
|
123
|
+
size: stats.size,
|
|
124
|
+
modifiedAt: stats.mtime.toISOString()
|
|
125
|
+
}
|
|
126
|
+
];
|
|
127
|
+
}).sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt) || left.filename.localeCompare(right.filename));
|
|
128
|
+
}
|
|
129
|
+
async getDownloadChunk(input) {
|
|
130
|
+
await this.downloadSaveQueue;
|
|
131
|
+
const { filename, filePath, stats } = this.resolveDownload(input.downloadId);
|
|
132
|
+
const offset = input.offset === void 0 ? 0 : Number(input.offset);
|
|
133
|
+
const requestedBytes = input.maxBytes === void 0 ? MAX_DOWNLOAD_CHUNK_BYTES : Number(input.maxBytes);
|
|
134
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > stats.size) throw new Error("Invalid download offset.");
|
|
135
|
+
if (!Number.isSafeInteger(requestedBytes) || requestedBytes < 1) throw new Error("Invalid download chunk size.");
|
|
136
|
+
const chunkSize = Math.min(requestedBytes, MAX_DOWNLOAD_CHUNK_BYTES, stats.size - offset);
|
|
137
|
+
const bytes = Buffer.alloc(chunkSize);
|
|
138
|
+
const handle = import_node_fs.default.openSync(filePath, import_node_fs.default.constants.O_RDONLY | import_node_fs.default.constants.O_NOFOLLOW);
|
|
139
|
+
let bytesRead = 0;
|
|
140
|
+
try {
|
|
141
|
+
const openedStats = import_node_fs.default.fstatSync(handle);
|
|
142
|
+
if (!openedStats.isFile() || openedStats.dev !== stats.dev || openedStats.ino !== stats.ino) {
|
|
143
|
+
throw new Error("Browser download changed before it could be read.");
|
|
144
|
+
}
|
|
145
|
+
bytesRead = import_node_fs.default.readSync(handle, bytes, 0, chunkSize, offset);
|
|
146
|
+
const completedStats = import_node_fs.default.fstatSync(handle);
|
|
147
|
+
if (completedStats.size !== stats.size || completedStats.mtimeMs !== stats.mtimeMs) {
|
|
148
|
+
throw new Error("Browser download changed while it was being read.");
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
import_node_fs.default.closeSync(handle);
|
|
152
|
+
}
|
|
153
|
+
const nextOffset = offset + bytesRead;
|
|
154
|
+
return {
|
|
155
|
+
downloadId: this.downloadId(filename),
|
|
156
|
+
filename,
|
|
157
|
+
size: stats.size,
|
|
158
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
159
|
+
offset,
|
|
160
|
+
nextOffset,
|
|
161
|
+
eof: nextOffset === stats.size,
|
|
162
|
+
base64: bytes.subarray(0, bytesRead).toString("base64")
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
page(tabId) {
|
|
166
|
+
if (typeof tabId !== "string") throw new Error("tabId is required.");
|
|
167
|
+
const page = this.pages.get(tabId);
|
|
168
|
+
if (!page || page.isClosed()) throw new Error(`Browser tab ${tabId} is no longer open.`);
|
|
169
|
+
return page;
|
|
170
|
+
}
|
|
171
|
+
async tabInfo(page) {
|
|
172
|
+
const tabId = this.track(page);
|
|
173
|
+
let windowId = "unknown";
|
|
174
|
+
try {
|
|
175
|
+
const cdp = await this.context.newCDPSession(page);
|
|
176
|
+
const result = await cdp.send("Browser.getWindowForTarget");
|
|
177
|
+
windowId = String(result.windowId);
|
|
178
|
+
await cdp.detach();
|
|
179
|
+
} catch {
|
|
180
|
+
}
|
|
181
|
+
return { tabId, windowId, title: await page.title().catch(() => ""), url: page.url() };
|
|
182
|
+
}
|
|
183
|
+
async listTabs() {
|
|
184
|
+
return await Promise.all([...this.pages.values()].filter((page) => !page.isClosed()).map((page) => this.tabInfo(page)));
|
|
185
|
+
}
|
|
186
|
+
async drainDownloads() {
|
|
187
|
+
await this.downloadSaveQueue;
|
|
188
|
+
}
|
|
189
|
+
async execute(action, input) {
|
|
190
|
+
if (action === "list_tabs") return { tabs: await this.listTabs() };
|
|
191
|
+
if (action === "list_downloads") return { downloads: await this.listDownloads() };
|
|
192
|
+
if (action === "get_download") return await this.getDownloadChunk(input);
|
|
193
|
+
if (action === "open_tab") {
|
|
194
|
+
const previous = this.context.pages().findLast((candidate) => !candidate.isClosed());
|
|
195
|
+
let page2;
|
|
196
|
+
if (previous) {
|
|
197
|
+
const cdp = await this.context.newCDPSession(previous);
|
|
198
|
+
const pagePromise = this.context.waitForEvent("page");
|
|
199
|
+
await cdp.send("Target.createTarget", {
|
|
200
|
+
url: "about:blank",
|
|
201
|
+
newWindow: input.disposition === "window",
|
|
202
|
+
background: true
|
|
203
|
+
});
|
|
204
|
+
page2 = await pagePromise;
|
|
205
|
+
await cdp.detach();
|
|
206
|
+
if (input.disposition === "window") {
|
|
207
|
+
const pageCdp = await this.context.newCDPSession(page2);
|
|
208
|
+
const { windowId } = await pageCdp.send("Browser.getWindowForTarget");
|
|
209
|
+
await pageCdp.send("Browser.setWindowBounds", { windowId, bounds: { windowState: "minimized" } });
|
|
210
|
+
await pageCdp.detach();
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
page2 = await this.context.newPage();
|
|
214
|
+
}
|
|
215
|
+
const url = typeof input.url === "string" ? input.url : "about:blank";
|
|
216
|
+
if (url !== "about:blank") await page2.goto(url, { waitUntil: "domcontentloaded" });
|
|
217
|
+
return { tab: await this.tabInfo(page2) };
|
|
218
|
+
}
|
|
219
|
+
const page = this.page(input.tabId);
|
|
220
|
+
const tabId = input.tabId;
|
|
221
|
+
switch (action) {
|
|
222
|
+
case "close_tab":
|
|
223
|
+
await page.close();
|
|
224
|
+
return { closed: true, tabId };
|
|
225
|
+
case "navigate":
|
|
226
|
+
await page.goto(String(input.url), { waitUntil: "domcontentloaded" });
|
|
227
|
+
return { tab: await this.tabInfo(page) };
|
|
228
|
+
case "screenshot": {
|
|
229
|
+
const bytes = await page.screenshot({ type: "png" });
|
|
230
|
+
const viewport = page.viewportSize();
|
|
231
|
+
const cursor = this.cursor.get(tabId);
|
|
232
|
+
return {
|
|
233
|
+
base64: bytes.toString("base64"),
|
|
234
|
+
width: viewport?.width ?? Number(await page.evaluate(() => window.innerWidth)),
|
|
235
|
+
height: viewport?.height ?? Number(await page.evaluate(() => window.innerHeight)),
|
|
236
|
+
cursorX: cursor?.x,
|
|
237
|
+
cursorY: cursor?.y
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
case "move_mouse": {
|
|
241
|
+
const x = Number(input.x);
|
|
242
|
+
const y = Number(input.y);
|
|
243
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("move_mouse requires numeric x and y coordinates.");
|
|
244
|
+
await page.mouse.move(x, y);
|
|
245
|
+
this.cursor.set(tabId, { x, y });
|
|
246
|
+
return { tabId, x, y };
|
|
247
|
+
}
|
|
248
|
+
case "mouse_click": {
|
|
249
|
+
const cursor = this.cursor.get(tabId);
|
|
250
|
+
if (!cursor) throw new Error("Move the mouse in this tab before clicking.");
|
|
251
|
+
const button = input.button === "middle" || input.button === "right" ? input.button : "left";
|
|
252
|
+
await page.mouse.click(cursor.x, cursor.y, { button, clickCount: input.clickCount === 2 ? 2 : 1 });
|
|
253
|
+
return { tabId, ...cursor, button };
|
|
254
|
+
}
|
|
255
|
+
case "keyboard": {
|
|
256
|
+
const keys = Array.isArray(input.keys) ? input.keys : [];
|
|
257
|
+
const modifiers = Array.isArray(input.modifiers) ? input.modifiers.filter((value) => typeof value === "string") : [];
|
|
258
|
+
for (const modifier of modifiers) await page.keyboard.down(modifier);
|
|
259
|
+
try {
|
|
260
|
+
for (const key of keys) {
|
|
261
|
+
if (typeof key !== "string") throw new Error("keyboard keys must all be strings.");
|
|
262
|
+
await page.keyboard.press(key);
|
|
263
|
+
}
|
|
264
|
+
} finally {
|
|
265
|
+
for (const modifier of modifiers.toReversed()) await page.keyboard.up(modifier);
|
|
266
|
+
}
|
|
267
|
+
return { tabId, keys, modifiers };
|
|
268
|
+
}
|
|
269
|
+
case "keyboard_type":
|
|
270
|
+
if (typeof input.text !== "string") throw new Error("keyboard_type requires text.");
|
|
271
|
+
await page.keyboard.insertText(input.text);
|
|
272
|
+
return { tabId, characters: input.text.length };
|
|
273
|
+
case "run_js": {
|
|
274
|
+
if (typeof input.code !== "string") throw new Error("run_js requires code.");
|
|
275
|
+
const result = await page.evaluate(async (code) => {
|
|
276
|
+
const invoke = new Function(`"use strict"; return (async () => {
|
|
277
|
+
${code}
|
|
278
|
+
})()`);
|
|
279
|
+
return await invoke();
|
|
280
|
+
}, input.code);
|
|
281
|
+
const serialized = JSON.stringify(result);
|
|
282
|
+
if (serialized && Buffer.byteLength(serialized) > 1024 * 1024) {
|
|
283
|
+
throw new Error("Browser JavaScript result exceeded the 1 MiB limit.");
|
|
284
|
+
}
|
|
285
|
+
return { tabId, result };
|
|
286
|
+
}
|
|
287
|
+
default:
|
|
288
|
+
throw new Error(`Unsupported browser operation: ${action}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
293
|
+
0 && (module.exports = {
|
|
294
|
+
BrowserRuntime
|
|
295
|
+
});
|