@ricsam/r5d-browser 0.0.0 → 0.0.44

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 CHANGED
@@ -1,45 +1,10 @@
1
- # @ricsam/r5d-browser
1
+ # r5d-browser
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ `r5d-browser` starts a dedicated, persistent Chromium profile on a Mac and connects it to r5d.dev. Sign in to sites in the opened browser normally; agents use Chromium's automation protocol without moving the real system pointer or taking keyboard focus.
4
4
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
5
+ ```sh
6
+ npm install -g @ricsam/r5d-browser
7
+ r5d-browser start
8
+ ```
6
9
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
8
-
9
- ## Purpose
10
-
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@ricsam/r5d-browser`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
15
-
16
- ## What is OIDC Trusted Publishing?
17
-
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
19
-
20
- ## Setup Instructions
21
-
22
- To properly configure OIDC trusted publishing for this package:
23
-
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
28
-
29
- ## DO NOT USE THIS PACKAGE
30
-
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
36
-
37
- ## More Information
38
-
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
42
-
43
- ---
44
-
45
- **Maintained for OIDC setup purposes only**
10
+ The profile is stored under `~/.r5d/browser/profile`. Browser screenshots are session artifacts in r5d.dev and are not retained by the browser process.
@@ -0,0 +1,440 @@
1
+ #!/usr/bin/env bun
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+ var import_node_fs = __toESM(require("node:fs"), 1);
26
+ var import_node_os = __toESM(require("node:os"), 1);
27
+ var import_node_path = __toESM(require("node:path"), 1);
28
+ var import_node_child_process = require("node:child_process");
29
+ var import_promises = require("node:timers/promises");
30
+ var import_ws = __toESM(require("ws"), 1);
31
+ var import_playwright_install = require("./playwright-install.cjs");
32
+ const DEFAULT_BASE_URL = "https://r5d.dev";
33
+ const PACKAGE_NAME = "@ricsam/r5d-browser";
34
+ const RECONNECT_DELAY_MS = 2e3;
35
+ const SERVER_HEARTBEAT_TIMEOUT_MS = 45e3;
36
+ function browserRoot() {
37
+ return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "browser");
38
+ }
39
+ function defaultConfigPath() {
40
+ return import_node_path.default.join(import_node_os.default.homedir(), ".config", "r5d", "r5dctl", "config.json");
41
+ }
42
+ function printHelp() {
43
+ process.stdout.write(`Usage:
44
+ r5d-browser start [--base-url <url>] [--token <token>] [--api-key <key>]
45
+ r5d-browser install
46
+ r5d-browser --version
47
+
48
+ Options:
49
+ --base-url <url> r5d.dev base URL (default from r5dctl config or ${DEFAULT_BASE_URL})
50
+ --token <token> Interactive r5d token
51
+ --api-key <key> r5d API key
52
+ --config <path> Shared r5dctl config path
53
+ --profile <path> Chromium profile path (default ~/.r5d/browser/profile)
54
+ --browsers-path <dir> Playwright browser binaries (default ~/.r5d/browser/browsers)
55
+ -v, --version Show r5d-browser version
56
+ -h, --help Show this help
57
+ `);
58
+ }
59
+ function findVersion() {
60
+ let current = process.argv[1] ? import_node_path.default.dirname(import_node_path.default.resolve(process.argv[1])) : process.cwd();
61
+ for (let index = 0; index < 12; index += 1) {
62
+ try {
63
+ const parsed = JSON.parse(import_node_fs.default.readFileSync(import_node_path.default.join(current, "package.json"), "utf8"));
64
+ if (parsed.name === PACKAGE_NAME && parsed.version) return parsed.version;
65
+ } catch {
66
+ }
67
+ try {
68
+ const sourceVersion = import_node_fs.default.readFileSync(import_node_path.default.join(current, "VERSION.txt"), "utf8").trim();
69
+ if (sourceVersion && import_node_path.default.basename(current) === "packages") return sourceVersion;
70
+ } catch {
71
+ }
72
+ const parent = import_node_path.default.dirname(current);
73
+ if (parent === current) break;
74
+ current = parent;
75
+ }
76
+ return "unknown";
77
+ }
78
+ function parseArgs(argv) {
79
+ const options = { configPath: defaultConfigPath(), help: false, version: false };
80
+ const rest = [];
81
+ for (let index = 0; index < argv.length; index += 1) {
82
+ const arg = argv[index];
83
+ if (!arg) continue;
84
+ if (arg === "-h" || arg === "--help") {
85
+ options.help = true;
86
+ continue;
87
+ }
88
+ if (arg === "-v" || arg === "--version") {
89
+ options.version = true;
90
+ continue;
91
+ }
92
+ const readOption = (name) => {
93
+ if (arg === name) {
94
+ index += 1;
95
+ const value = argv[index];
96
+ if (!value) throw new Error(`Missing value for ${name}`);
97
+ return value;
98
+ }
99
+ return arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : void 0;
100
+ };
101
+ const entries = [
102
+ ["--base-url", "baseUrl"],
103
+ ["--token", "token"],
104
+ ["--api-key", "apiKey"],
105
+ ["--config", "configPath"],
106
+ ["--profile", "profilePath"],
107
+ ["--browsers-path", "browsersPath"]
108
+ ];
109
+ let matched = false;
110
+ for (const [name, key] of entries) {
111
+ const value = readOption(name);
112
+ if (value !== void 0) {
113
+ options[key] = value;
114
+ matched = true;
115
+ break;
116
+ }
117
+ }
118
+ if (!matched) rest.push(arg);
119
+ }
120
+ if (options.version) return { command: "version", options };
121
+ if (options.help || rest.length === 0) return { command: "help", options };
122
+ if (rest[0] !== "start" && rest[0] !== "install") throw new Error(`Unknown command: ${rest[0]}`);
123
+ return { command: rest[0], options };
124
+ }
125
+ function readConfig(configPath) {
126
+ if (!import_node_fs.default.existsSync(configPath)) return {};
127
+ const parsed = JSON.parse(import_node_fs.default.readFileSync(configPath, "utf8"));
128
+ return {
129
+ baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : void 0,
130
+ token: typeof parsed.token === "string" ? parsed.token : void 0,
131
+ apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : void 0
132
+ };
133
+ }
134
+ function resolveConnection(options) {
135
+ const config = readConfig(options.configPath);
136
+ const token = options.token ?? process.env.R5D_BROWSER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.R5DCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.R5DCTL_API_KEY ?? config.apiKey;
137
+ if (!token) throw new Error("Authentication required. Run `r5dctl auth login` or pass --token/--api-key.");
138
+ return {
139
+ baseUrl: (options.baseUrl ?? process.env.R5D_BASE_URL ?? process.env.R5DCTL_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL).replace(
140
+ /\/+$/,
141
+ ""
142
+ ),
143
+ token
144
+ };
145
+ }
146
+ function installChromium(browsersPath) {
147
+ import_node_fs.default.mkdirSync(browsersPath, { recursive: true });
148
+ process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
149
+ const cliPath = (0, import_playwright_install.resolvePlaywrightCliPath)();
150
+ process.stdout.write(`[r5d-browser] Installing Chromium under ${browsersPath}...
151
+ `);
152
+ const result = (0, import_node_child_process.spawnSync)(process.execPath, [cliPath, "install", "chromium"], {
153
+ stdio: "inherit",
154
+ env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browsersPath }
155
+ });
156
+ if (result.status !== 0) throw new Error(`Chromium installation failed with exit code ${result.status ?? "unknown"}.`);
157
+ }
158
+ function loadInstanceId() {
159
+ const instancePath = import_node_path.default.join(browserRoot(), "instance-id");
160
+ import_node_fs.default.mkdirSync(import_node_path.default.dirname(instancePath), { recursive: true });
161
+ try {
162
+ const id2 = import_node_fs.default.readFileSync(instancePath, "utf8").trim();
163
+ if (id2) return id2;
164
+ } catch {
165
+ }
166
+ const id = crypto.randomUUID();
167
+ import_node_fs.default.writeFileSync(instancePath, `${id}
168
+ `, { mode: 384 });
169
+ return id;
170
+ }
171
+ class BrowserRuntime {
172
+ constructor(context) {
173
+ this.context = context;
174
+ for (const page of context.pages()) this.track(page);
175
+ context.on("page", (page) => this.track(page));
176
+ }
177
+ context;
178
+ ids = /* @__PURE__ */ new WeakMap();
179
+ pages = /* @__PURE__ */ new Map();
180
+ cursor = /* @__PURE__ */ new Map();
181
+ track(page) {
182
+ const existing = this.ids.get(page);
183
+ if (existing) return existing;
184
+ const id = crypto.randomUUID();
185
+ this.ids.set(page, id);
186
+ this.pages.set(id, page);
187
+ page.once("close", () => {
188
+ this.pages.delete(id);
189
+ this.cursor.delete(id);
190
+ });
191
+ return id;
192
+ }
193
+ page(tabId) {
194
+ if (typeof tabId !== "string") throw new Error("tabId is required.");
195
+ const page = this.pages.get(tabId);
196
+ if (!page || page.isClosed()) throw new Error(`Browser tab ${tabId} is no longer open.`);
197
+ return page;
198
+ }
199
+ async tabInfo(page) {
200
+ const tabId = this.track(page);
201
+ let windowId = "unknown";
202
+ try {
203
+ const cdp = await this.context.newCDPSession(page);
204
+ const result = await cdp.send("Browser.getWindowForTarget");
205
+ windowId = String(result.windowId);
206
+ await cdp.detach();
207
+ } catch {
208
+ }
209
+ return { tabId, windowId, title: await page.title().catch(() => ""), url: page.url() };
210
+ }
211
+ async listTabs() {
212
+ return await Promise.all([...this.pages.values()].filter((page) => !page.isClosed()).map((page) => this.tabInfo(page)));
213
+ }
214
+ async execute(action, input) {
215
+ if (action === "list_tabs") return { tabs: await this.listTabs() };
216
+ if (action === "open_tab") {
217
+ const previous = this.context.pages().findLast((candidate) => !candidate.isClosed());
218
+ let page2;
219
+ if (previous) {
220
+ const cdp = await this.context.newCDPSession(previous);
221
+ const pagePromise = this.context.waitForEvent("page");
222
+ await cdp.send("Target.createTarget", {
223
+ url: "about:blank",
224
+ newWindow: input.disposition === "window",
225
+ background: true
226
+ });
227
+ page2 = await pagePromise;
228
+ await cdp.detach();
229
+ if (input.disposition === "window") {
230
+ const pageCdp = await this.context.newCDPSession(page2);
231
+ const { windowId } = await pageCdp.send("Browser.getWindowForTarget");
232
+ await pageCdp.send("Browser.setWindowBounds", { windowId, bounds: { windowState: "minimized" } });
233
+ await pageCdp.detach();
234
+ }
235
+ } else {
236
+ page2 = await this.context.newPage();
237
+ }
238
+ const url = typeof input.url === "string" ? input.url : "about:blank";
239
+ if (url !== "about:blank") await page2.goto(url, { waitUntil: "domcontentloaded" });
240
+ return { tab: await this.tabInfo(page2) };
241
+ }
242
+ const page = this.page(input.tabId);
243
+ const tabId = input.tabId;
244
+ switch (action) {
245
+ case "close_tab":
246
+ await page.close();
247
+ return { closed: true, tabId };
248
+ case "navigate":
249
+ await page.goto(String(input.url), { waitUntil: "domcontentloaded" });
250
+ return { tab: await this.tabInfo(page) };
251
+ case "screenshot": {
252
+ const bytes = await page.screenshot({ type: "png" });
253
+ const viewport = page.viewportSize();
254
+ const cursor = this.cursor.get(tabId);
255
+ return {
256
+ base64: bytes.toString("base64"),
257
+ width: viewport?.width ?? Number(await page.evaluate(() => window.innerWidth)),
258
+ height: viewport?.height ?? Number(await page.evaluate(() => window.innerHeight)),
259
+ cursorX: cursor?.x,
260
+ cursorY: cursor?.y
261
+ };
262
+ }
263
+ case "move_mouse": {
264
+ const x = Number(input.x);
265
+ const y = Number(input.y);
266
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("move_mouse requires numeric x and y coordinates.");
267
+ await page.mouse.move(x, y);
268
+ this.cursor.set(tabId, { x, y });
269
+ return { tabId, x, y };
270
+ }
271
+ case "mouse_click": {
272
+ const cursor = this.cursor.get(tabId);
273
+ if (!cursor) throw new Error("Move the mouse in this tab before clicking.");
274
+ const button = input.button === "middle" || input.button === "right" ? input.button : "left";
275
+ await page.mouse.click(cursor.x, cursor.y, { button, clickCount: input.clickCount === 2 ? 2 : 1 });
276
+ return { tabId, ...cursor, button };
277
+ }
278
+ case "keyboard": {
279
+ const keys = Array.isArray(input.keys) ? input.keys : [];
280
+ const modifiers = Array.isArray(input.modifiers) ? input.modifiers.filter((value) => typeof value === "string") : [];
281
+ for (const modifier of modifiers) await page.keyboard.down(modifier);
282
+ try {
283
+ for (const key of keys) {
284
+ if (typeof key !== "string") throw new Error("keyboard keys must all be strings.");
285
+ await page.keyboard.press(key);
286
+ }
287
+ } finally {
288
+ for (const modifier of modifiers.toReversed()) await page.keyboard.up(modifier);
289
+ }
290
+ return { tabId, keys, modifiers };
291
+ }
292
+ case "keyboard_type":
293
+ if (typeof input.text !== "string") throw new Error("keyboard_type requires text.");
294
+ await page.keyboard.insertText(input.text);
295
+ return { tabId, characters: input.text.length };
296
+ case "run_js": {
297
+ if (typeof input.code !== "string") throw new Error("run_js requires code.");
298
+ const result = await page.evaluate(async (code) => {
299
+ const invoke = new Function(`"use strict"; return (async () => {
300
+ ${code}
301
+ })()`);
302
+ return await invoke();
303
+ }, input.code);
304
+ const serialized = JSON.stringify(result);
305
+ if (serialized && Buffer.byteLength(serialized) > 1024 * 1024) {
306
+ throw new Error("Browser JavaScript result exceeded the 1 MiB limit.");
307
+ }
308
+ return { tabId, result };
309
+ }
310
+ default:
311
+ throw new Error(`Unsupported browser operation: ${action}`);
312
+ }
313
+ }
314
+ }
315
+ async function connectLoop(params) {
316
+ for (; ; ) {
317
+ const url = new URL("/browser/ws", params.baseUrl);
318
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
319
+ url.searchParams.set("instance", params.instanceId);
320
+ url.searchParams.set("version", findVersion());
321
+ let socket = null;
322
+ try {
323
+ socket = new import_ws.default(url, { headers: { Authorization: `Bearer ${params.token}` } });
324
+ await new Promise((resolve, reject) => {
325
+ socket.addEventListener("open", () => resolve(), { once: true });
326
+ socket.addEventListener("error", () => reject(new Error("WebSocket connection failed.")), { once: true });
327
+ });
328
+ process.stdout.write(`[r5d-browser] connected to ${params.baseUrl}
329
+ `);
330
+ socket.send(
331
+ JSON.stringify({
332
+ type: "hello",
333
+ hostInfo: {
334
+ hostname: import_node_os.default.hostname(),
335
+ platform: process.platform,
336
+ arch: process.arch,
337
+ version: findVersion(),
338
+ chromiumVersion: params.chromiumVersion,
339
+ profilePath: params.profilePath
340
+ },
341
+ tabs: await params.runtime.listTabs()
342
+ })
343
+ );
344
+ let lastServerMessageAt = Date.now();
345
+ const watchdog = setInterval(() => {
346
+ if (Date.now() - lastServerMessageAt >= SERVER_HEARTBEAT_TIMEOUT_MS) socket?.close();
347
+ }, 5e3);
348
+ await new Promise((resolve) => {
349
+ socket.addEventListener("message", (event) => {
350
+ lastServerMessageAt = Date.now();
351
+ void (async () => {
352
+ const message = JSON.parse(String(event.data));
353
+ if (message.type === "ping") {
354
+ socket?.send(JSON.stringify({ type: "pong" }));
355
+ return;
356
+ }
357
+ if (message.type !== "operation") return;
358
+ try {
359
+ const beforeTabs = await params.runtime.listTabs();
360
+ const operationResult = await params.runtime.execute(message.action, message.input);
361
+ const tabs = await params.runtime.listTabs();
362
+ const beforeIds = new Set(beforeTabs.map((tab) => tab.tabId));
363
+ const openedTabs = tabs.filter((tab) => !beforeIds.has(tab.tabId));
364
+ const result = operationResult && typeof operationResult === "object" ? { ...operationResult, openedTabs } : { result: operationResult, openedTabs };
365
+ socket?.send(JSON.stringify({ type: "operation_result", requestId: message.requestId, result }));
366
+ socket?.send(JSON.stringify({ type: "tabs_snapshot", tabs }));
367
+ } catch (error) {
368
+ socket?.send(
369
+ JSON.stringify({
370
+ type: "operation_result",
371
+ requestId: message.requestId,
372
+ error: error instanceof Error ? error.message : String(error)
373
+ })
374
+ );
375
+ }
376
+ })();
377
+ });
378
+ socket.addEventListener("close", () => resolve(), { once: true });
379
+ });
380
+ clearInterval(watchdog);
381
+ } catch (error) {
382
+ process.stderr.write(`[r5d-browser] ${error instanceof Error ? error.message : String(error)}
383
+ `);
384
+ } finally {
385
+ socket?.close();
386
+ }
387
+ process.stdout.write(`[r5d-browser] disconnected; reconnecting...
388
+ `);
389
+ await (0, import_promises.setTimeout)(RECONNECT_DELAY_MS);
390
+ }
391
+ }
392
+ async function start(options) {
393
+ if (process.platform !== "darwin") throw new Error("r5d-browser currently supports macOS only.");
394
+ const { baseUrl, token } = resolveConnection(options);
395
+ const browsersPath = import_node_path.default.resolve(options.browsersPath ?? import_node_path.default.join(browserRoot(), "browsers"));
396
+ const profilePath = import_node_path.default.resolve(options.profilePath ?? import_node_path.default.join(browserRoot(), "profile"));
397
+ process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
398
+ let playwright = await import("playwright");
399
+ if (!import_node_fs.default.existsSync(playwright.chromium.executablePath())) {
400
+ installChromium(browsersPath);
401
+ playwright = await import("playwright");
402
+ }
403
+ import_node_fs.default.mkdirSync(profilePath, { recursive: true });
404
+ const context = await playwright.chromium.launchPersistentContext(profilePath, {
405
+ headless: false,
406
+ viewport: { width: 1440, height: 900 },
407
+ acceptDownloads: true,
408
+ downloadsPath: import_node_path.default.join(browserRoot(), "downloads")
409
+ });
410
+ if (context.pages().length === 0) await context.newPage();
411
+ const runtime = new BrowserRuntime(context);
412
+ process.stdout.write(`[r5d-browser] profile: ${profilePath}
413
+ `);
414
+ process.stdout.write(`[r5d-browser] agents use synthetic Chromium input; your system pointer and keyboard stay untouched.
415
+ `);
416
+ process.on("SIGINT", () => void context.close().finally(() => process.exit(0)));
417
+ process.on("SIGTERM", () => void context.close().finally(() => process.exit(0)));
418
+ return connectLoop({
419
+ runtime,
420
+ baseUrl,
421
+ token,
422
+ instanceId: loadInstanceId(),
423
+ profilePath,
424
+ chromiumVersion: context.browser()?.version() ?? "unknown"
425
+ });
426
+ }
427
+ async function main() {
428
+ const { command, options } = parseArgs(process.argv.slice(2));
429
+ if (command === "help") return printHelp();
430
+ if (command === "version") return void process.stdout.write(`r5d-browser ${findVersion()}
431
+ `);
432
+ const browsersPath = import_node_path.default.resolve(options.browsersPath ?? import_node_path.default.join(browserRoot(), "browsers"));
433
+ if (command === "install") return installChromium(browsersPath);
434
+ await start(options);
435
+ }
436
+ main().catch((error) => {
437
+ process.stderr.write(`r5d-browser: ${error instanceof Error ? error.message : String(error)}
438
+ `);
439
+ process.exit(1);
440
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "@ricsam/r5d-browser",
3
+ "version": "0.0.44",
4
+ "type": "commonjs"
5
+ }
@@ -0,0 +1,49 @@
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 playwright_install_exports = {};
30
+ __export(playwright_install_exports, {
31
+ resolvePlaywrightCliPath: () => resolvePlaywrightCliPath
32
+ });
33
+ module.exports = __toCommonJS(playwright_install_exports);
34
+ var import_node_fs = __toESM(require("node:fs"), 1);
35
+ var import_node_path = __toESM(require("node:path"), 1);
36
+ var import_node_module = require("node:module");
37
+ function resolvePlaywrightCliPath(fromPath = process.argv[1] ?? import_node_path.default.join(process.cwd(), "package.json")) {
38
+ const require2 = (0, import_node_module.createRequire)(fromPath);
39
+ const packageJsonPath = require2.resolve("playwright/package.json");
40
+ const cliPath = import_node_path.default.join(import_node_path.default.dirname(packageJsonPath), "cli.js");
41
+ if (!import_node_fs.default.existsSync(cliPath)) {
42
+ throw new Error(`The Playwright CLI was not found next to ${packageJsonPath}. Reinstall @ricsam/r5d-browser.`);
43
+ }
44
+ return cliPath;
45
+ }
46
+ // Annotate the CommonJS export names for ESM import in node:
47
+ 0 && (module.exports = {
48
+ resolvePlaywrightCliPath
49
+ });
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env bun
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import WebSocket from "ws";
8
+ import { resolvePlaywrightCliPath } from "./playwright-install.mjs";
9
+ const DEFAULT_BASE_URL = "https://r5d.dev";
10
+ const PACKAGE_NAME = "@ricsam/r5d-browser";
11
+ const RECONNECT_DELAY_MS = 2e3;
12
+ const SERVER_HEARTBEAT_TIMEOUT_MS = 45e3;
13
+ function browserRoot() {
14
+ return path.join(os.homedir(), ".r5d", "browser");
15
+ }
16
+ function defaultConfigPath() {
17
+ return path.join(os.homedir(), ".config", "r5d", "r5dctl", "config.json");
18
+ }
19
+ function printHelp() {
20
+ process.stdout.write(`Usage:
21
+ r5d-browser start [--base-url <url>] [--token <token>] [--api-key <key>]
22
+ r5d-browser install
23
+ r5d-browser --version
24
+
25
+ Options:
26
+ --base-url <url> r5d.dev base URL (default from r5dctl config or ${DEFAULT_BASE_URL})
27
+ --token <token> Interactive r5d token
28
+ --api-key <key> r5d API key
29
+ --config <path> Shared r5dctl config path
30
+ --profile <path> Chromium profile path (default ~/.r5d/browser/profile)
31
+ --browsers-path <dir> Playwright browser binaries (default ~/.r5d/browser/browsers)
32
+ -v, --version Show r5d-browser version
33
+ -h, --help Show this help
34
+ `);
35
+ }
36
+ function findVersion() {
37
+ let current = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : process.cwd();
38
+ for (let index = 0; index < 12; index += 1) {
39
+ try {
40
+ const parsed = JSON.parse(fs.readFileSync(path.join(current, "package.json"), "utf8"));
41
+ if (parsed.name === PACKAGE_NAME && parsed.version) return parsed.version;
42
+ } catch {
43
+ }
44
+ try {
45
+ const sourceVersion = fs.readFileSync(path.join(current, "VERSION.txt"), "utf8").trim();
46
+ if (sourceVersion && path.basename(current) === "packages") return sourceVersion;
47
+ } catch {
48
+ }
49
+ const parent = path.dirname(current);
50
+ if (parent === current) break;
51
+ current = parent;
52
+ }
53
+ return "unknown";
54
+ }
55
+ function parseArgs(argv) {
56
+ const options = { configPath: defaultConfigPath(), help: false, version: false };
57
+ const rest = [];
58
+ for (let index = 0; index < argv.length; index += 1) {
59
+ const arg = argv[index];
60
+ if (!arg) continue;
61
+ if (arg === "-h" || arg === "--help") {
62
+ options.help = true;
63
+ continue;
64
+ }
65
+ if (arg === "-v" || arg === "--version") {
66
+ options.version = true;
67
+ continue;
68
+ }
69
+ const readOption = (name) => {
70
+ if (arg === name) {
71
+ index += 1;
72
+ const value = argv[index];
73
+ if (!value) throw new Error(`Missing value for ${name}`);
74
+ return value;
75
+ }
76
+ return arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : void 0;
77
+ };
78
+ const entries = [
79
+ ["--base-url", "baseUrl"],
80
+ ["--token", "token"],
81
+ ["--api-key", "apiKey"],
82
+ ["--config", "configPath"],
83
+ ["--profile", "profilePath"],
84
+ ["--browsers-path", "browsersPath"]
85
+ ];
86
+ let matched = false;
87
+ for (const [name, key] of entries) {
88
+ const value = readOption(name);
89
+ if (value !== void 0) {
90
+ options[key] = value;
91
+ matched = true;
92
+ break;
93
+ }
94
+ }
95
+ if (!matched) rest.push(arg);
96
+ }
97
+ if (options.version) return { command: "version", options };
98
+ if (options.help || rest.length === 0) return { command: "help", options };
99
+ if (rest[0] !== "start" && rest[0] !== "install") throw new Error(`Unknown command: ${rest[0]}`);
100
+ return { command: rest[0], options };
101
+ }
102
+ function readConfig(configPath) {
103
+ if (!fs.existsSync(configPath)) return {};
104
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
105
+ return {
106
+ baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : void 0,
107
+ token: typeof parsed.token === "string" ? parsed.token : void 0,
108
+ apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : void 0
109
+ };
110
+ }
111
+ function resolveConnection(options) {
112
+ const config = readConfig(options.configPath);
113
+ const token = options.token ?? process.env.R5D_BROWSER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.R5DCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.R5DCTL_API_KEY ?? config.apiKey;
114
+ if (!token) throw new Error("Authentication required. Run `r5dctl auth login` or pass --token/--api-key.");
115
+ return {
116
+ baseUrl: (options.baseUrl ?? process.env.R5D_BASE_URL ?? process.env.R5DCTL_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL).replace(
117
+ /\/+$/,
118
+ ""
119
+ ),
120
+ token
121
+ };
122
+ }
123
+ function installChromium(browsersPath) {
124
+ fs.mkdirSync(browsersPath, { recursive: true });
125
+ process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
126
+ const cliPath = resolvePlaywrightCliPath();
127
+ process.stdout.write(`[r5d-browser] Installing Chromium under ${browsersPath}...
128
+ `);
129
+ const result = spawnSync(process.execPath, [cliPath, "install", "chromium"], {
130
+ stdio: "inherit",
131
+ env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browsersPath }
132
+ });
133
+ if (result.status !== 0) throw new Error(`Chromium installation failed with exit code ${result.status ?? "unknown"}.`);
134
+ }
135
+ function loadInstanceId() {
136
+ const instancePath = path.join(browserRoot(), "instance-id");
137
+ fs.mkdirSync(path.dirname(instancePath), { recursive: true });
138
+ try {
139
+ const id2 = fs.readFileSync(instancePath, "utf8").trim();
140
+ if (id2) return id2;
141
+ } catch {
142
+ }
143
+ const id = crypto.randomUUID();
144
+ fs.writeFileSync(instancePath, `${id}
145
+ `, { mode: 384 });
146
+ return id;
147
+ }
148
+ class BrowserRuntime {
149
+ constructor(context) {
150
+ this.context = context;
151
+ for (const page of context.pages()) this.track(page);
152
+ context.on("page", (page) => this.track(page));
153
+ }
154
+ context;
155
+ ids = /* @__PURE__ */ new WeakMap();
156
+ pages = /* @__PURE__ */ new Map();
157
+ cursor = /* @__PURE__ */ new Map();
158
+ track(page) {
159
+ const existing = this.ids.get(page);
160
+ if (existing) return existing;
161
+ const id = crypto.randomUUID();
162
+ this.ids.set(page, id);
163
+ this.pages.set(id, page);
164
+ page.once("close", () => {
165
+ this.pages.delete(id);
166
+ this.cursor.delete(id);
167
+ });
168
+ return id;
169
+ }
170
+ page(tabId) {
171
+ if (typeof tabId !== "string") throw new Error("tabId is required.");
172
+ const page = this.pages.get(tabId);
173
+ if (!page || page.isClosed()) throw new Error(`Browser tab ${tabId} is no longer open.`);
174
+ return page;
175
+ }
176
+ async tabInfo(page) {
177
+ const tabId = this.track(page);
178
+ let windowId = "unknown";
179
+ try {
180
+ const cdp = await this.context.newCDPSession(page);
181
+ const result = await cdp.send("Browser.getWindowForTarget");
182
+ windowId = String(result.windowId);
183
+ await cdp.detach();
184
+ } catch {
185
+ }
186
+ return { tabId, windowId, title: await page.title().catch(() => ""), url: page.url() };
187
+ }
188
+ async listTabs() {
189
+ return await Promise.all([...this.pages.values()].filter((page) => !page.isClosed()).map((page) => this.tabInfo(page)));
190
+ }
191
+ async execute(action, input) {
192
+ if (action === "list_tabs") return { tabs: await this.listTabs() };
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
+ async function connectLoop(params) {
293
+ for (; ; ) {
294
+ const url = new URL("/browser/ws", params.baseUrl);
295
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
296
+ url.searchParams.set("instance", params.instanceId);
297
+ url.searchParams.set("version", findVersion());
298
+ let socket = null;
299
+ try {
300
+ socket = new WebSocket(url, { headers: { Authorization: `Bearer ${params.token}` } });
301
+ await new Promise((resolve, reject) => {
302
+ socket.addEventListener("open", () => resolve(), { once: true });
303
+ socket.addEventListener("error", () => reject(new Error("WebSocket connection failed.")), { once: true });
304
+ });
305
+ process.stdout.write(`[r5d-browser] connected to ${params.baseUrl}
306
+ `);
307
+ socket.send(
308
+ JSON.stringify({
309
+ type: "hello",
310
+ hostInfo: {
311
+ hostname: os.hostname(),
312
+ platform: process.platform,
313
+ arch: process.arch,
314
+ version: findVersion(),
315
+ chromiumVersion: params.chromiumVersion,
316
+ profilePath: params.profilePath
317
+ },
318
+ tabs: await params.runtime.listTabs()
319
+ })
320
+ );
321
+ let lastServerMessageAt = Date.now();
322
+ const watchdog = setInterval(() => {
323
+ if (Date.now() - lastServerMessageAt >= SERVER_HEARTBEAT_TIMEOUT_MS) socket?.close();
324
+ }, 5e3);
325
+ await new Promise((resolve) => {
326
+ socket.addEventListener("message", (event) => {
327
+ lastServerMessageAt = Date.now();
328
+ void (async () => {
329
+ const message = JSON.parse(String(event.data));
330
+ if (message.type === "ping") {
331
+ socket?.send(JSON.stringify({ type: "pong" }));
332
+ return;
333
+ }
334
+ if (message.type !== "operation") return;
335
+ try {
336
+ const beforeTabs = await params.runtime.listTabs();
337
+ const operationResult = await params.runtime.execute(message.action, message.input);
338
+ const tabs = await params.runtime.listTabs();
339
+ const beforeIds = new Set(beforeTabs.map((tab) => tab.tabId));
340
+ const openedTabs = tabs.filter((tab) => !beforeIds.has(tab.tabId));
341
+ const result = operationResult && typeof operationResult === "object" ? { ...operationResult, openedTabs } : { result: operationResult, openedTabs };
342
+ socket?.send(JSON.stringify({ type: "operation_result", requestId: message.requestId, result }));
343
+ socket?.send(JSON.stringify({ type: "tabs_snapshot", tabs }));
344
+ } catch (error) {
345
+ socket?.send(
346
+ JSON.stringify({
347
+ type: "operation_result",
348
+ requestId: message.requestId,
349
+ error: error instanceof Error ? error.message : String(error)
350
+ })
351
+ );
352
+ }
353
+ })();
354
+ });
355
+ socket.addEventListener("close", () => resolve(), { once: true });
356
+ });
357
+ clearInterval(watchdog);
358
+ } catch (error) {
359
+ process.stderr.write(`[r5d-browser] ${error instanceof Error ? error.message : String(error)}
360
+ `);
361
+ } finally {
362
+ socket?.close();
363
+ }
364
+ process.stdout.write(`[r5d-browser] disconnected; reconnecting...
365
+ `);
366
+ await sleep(RECONNECT_DELAY_MS);
367
+ }
368
+ }
369
+ async function start(options) {
370
+ if (process.platform !== "darwin") throw new Error("r5d-browser currently supports macOS only.");
371
+ const { baseUrl, token } = resolveConnection(options);
372
+ const browsersPath = path.resolve(options.browsersPath ?? path.join(browserRoot(), "browsers"));
373
+ const profilePath = path.resolve(options.profilePath ?? path.join(browserRoot(), "profile"));
374
+ process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
375
+ let playwright = await import("playwright");
376
+ if (!fs.existsSync(playwright.chromium.executablePath())) {
377
+ installChromium(browsersPath);
378
+ playwright = await import("playwright");
379
+ }
380
+ fs.mkdirSync(profilePath, { recursive: true });
381
+ const context = await playwright.chromium.launchPersistentContext(profilePath, {
382
+ headless: false,
383
+ viewport: { width: 1440, height: 900 },
384
+ acceptDownloads: true,
385
+ downloadsPath: path.join(browserRoot(), "downloads")
386
+ });
387
+ if (context.pages().length === 0) await context.newPage();
388
+ const runtime = new BrowserRuntime(context);
389
+ process.stdout.write(`[r5d-browser] profile: ${profilePath}
390
+ `);
391
+ process.stdout.write(`[r5d-browser] agents use synthetic Chromium input; your system pointer and keyboard stay untouched.
392
+ `);
393
+ process.on("SIGINT", () => void context.close().finally(() => process.exit(0)));
394
+ process.on("SIGTERM", () => void context.close().finally(() => process.exit(0)));
395
+ return connectLoop({
396
+ runtime,
397
+ baseUrl,
398
+ token,
399
+ instanceId: loadInstanceId(),
400
+ profilePath,
401
+ chromiumVersion: context.browser()?.version() ?? "unknown"
402
+ });
403
+ }
404
+ async function main() {
405
+ const { command, options } = parseArgs(process.argv.slice(2));
406
+ if (command === "help") return printHelp();
407
+ if (command === "version") return void process.stdout.write(`r5d-browser ${findVersion()}
408
+ `);
409
+ const browsersPath = path.resolve(options.browsersPath ?? path.join(browserRoot(), "browsers"));
410
+ if (command === "install") return installChromium(browsersPath);
411
+ await start(options);
412
+ }
413
+ main().catch((error) => {
414
+ process.stderr.write(`r5d-browser: ${error instanceof Error ? error.message : String(error)}
415
+ `);
416
+ process.exit(1);
417
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "@ricsam/r5d-browser",
3
+ "version": "0.0.44",
4
+ "type": "module"
5
+ }
@@ -0,0 +1,15 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ function resolvePlaywrightCliPath(fromPath = process.argv[1] ?? path.join(process.cwd(), "package.json")) {
5
+ const require2 = createRequire(fromPath);
6
+ const packageJsonPath = require2.resolve("playwright/package.json");
7
+ const cliPath = path.join(path.dirname(packageJsonPath), "cli.js");
8
+ if (!fs.existsSync(cliPath)) {
9
+ throw new Error(`The Playwright CLI was not found next to ${packageJsonPath}. Reinstall @ricsam/r5d-browser.`);
10
+ }
11
+ return cliPath;
12
+ }
13
+ export {
14
+ resolvePlaywrightCliPath
15
+ };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
@@ -0,0 +1 @@
1
+ export declare function resolvePlaywrightCliPath(fromPath?: string): string;
package/package.json CHANGED
@@ -1,10 +1,39 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.0",
4
- "description": "OIDC trusted publishing setup package for @ricsam/r5d-browser",
5
- "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
3
+ "version": "0.0.44",
4
+ "type": "module",
5
+ "main": "./dist/cjs/main.cjs",
6
+ "module": "./dist/mjs/main.mjs",
7
+ "types": "./dist/types/main.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/types/main.d.ts",
11
+ "import": "./dist/mjs/main.mjs",
12
+ "require": "./dist/cjs/main.cjs"
13
+ }
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ricsam/r5d-dev.git",
18
+ "directory": "packages/r5d-browser"
19
+ },
20
+ "bin": {
21
+ "r5d-browser": "dist/cjs/main.cjs"
22
+ },
23
+ "dependencies": {
24
+ "playwright": "1.62.1",
25
+ "ws": "^8.18.3"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "engines": {
35
+ "bun": ">=1.3.0",
36
+ "node": ">=18"
37
+ },
38
+ "sideEffects": false
10
39
  }