@ricsam/r5d-browser 0.0.137 → 0.0.138
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 +7 -33
- package/dist/cjs/package.json +1 -1
- package/dist/mjs/browser-input.mjs +56 -1
- package/dist/mjs/cli-options.mjs +1 -0
- package/dist/mjs/main.mjs +25 -396
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/personal-diagnostics.mjs +24 -0
- package/dist/mjs/personal-main.mjs +159 -0
- package/dist/mjs/personal-port-forward.mjs +183 -0
- package/dist/mjs/personal-relay-client.mjs +33 -0
- package/dist/mjs/personal-relay-shutdown.mjs +46 -0
- package/dist/mjs/runtime/browser-executor.mjs +11 -0
- package/dist/mjs/runtime/chromium-factory.mjs +101 -0
- package/dist/mjs/runtime/ipc.mjs +128 -0
- package/dist/mjs/runtime/isolated-root.mjs +53 -0
- package/dist/mjs/runtime/keeper.mjs +169 -0
- package/dist/mjs/runtime/ledger.mjs +193 -0
- package/dist/mjs/runtime/main.mjs +105 -0
- package/dist/mjs/runtime/private-paths.mjs +48 -0
- package/dist/mjs/runtime/protocol.mjs +72 -0
- package/dist/types/browser-input.d.ts +4 -0
- package/dist/types/cli-options.d.ts +1 -0
- package/dist/types/personal-diagnostics.d.ts +13 -0
- package/dist/types/personal-main.d.ts +9 -0
- package/dist/types/personal-port-forward.d.ts +45 -0
- package/dist/types/personal-relay-client.d.ts +51 -0
- package/dist/types/personal-relay-shutdown.d.ts +19 -0
- package/dist/types/runtime/chromium-factory.d.ts +25 -0
- package/dist/types/runtime/fixtures/counter-main.d.ts +1 -0
- package/dist/types/runtime/fixtures/counter-runtime.d.ts +6 -2
- package/dist/types/runtime/isolated-root.d.ts +12 -0
- package/dist/types/runtime/main.d.ts +20 -13
- package/dist/types/runtime/private-paths.d.ts +4 -0
- package/package.json +6 -5
- package/dist/cjs/browser-input.cjs +0 -87
- package/dist/cjs/browser-runtime.cjs +0 -708
- package/dist/cjs/chrome-launcher.cjs +0 -701
- package/dist/cjs/cli-options.cjs +0 -95
- package/dist/cjs/main.cjs +0 -427
- package/dist/cjs/password-safe-snapshot.cjs +0 -150
- package/dist/cjs/port-forward-manager.cjs +0 -233
- package/dist/cjs/upload-store.cjs +0 -136
- package/dist/mjs/port-forward-manager.mjs +0 -199
- package/dist/types/port-forward-manager.d.ts +0 -23
package/README.md
CHANGED
|
@@ -1,43 +1,17 @@
|
|
|
1
1
|
# r5d-browser
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connect a personal Chrome browser to your r5d account:
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
|
|
6
|
+
bun install --global @ricsam/r5dctl @ricsam/r5d-browser
|
|
7
|
+
r5dctl auth login
|
|
7
8
|
r5d-browser start
|
|
8
9
|
```
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
For a different environment, use `r5dctl auth login --base-url https://your-environment.example` and `r5d-browser start --base-url https://your-environment.example` with that environment's credential. The client uses the private r5dctl configuration by default. `--token`, `--api-key`, `--config`, `--chrome-path`, and `--profile` are also supported.
|
|
11
12
|
|
|
12
|
-
|
|
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`
|
|
13
|
+
Chrome must be installed. The client creates a separate persistent profile for each server origin, so the browser's sign-ins remain available when you reconnect. Keep the command running while using browser tools. The browser and its action receipts stay on your machine through server deployments and connection retries.
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
The browser connects over HTTPS using your device credential. It supports tab interaction, screenshots, uploads, downloads, and loopback port forwarding from the chat's worker to your browser machine. Forwarded listeners bind only to `127.0.0.1` and `::1`. Stop a forward in User settings or with the browser stop tool.
|
|
18
16
|
|
|
19
|
-
|
|
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
|
-
Password input values are replaced with `[redacted]` in `browser_read_page` snapshots, including values used in accessibility labels and iframe content. Reading a snapshot preserves the actual form values.
|
|
26
|
-
|
|
27
|
-
Agents read pages through `browser_read_page`, an accessibility outline with stable element refs (`[ref=e12]`) on interactable elements and `[box=x,y,w,h]` viewport coordinates on everything visible. Refs feed the one-call interaction tools: `browser_mouse_click` clicks a ref (scrolled into view, with actionability checks) or an x/y point, `browser_form_input` sets values on text inputs, selects, checkboxes, and contenteditable elements with trusted events that controlled (React-style) components honor, and `browser_scroll` scrolls an element into view or dispatches trusted wheel input. Same-origin iframe content appears inline with frame-prefixed refs such as `f1e3`; cross-origin frame content may be omitted. Refs come from the most recent snapshot of a tab, so agents re-read the page after navigations or large re-renders. `browser_wait_for` waits for a selector, visible text, a URL glob, or a load state instead of sleeping, and `browser_keyboard_type` has a `keys` mode that presses each character as a real key event (up to 500 characters per call) for type-ahead widgets — characters without key codes, such as emoji, still fall back to text insertion. Screenshots capture the viewport by default; `fullPage: true` captures the scrollable page up to 4000 CSS px of height.
|
|
28
|
-
|
|
29
|
-
Agents attach worker files with `browser_upload_files({ tabId, files, ref })`, or `selector` instead of `ref` for a hidden file input. `files` contains up to 100 regular-file paths on the session's bound worker: absolute paths, paths relative to the session working directory, `~/` paths, or paths using `$R5D_ROOT` and `$R5D_SESSION_ID`. The files may live outside the repository. Multiple files require an input with `multiple`; directory inputs and native file-picker dialogs are not supported.
|
|
30
|
-
|
|
31
|
-
The operation reads the source files in 1 MiB chunks, relays them through r5d.dev without storing their bytes in artifacts or recovery journals, verifies size and SHA-256, and attaches them using Playwright. Browser copies live in private, generated folders under `~/.r5d/browser/uploads`, preserving filenames even when several files share a name. Completed copies remain until their tab closes or r5d-browser exits so later form submission can still read them. Failed transfers are removed; abandoned partial transfers expire after five minutes without activity, and the next browser start clears staging left after a crash. There is no per-file byte limit; one operation has a 30-minute deadline. Changed source files, disconnections, stale target elements, and session cancellation fail the operation without automatically retrying attachment. Both the worker and browser must advertise upload support.
|
|
32
|
-
|
|
33
|
-
Setting a file input fires its normal input/change events, which may immediately start a site's upload. For forms that require a separate submit action, the agent submits afterwards. The result reports attached filenames and byte sizes; it does not claim the website finished processing them.
|
|
34
|
-
|
|
35
|
-
Agents running JavaScript in a page can save captured text straight into the active session's artifacts by calling `r5dCreateArtifact(filename, content)`, which takes two strings and must be called before the agent's code returns. Each captured file is written to the session artifacts in r5d.dev, synchronized to every connected worker, and reported back under `$R5D_ROOT/artifacts/$R5D_SESSION_ID`; a single call may create at most 32 files totalling 4 MiB. The function is passed into the agent's code rather than assigned to `window`, so pages can neither call it nor use it to detect automation.
|
|
36
|
-
|
|
37
|
-
For responsive testing that needs an existing login (for example, an app using Google login), agents can call `browser_set_viewport` with `{ tabId, width: 390, height: 844 }`. Both dimensions must be integers from 1 to 4096 CSS pixels. The override is scoped to that tab, even in a shared window: it does not resize the OS window or affect other tabs. It preserves the profile, user agent, touch support, and device pixel ratio; this is layout sizing, not mobile-device emulation. It adds no Chrome launch flags or webdriver spoofing; `navigator.webdriver` remains natively `false`.
|
|
38
|
-
|
|
39
|
-
The viewport survives navigation, reloads, and r5d.dev control-connection reconnects until the agent calls `browser_set_viewport` with `{ tabId, reset: true }`, closes the tab, or exits the browser. Reset restores sizing from the current OS window; omit width/height when resetting. The tool returns the actual width and height. Re-read the page or capture a screenshot after resizing because old coordinates are stale, and reset when testing is finished. To apply a size before the first page load, open `about:blank`, set its viewport, then navigate. An outdated browser client returns an update-and-restart instruction for this tool.
|
|
40
|
-
|
|
41
|
-
As an escape hatch, agents can create loopback-only TCP port forwards from this Mac to the worker bound to a chat when a local flow specifically needs this browser's authenticated state or human interaction, such as testing Google OAuth. Ordinary local application testing should use Playwright directly on the worker without a forward. 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.
|
|
42
|
-
|
|
43
|
-
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.
|
|
17
|
+
Operators may also start an independently supervised keeper with `r5d-browser --config /absolute/private/browser-keeper.json`. That configuration binds the installation, user, browser factory, profile, ledger, and permitted adapter credentials.
|
package/dist/cjs/package.json
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseUploadTarget } from "@ricsam/r5d-api/browser-upload";
|
|
1
2
|
const browserInputFields = {
|
|
2
3
|
list_tabs: { required: [], optional: [] },
|
|
3
4
|
list_downloads: { required: [], optional: [] },
|
|
@@ -54,8 +55,62 @@ function normalizeBrowserInput(action, input) {
|
|
|
54
55
|
}
|
|
55
56
|
return normalized;
|
|
56
57
|
}
|
|
58
|
+
function preflightBrowserInput(action, raw) {
|
|
59
|
+
const input = normalizeBrowserInput(action, raw);
|
|
60
|
+
const ref = () => {
|
|
61
|
+
if (typeof input.ref !== "string" || !/^(f\d+)?e\d+$/.test(input.ref))
|
|
62
|
+
throw new Error('ref must look like "e12" or "f1e3", exactly as printed by browser_read_page ([ref=e12]).');
|
|
63
|
+
};
|
|
64
|
+
const coordinates = (message) => {
|
|
65
|
+
if (!Number.isFinite(Number(input.x)) || !Number.isFinite(Number(input.y))) throw new Error(message);
|
|
66
|
+
};
|
|
67
|
+
if (action === "port_forward") {
|
|
68
|
+
for (const name of ["workerPort", "browserPort"])
|
|
69
|
+
if ((name === "workerPort" || input[name] !== void 0) && (!Number.isInteger(input[name]) || Number(input[name]) < 1 || Number(input[name]) > 65535))
|
|
70
|
+
throw new Error(`${name} must be an integer from 1 to 65535.`);
|
|
71
|
+
if (Object.keys(input).some((name) => !["workerPort", "browserPort"].includes(name))) throw new Error("port_forward accepts only workerPort and browserPort.");
|
|
72
|
+
}
|
|
73
|
+
if (action === "stop_port_forward" && (typeof input.forwardId !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$/.test(input.forwardId)))
|
|
74
|
+
throw new Error("stop_port_forward requires a valid forwardId.");
|
|
75
|
+
if (action === "set_viewport") parseViewportInput(input);
|
|
76
|
+
if (action === "move_mouse") coordinates("move_mouse requires numeric x and y coordinates.");
|
|
77
|
+
if (action === "mouse_click") {
|
|
78
|
+
if (input.ref !== void 0) {
|
|
79
|
+
if (input.x !== void 0 || input.y !== void 0) throw new Error("mouse_click accepts ref or x/y, not both.");
|
|
80
|
+
ref();
|
|
81
|
+
} else if (input.x !== void 0 || input.y !== void 0) coordinates("mouse_click by coordinates requires numeric x and y.");
|
|
82
|
+
}
|
|
83
|
+
if (action === "scroll") {
|
|
84
|
+
if (input.ref !== void 0) {
|
|
85
|
+
if (input.direction !== void 0) throw new Error("scroll accepts ref or direction, not both.");
|
|
86
|
+
ref();
|
|
87
|
+
} else {
|
|
88
|
+
if (!["up", "down", "left", "right"].includes(String(input.direction)))
|
|
89
|
+
throw new Error('scroll requires ref, or direction "up", "down", "left", or "right".');
|
|
90
|
+
if (input.x !== void 0 || input.y !== void 0) coordinates("scroll requires numeric x and y when provided.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (action === "upload_files" || action === "upload_begin") parseUploadTarget(input);
|
|
94
|
+
if (action === "wait_for") {
|
|
95
|
+
const provided = ["selector", "text", "url", "loadState"].filter((key) => input[key] !== void 0);
|
|
96
|
+
if (provided.length !== 1) throw new Error("wait_for requires exactly one of selector, text, url, or loadState.");
|
|
97
|
+
if (provided[0] === "loadState") {
|
|
98
|
+
if (!["load", "domcontentloaded", "networkidle"].includes(String(input.loadState)))
|
|
99
|
+
throw new Error('wait_for loadState must be "load", "domcontentloaded", or "networkidle".');
|
|
100
|
+
} else if (typeof input[provided[0]] !== "string") throw new Error(`wait_for ${provided[0]} must be a string.`);
|
|
101
|
+
}
|
|
102
|
+
if (action === "form_input") {
|
|
103
|
+
ref();
|
|
104
|
+
const provided = ["value", "values", "checked"].filter((key) => input[key] !== void 0);
|
|
105
|
+
if (provided.length !== 1) throw new Error("form_input requires exactly one of value, values, or checked.");
|
|
106
|
+
if (provided[0] === "values" && (!Array.isArray(input.values) || input.values.some((value) => typeof value !== "string")))
|
|
107
|
+
throw new Error("form_input values must be an array of strings.");
|
|
108
|
+
}
|
|
109
|
+
return input;
|
|
110
|
+
}
|
|
57
111
|
export {
|
|
58
112
|
MAX_VIEWPORT_DIMENSION,
|
|
59
113
|
normalizeBrowserInput,
|
|
60
|
-
parseViewportInput
|
|
114
|
+
parseViewportInput,
|
|
115
|
+
preflightBrowserInput
|
|
61
116
|
};
|
package/dist/mjs/cli-options.mjs
CHANGED
package/dist/mjs/main.mjs
CHANGED
|
@@ -1,404 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import WebSocket from "ws";
|
|
6
|
-
import { BrowserRuntime } from "./browser-runtime.mjs";
|
|
2
|
+
import { startConfiguredBrowserKeeper } from "./runtime/main.mjs";
|
|
7
3
|
import { parseBrowserArgs } from "./cli-options.mjs";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const CHROME_DISCONNECT_EXIT_GRACE_MS = 2e3;
|
|
15
|
-
function browserRoot() {
|
|
16
|
-
return path.join(os.homedir(), ".r5d", "browser");
|
|
17
|
-
}
|
|
18
|
-
function printHelp() {
|
|
19
|
-
process.stdout.write(`Usage:
|
|
20
|
-
r5d-browser start [--base-url <url>] [--token <token>] [--api-key <key>]
|
|
21
|
-
r5d-browser --version
|
|
22
|
-
|
|
23
|
-
Options:
|
|
24
|
-
--base-url <url> r5d.dev base URL (default from r5dctl config or ${DEFAULT_BASE_URL})
|
|
25
|
-
--token <token> r5d device credential
|
|
26
|
-
--api-key <key> r5d API key
|
|
27
|
-
--config <path> Shared r5dctl config path
|
|
28
|
-
--profile <path> Google Chrome profile path (default ~/.r5d/browser/chrome-profile)
|
|
29
|
-
--chrome-path <path> Google Chrome executable (default: auto-detect stable Chrome)
|
|
30
|
-
-v, --version Show r5d-browser version
|
|
31
|
-
-h, --help Show this help
|
|
32
|
-
`);
|
|
33
|
-
}
|
|
34
|
-
function findVersion() {
|
|
35
|
-
let current = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : process.cwd();
|
|
36
|
-
for (let index = 0; index < 12; index += 1) {
|
|
37
|
-
try {
|
|
38
|
-
const parsed = JSON.parse(fs.readFileSync(path.join(current, "package.json"), "utf8"));
|
|
39
|
-
if (parsed.name === PACKAGE_NAME && parsed.version) return parsed.version;
|
|
40
|
-
} catch {
|
|
41
|
-
}
|
|
42
|
-
try {
|
|
43
|
-
const sourceVersion = fs.readFileSync(path.join(current, "VERSION.txt"), "utf8").trim();
|
|
44
|
-
if (sourceVersion && path.basename(current) === "packages") return sourceVersion;
|
|
45
|
-
} catch {
|
|
46
|
-
}
|
|
47
|
-
const parent = path.dirname(current);
|
|
48
|
-
if (parent === current) break;
|
|
49
|
-
current = parent;
|
|
50
|
-
}
|
|
51
|
-
return "unknown";
|
|
52
|
-
}
|
|
53
|
-
function readConfig(configPath) {
|
|
54
|
-
if (!fs.existsSync(configPath)) return {};
|
|
55
|
-
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
56
|
-
return {
|
|
57
|
-
baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : void 0,
|
|
58
|
-
token: typeof parsed.token === "string" ? parsed.token : void 0,
|
|
59
|
-
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : void 0
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
function resolveConnection(options) {
|
|
63
|
-
const config = readConfig(options.configPath);
|
|
64
|
-
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;
|
|
65
|
-
if (!token) throw new Error("Authentication required. Run `r5dctl auth login` or pass --token/--api-key.");
|
|
66
|
-
return {
|
|
67
|
-
baseUrl: (options.baseUrl ?? process.env.R5D_BASE_URL ?? process.env.R5DCTL_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL).replace(
|
|
68
|
-
/\/+$/,
|
|
69
|
-
""
|
|
70
|
-
),
|
|
71
|
-
token
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
function loadInstanceId() {
|
|
75
|
-
const instancePath = path.join(browserRoot(), "instance-id");
|
|
76
|
-
fs.mkdirSync(path.dirname(instancePath), { recursive: true });
|
|
77
|
-
try {
|
|
78
|
-
const id2 = fs.readFileSync(instancePath, "utf8").trim();
|
|
79
|
-
if (id2) return id2;
|
|
80
|
-
} catch {
|
|
81
|
-
}
|
|
82
|
-
const id = crypto.randomUUID();
|
|
83
|
-
fs.writeFileSync(instancePath, `${id}
|
|
84
|
-
`, { mode: 384 });
|
|
85
|
-
return id;
|
|
86
|
-
}
|
|
87
|
-
async function waitForReconnectDelay(signal) {
|
|
88
|
-
if (signal.aborted) return;
|
|
89
|
-
await new Promise((resolve) => {
|
|
90
|
-
const finish = () => {
|
|
91
|
-
clearTimeout(timer);
|
|
92
|
-
signal.removeEventListener("abort", finish);
|
|
93
|
-
resolve();
|
|
94
|
-
};
|
|
95
|
-
const timer = setTimeout(finish, RECONNECT_DELAY_MS);
|
|
96
|
-
signal.addEventListener("abort", finish, { once: true });
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
async function connectLoop(params) {
|
|
100
|
-
while (!params.signal.aborted) {
|
|
101
|
-
const url = new URL("/browser/ws", params.baseUrl);
|
|
102
|
-
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
103
|
-
url.searchParams.set("instance", params.instanceId);
|
|
104
|
-
url.searchParams.set("version", findVersion());
|
|
105
|
-
let socket = null;
|
|
106
|
-
let watchdog;
|
|
107
|
-
const closeForAbort = () => socket?.close(1e3, "r5d-browser shutting down");
|
|
108
|
-
try {
|
|
109
|
-
socket = new WebSocket(url, { headers: { Authorization: `Bearer ${params.token}` } });
|
|
110
|
-
socket.on("error", () => void 0);
|
|
111
|
-
params.signal.addEventListener("abort", closeForAbort, { once: true });
|
|
112
|
-
await new Promise((resolve, reject) => {
|
|
113
|
-
socket.addEventListener("open", () => resolve(), { once: true });
|
|
114
|
-
socket.addEventListener("error", () => reject(new Error("WebSocket connection failed.")), { once: true });
|
|
115
|
-
socket.addEventListener("close", () => reject(new Error("WebSocket closed before connecting.")), { once: true });
|
|
116
|
-
});
|
|
117
|
-
if (params.signal.aborted) break;
|
|
118
|
-
process.stdout.write(`[r5d-browser] connected to ${params.baseUrl}
|
|
119
|
-
`);
|
|
120
|
-
socket.send(
|
|
121
|
-
JSON.stringify({
|
|
122
|
-
type: "hello",
|
|
123
|
-
hostInfo: {
|
|
124
|
-
hostname: os.hostname(),
|
|
125
|
-
platform: process.platform,
|
|
126
|
-
arch: process.arch,
|
|
127
|
-
version: findVersion(),
|
|
128
|
-
chromiumVersion: params.chromiumVersion,
|
|
129
|
-
profilePath: params.profilePath,
|
|
130
|
-
capabilities: {
|
|
131
|
-
portForwarding: true,
|
|
132
|
-
runJsArtifacts: true,
|
|
133
|
-
readPage: true,
|
|
134
|
-
passwordRedaction: true,
|
|
135
|
-
refActions: true,
|
|
136
|
-
waitFor: true,
|
|
137
|
-
formInput: true,
|
|
138
|
-
typedKeys: true,
|
|
139
|
-
screenshotFullPage: true,
|
|
140
|
-
viewportSizing: true,
|
|
141
|
-
fileUploads: true
|
|
142
|
-
}
|
|
143
|
-
},
|
|
144
|
-
tabs: await params.runtime.listTabs(),
|
|
145
|
-
portForwards: params.portForwards.list()
|
|
146
|
-
})
|
|
147
|
-
);
|
|
148
|
-
let lastServerMessageAt = Date.now();
|
|
149
|
-
watchdog = setInterval(() => {
|
|
150
|
-
if (Date.now() - lastServerMessageAt >= SERVER_HEARTBEAT_TIMEOUT_MS) socket?.close();
|
|
151
|
-
}, 5e3);
|
|
152
|
-
await new Promise((resolve) => {
|
|
153
|
-
socket.addEventListener("message", (event) => {
|
|
154
|
-
if (params.signal.aborted) return;
|
|
155
|
-
lastServerMessageAt = Date.now();
|
|
156
|
-
void (async () => {
|
|
157
|
-
const message = JSON.parse(String(event.data));
|
|
158
|
-
if (message.type === "ping") {
|
|
159
|
-
socket?.send(JSON.stringify({ type: "pong" }));
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
if (message.type === "port_forward_start") {
|
|
163
|
-
try {
|
|
164
|
-
await params.portForwards.start(message.forward);
|
|
165
|
-
socket?.send(JSON.stringify({ type: "port_forward_result", requestId: message.requestId, forward: message.forward }));
|
|
166
|
-
} catch (error) {
|
|
167
|
-
socket?.send(
|
|
168
|
-
JSON.stringify({
|
|
169
|
-
type: "port_forward_result",
|
|
170
|
-
requestId: message.requestId,
|
|
171
|
-
error: error instanceof Error ? error.message : String(error)
|
|
172
|
-
})
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
if (message.type === "port_forward_stop") {
|
|
178
|
-
try {
|
|
179
|
-
await params.portForwards.stop(message.forwardId);
|
|
180
|
-
socket?.send(JSON.stringify({ type: "port_forward_result", requestId: message.requestId }));
|
|
181
|
-
} catch (error) {
|
|
182
|
-
socket?.send(
|
|
183
|
-
JSON.stringify({
|
|
184
|
-
type: "port_forward_result",
|
|
185
|
-
requestId: message.requestId,
|
|
186
|
-
error: error instanceof Error ? error.message : String(error)
|
|
187
|
-
})
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
if (message.type !== "operation") return;
|
|
193
|
-
try {
|
|
194
|
-
if (["upload_begin", "upload_chunk", "upload_abort"].includes(message.action)) {
|
|
195
|
-
const result2 = await params.runtime.execute(message.action, message.input);
|
|
196
|
-
socket?.send(JSON.stringify({ type: "operation_result", requestId: message.requestId, result: result2 }));
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
const beforeTabs = await params.runtime.listTabs();
|
|
200
|
-
const operationResult = await params.runtime.execute(message.action, message.input);
|
|
201
|
-
const tabs = await params.runtime.listTabs();
|
|
202
|
-
const beforeIds = new Set(beforeTabs.map((tab) => tab.tabId));
|
|
203
|
-
const openedTabs = tabs.filter((tab) => !beforeIds.has(tab.tabId));
|
|
204
|
-
const result = operationResult && typeof operationResult === "object" ? { ...operationResult, openedTabs } : { result: operationResult, openedTabs };
|
|
205
|
-
socket?.send(JSON.stringify({ type: "operation_result", requestId: message.requestId, result }));
|
|
206
|
-
socket?.send(JSON.stringify({ type: "tabs_snapshot", tabs }));
|
|
207
|
-
} catch (error) {
|
|
208
|
-
socket?.send(
|
|
209
|
-
JSON.stringify({
|
|
210
|
-
type: "operation_result",
|
|
211
|
-
requestId: message.requestId,
|
|
212
|
-
error: error instanceof Error ? error.message : String(error)
|
|
213
|
-
})
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
})();
|
|
217
|
-
});
|
|
218
|
-
socket.addEventListener("close", () => resolve(), { once: true });
|
|
219
|
-
});
|
|
220
|
-
} catch (error) {
|
|
221
|
-
if (!params.signal.aborted) process.stderr.write(`[r5d-browser] ${error instanceof Error ? error.message : String(error)}
|
|
222
|
-
`);
|
|
223
|
-
} finally {
|
|
224
|
-
if (watchdog) clearInterval(watchdog);
|
|
225
|
-
params.signal.removeEventListener("abort", closeForAbort);
|
|
226
|
-
socket?.close();
|
|
227
|
-
}
|
|
228
|
-
if (params.signal.aborted) break;
|
|
229
|
-
process.stdout.write(`[r5d-browser] disconnected; reconnecting...
|
|
230
|
-
`);
|
|
231
|
-
await waitForReconnectDelay(params.signal);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
function isProcessAlive(pid) {
|
|
235
|
-
try {
|
|
236
|
-
process.kill(pid, 0);
|
|
237
|
-
return true;
|
|
238
|
-
} catch (error) {
|
|
239
|
-
return error.code === "EPERM";
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
async function waitUpTo(promise, timeoutMs) {
|
|
243
|
-
let timer;
|
|
244
|
-
try {
|
|
245
|
-
return await Promise.race([
|
|
246
|
-
promise.then(() => true),
|
|
247
|
-
new Promise((resolve) => {
|
|
248
|
-
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
249
|
-
})
|
|
250
|
-
]);
|
|
251
|
-
} finally {
|
|
252
|
-
if (timer) clearTimeout(timer);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
async function resultWithin(promise, timeoutMs) {
|
|
256
|
-
let timer;
|
|
257
|
-
try {
|
|
258
|
-
return await Promise.race([
|
|
259
|
-
promise,
|
|
260
|
-
new Promise((resolve) => {
|
|
261
|
-
timer = setTimeout(() => resolve(void 0), timeoutMs);
|
|
262
|
-
})
|
|
263
|
-
]);
|
|
264
|
-
} finally {
|
|
265
|
-
if (timer) clearTimeout(timer);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
async function start(options) {
|
|
269
|
-
if (process.platform !== "darwin") throw new Error("r5d-browser currently supports macOS only.");
|
|
270
|
-
const { baseUrl, token } = resolveConnection(options);
|
|
271
|
-
const requestedProfilePath = path.resolve(options.profilePath ?? defaultChromeProfilePath());
|
|
272
|
-
const downloadsPath = path.join(browserRoot(), "downloads");
|
|
273
|
-
const playwrightDownloadsPath = path.join(downloadsPath, ".playwright");
|
|
274
|
-
fs.mkdirSync(playwrightDownloadsPath, { recursive: true, mode: 448 });
|
|
275
|
-
let requestedSignalOutcome;
|
|
276
|
-
let requestStop = () => void 0;
|
|
277
|
-
const requestedStop = new Promise((resolve) => {
|
|
278
|
-
requestStop = resolve;
|
|
279
|
-
});
|
|
280
|
-
const signalHandlers = /* @__PURE__ */ new Map();
|
|
281
|
-
const processEvents = process;
|
|
282
|
-
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
283
|
-
const handler = () => {
|
|
284
|
-
requestedSignalOutcome ??= { exitCode: 0 };
|
|
285
|
-
requestStop(requestedSignalOutcome);
|
|
286
|
-
};
|
|
287
|
-
signalHandlers.set(signal, handler);
|
|
288
|
-
process.on(signal, handler);
|
|
289
|
-
}
|
|
290
|
-
let launch;
|
|
291
|
-
try {
|
|
292
|
-
launch = await launchStableChrome({
|
|
293
|
-
chromePath: options.chromePath,
|
|
294
|
-
profilePath: requestedProfilePath,
|
|
295
|
-
playwrightDownloadsPath,
|
|
296
|
-
lockPath: defaultChromeLockPath(),
|
|
297
|
-
onLaunchAttemptFailed: (attempt, attempts, error) => {
|
|
298
|
-
process.stderr.write(
|
|
299
|
-
`[r5d-browser] Chrome launch attempt ${attempt}/${attempts} failed: ${error instanceof Error ? error.message : String(error)}
|
|
300
|
-
`
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
});
|
|
304
|
-
} catch (error) {
|
|
305
|
-
for (const [signal, handler] of signalHandlers) processEvents.off(signal, handler);
|
|
306
|
-
throw error;
|
|
4
|
+
import { startPersonalBrowser } from "./personal-main.mjs";
|
|
5
|
+
async function main() {
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
if (args.includes("--version")) {
|
|
8
|
+
console.log(`r5d-browser ${true ? "0.0.138" : "development"}`);
|
|
9
|
+
return;
|
|
307
10
|
}
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
await launch.close();
|
|
311
|
-
} finally {
|
|
312
|
-
for (const [signal, handler] of signalHandlers) processEvents.off(signal, handler);
|
|
313
|
-
}
|
|
11
|
+
if (!args.length || args.includes("--help")) {
|
|
12
|
+
console.log("Usage: r5d-browser start [--base-url https://r5d.dev] [--token credential] [--config r5dctl-config] [--profile path] [--root <path>] [--chrome-path <path>]\n r5d-browser --config /absolute/private/browser-keeper.json\n\nConnects your persistent Google Chrome profile to your r5d account. Sign in with r5dctl auth login first.\n--root sets the private r5d state base directory (an environment-specific subdirectory is added). It does not change Chrome's HOME.");
|
|
314
13
|
return;
|
|
315
14
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
fs.rmSync(uploadsPath, { recursive: true, force: true });
|
|
320
|
-
const runtime = new BrowserRuntime(launch.context, downloadsPath, uploadsPath);
|
|
321
|
-
const instanceId = loadInstanceId();
|
|
322
|
-
const portForwards = new BrowserPortForwardManager(baseUrl, token, instanceId);
|
|
323
|
-
process.stdout.write(`[r5d-browser] profile: ${profilePath}
|
|
324
|
-
`);
|
|
325
|
-
process.stdout.write(`[r5d-browser] downloads: ${downloadsPath}
|
|
326
|
-
`);
|
|
327
|
-
process.stdout.write(`[r5d-browser] Google Chrome ${launch.chromeVersion}; navigator.webdriver is disabled.
|
|
328
|
-
`);
|
|
329
|
-
process.stdout.write(`[r5d-browser] agents use synthetic Chrome input; your system pointer and keyboard stay untouched.
|
|
330
|
-
`);
|
|
331
|
-
const abortController = new AbortController();
|
|
332
|
-
const controlLoop = connectLoop({
|
|
333
|
-
runtime,
|
|
334
|
-
portForwards,
|
|
335
|
-
baseUrl,
|
|
336
|
-
token,
|
|
337
|
-
instanceId,
|
|
338
|
-
profilePath,
|
|
339
|
-
chromiumVersion: launch.chromeVersion,
|
|
340
|
-
signal: abortController.signal
|
|
341
|
-
});
|
|
342
|
-
const controlStopped = controlLoop.then(
|
|
343
|
-
() => ({ exitCode: abortController.signal.aborted ? 0 : 1, message: "The r5d.dev browser control loop stopped unexpectedly." }),
|
|
344
|
-
(error) => ({
|
|
345
|
-
exitCode: 1,
|
|
346
|
-
message: `The r5d.dev browser control loop failed: ${error instanceof Error ? error.message : String(error)}`
|
|
347
|
-
})
|
|
348
|
-
);
|
|
349
|
-
const chromeExited = launch.exited.then(({ code, signal }) => ({
|
|
350
|
-
exitCode: code === 0 ? 0 : 1,
|
|
351
|
-
message: code === 0 ? void 0 : `Google Chrome exited unexpectedly (${signal ? `signal ${signal}` : `code ${code ?? "unknown"}`}).`
|
|
352
|
-
}));
|
|
353
|
-
const chromeDisconnected = launch.disconnected.then(async () => {
|
|
354
|
-
const exitOutcome = await resultWithin(chromeExited, CHROME_DISCONNECT_EXIT_GRACE_MS);
|
|
355
|
-
if (exitOutcome) return exitOutcome;
|
|
356
|
-
if (!isProcessAlive(launch.chromePid)) return await chromeExited;
|
|
357
|
-
return { exitCode: 1, message: "The Google Chrome control connection closed unexpectedly." };
|
|
358
|
-
});
|
|
359
|
-
const outcome = await Promise.race([requestedStop, chromeExited, chromeDisconnected, controlStopped]);
|
|
360
|
-
abortController.abort();
|
|
361
|
-
let cleanupError;
|
|
362
|
-
try {
|
|
363
|
-
await portForwards.close();
|
|
364
|
-
} catch (error) {
|
|
365
|
-
cleanupError = error;
|
|
366
|
-
}
|
|
367
|
-
try {
|
|
368
|
-
if (!await waitUpTo(runtime.drainDownloads(), 5e3)) {
|
|
369
|
-
process.stderr.write("[r5d-browser] timed out waiting for active downloads during shutdown.\n");
|
|
370
|
-
}
|
|
371
|
-
} catch (error) {
|
|
372
|
-
cleanupError ??= error;
|
|
373
|
-
}
|
|
374
|
-
try {
|
|
375
|
-
await launch.close();
|
|
376
|
-
} catch (error) {
|
|
377
|
-
cleanupError ??= error;
|
|
378
|
-
}
|
|
379
|
-
runtime.closeUploads();
|
|
380
|
-
await controlStopped;
|
|
381
|
-
for (const [signal, handler] of signalHandlers) processEvents.off(signal, handler);
|
|
382
|
-
if (outcome.exitCode !== 0) throw new Error(outcome.message ?? "Google Chrome stopped unexpectedly.");
|
|
383
|
-
if (cleanupError) throw cleanupError;
|
|
384
|
-
} catch (error) {
|
|
385
|
-
try {
|
|
386
|
-
await launch.close().catch(() => void 0);
|
|
387
|
-
} finally {
|
|
388
|
-
for (const [signal, handler] of signalHandlers) processEvents.off(signal, handler);
|
|
389
|
-
}
|
|
390
|
-
throw error;
|
|
15
|
+
if (args[0] === "start") {
|
|
16
|
+
await startPersonalBrowser(parseBrowserArgs(args).options, true ? "0.0.138" : "development");
|
|
17
|
+
return;
|
|
391
18
|
}
|
|
19
|
+
if (args.length !== 2 || args[0] !== "--config" || !args[1]?.startsWith("/")) throw new Error("An absolute private browser keeper configuration is required");
|
|
20
|
+
const service = await startConfiguredBrowserKeeper(args[1]);
|
|
21
|
+
let closing = false;
|
|
22
|
+
const close = () => {
|
|
23
|
+
if (closing) return;
|
|
24
|
+
closing = true;
|
|
25
|
+
void service.close().then(() => process.exit(0), () => process.exit(1));
|
|
26
|
+
};
|
|
27
|
+
process.once("SIGTERM", close);
|
|
28
|
+
process.once("SIGINT", close);
|
|
392
29
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (command === "version") return void process.stdout.write(`r5d-browser ${findVersion()}
|
|
397
|
-
`);
|
|
398
|
-
await start(options);
|
|
399
|
-
}
|
|
400
|
-
main().catch((error) => {
|
|
401
|
-
process.stderr.write(`r5d-browser: ${error instanceof Error ? error.message : String(error)}
|
|
402
|
-
`);
|
|
403
|
-
process.exit(1);
|
|
30
|
+
void main().catch(() => {
|
|
31
|
+
console.error("Browser keeper startup failed; check private configuration and exclusive ownership");
|
|
32
|
+
process.exitCode = 1;
|
|
404
33
|
});
|
package/dist/mjs/package.json
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
function browserErrorCategory(error) {
|
|
2
|
+
const message = error instanceof Error ? error.message : "";
|
|
3
|
+
if (/timeout|timed out/i.test(message)) return "timeout";
|
|
4
|
+
if (/closed|disconnect|ECONNRESET|ECONNREFUSED/i.test(message)) return "disconnected";
|
|
5
|
+
if (/required|invalid|must be|must look|ref or x\/y/i.test(message)) return "invalid-input";
|
|
6
|
+
return "other";
|
|
7
|
+
}
|
|
8
|
+
function personalBrowserDiagnosticObserver(enabled, write = (line) => process.stderr.write(line)) {
|
|
9
|
+
return (event) => {
|
|
10
|
+
if (!enabled) return;
|
|
11
|
+
const { phase, method, bytes, status, category } = event;
|
|
12
|
+
const code = event.code && /^[a-z_]{1,100}$/.test(event.code) ? event.code : void 0;
|
|
13
|
+
const action = event.action && /^[a-z_]{1,32}$/.test(event.action) ? event.action : void 0;
|
|
14
|
+
try {
|
|
15
|
+
write(`[r5d-browser-diagnostic] ${JSON.stringify({ phase, method, bytes, status, code, action, category })}
|
|
16
|
+
`);
|
|
17
|
+
} catch {
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export {
|
|
22
|
+
browserErrorCategory,
|
|
23
|
+
personalBrowserDiagnosticObserver
|
|
24
|
+
};
|