@rynx-ai/daemon 0.1.11-beta.45 → 0.1.11-beta.49
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/bundled-plugins/plugins/lark/dist/runtime.js +32 -26
- package/bundled-plugins/plugins/lark/dist/settings.js +17 -11
- package/bundled-plugins/plugins/lark/package.json +1 -1
- package/dist/app-browser-host-supervisor.js +6 -1
- package/dist/daemon-server.js +23 -7
- package/dist/headless-browser-host.js +114 -42
- package/dist/log-files.d.ts +9 -0
- package/dist/log-files.js +96 -0
- package/dist/page-agent-browser-connection.d.ts +6 -0
- package/dist/page-agent-browser-connection.js +71 -0
- package/dist/page-agent-browser-driver.d.ts +14 -0
- package/dist/page-agent-browser-driver.js +216 -0
- package/dist/page-agent-browser-policy.d.ts +3 -0
- package/dist/page-agent-browser-policy.js +37 -0
- package/dist/page-automation-manager.d.ts +29 -0
- package/dist/page-automation-manager.js +144 -0
- package/dist/page-cdp-gateway.d.ts +5 -0
- package/dist/page-cdp-gateway.js +205 -0
- package/dist/plugin-host-rpc.js +44 -0
- package/package.json +12 -11
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { diagnosticEvents, sessionDiagnosticDir, teeDiagnosticStream } from "@rynx-ai/core";
|
|
8
|
+
import { createPageAgentBrowserConnection } from "./page-agent-browser-connection.js";
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
export const PAGE_AGENT_BROWSER_VERSION = "0.36.0";
|
|
11
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
export class PageAutomationError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message, options) {
|
|
15
|
+
super(message, options);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = "PageAutomationError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Resolve only the pinned dependency. Never use a PATH/global helper or run its Node wrapper. */
|
|
21
|
+
export function pageAgentBrowserBinary() {
|
|
22
|
+
// Operator-owned absolute native binary. This is never read from request
|
|
23
|
+
// argv or the managed Agent's environment. Upgrading it needs no Rynx build.
|
|
24
|
+
const configured = process.env.RYNX_AGENT_BROWSER_EXECUTABLE;
|
|
25
|
+
if (configured) {
|
|
26
|
+
if (!isAbsolute(configured))
|
|
27
|
+
throw new Error("RYNX_AGENT_BROWSER_EXECUTABLE must be an absolute native binary path");
|
|
28
|
+
return resolve(configured);
|
|
29
|
+
}
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const root = dirname(require.resolve("agent-browser/package.json"));
|
|
32
|
+
const metadata = require("agent-browser/package.json");
|
|
33
|
+
if (metadata.version !== PAGE_AGENT_BROWSER_VERSION)
|
|
34
|
+
throw new Error("Bundled agent-browser version mismatch");
|
|
35
|
+
const report = process.platform === "linux" ? process.report?.getReport() : undefined;
|
|
36
|
+
const platform = process.platform === "linux" && !report?.header?.glibcVersionRuntime ? "linux-musl" : process.platform;
|
|
37
|
+
const arch = process.platform === "win32" && process.arch === "arm64" ? "x64" : process.arch;
|
|
38
|
+
return join(root, "bin", `agent-browser-${platform}-${arch}${process.platform === "win32" ? ".exe" : ""}`).replace(/\.asar([/\\])/, ".asar.unpacked$1");
|
|
39
|
+
}
|
|
40
|
+
export async function createPageAgentBrowserDriver(root, binding) {
|
|
41
|
+
const binary = pageAgentBrowserBinary();
|
|
42
|
+
const id = randomBytes(6).toString("hex");
|
|
43
|
+
const logRoot = sessionDiagnosticDir("browser", binding.sessionId);
|
|
44
|
+
const event = diagnosticEvents(join(logRoot, "driver.log"), { sessionId: binding.sessionId, driverId: id, browserGeneration: binding.browserGeneration, pageId: binding.pageId });
|
|
45
|
+
let commandSeq = 0;
|
|
46
|
+
const directory = join(root, id);
|
|
47
|
+
await mkdir(directory, { mode: 0o700 });
|
|
48
|
+
const config = join(directory, "config.json");
|
|
49
|
+
await writeFile(config, "{}\n", { mode: 0o600, flag: "wx" });
|
|
50
|
+
// A native helper has no reason to inherit API tokens, proxies, agent
|
|
51
|
+
// settings, a provider, persistence, plugins or the caller's project config.
|
|
52
|
+
const env = {};
|
|
53
|
+
for (const key of ["PATH", "HOME", "USERPROFILE", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "DISPLAY", "XDG_RUNTIME_DIR"]) {
|
|
54
|
+
if (process.env[key] !== undefined)
|
|
55
|
+
env[key] = process.env[key];
|
|
56
|
+
}
|
|
57
|
+
env.AGENT_BROWSER_SOCKET_DIR = root;
|
|
58
|
+
env.AGENT_BROWSER_DEFAULT_TIMEOUT = "20000";
|
|
59
|
+
const { stdout: versionText } = await exec(binary, ["--version"], { cwd: directory, env, timeout: 5_000, maxBuffer: 4096 }).catch(async (error) => {
|
|
60
|
+
await rm(directory, { recursive: true, force: true });
|
|
61
|
+
throw new PageAutomationError("command_failed", `Browser engine cannot start (${String(error.code ?? "timeout")}); rebuild/reinstall the Rynx Runtime or check its configured native engine`, { cause: error });
|
|
62
|
+
});
|
|
63
|
+
const version = versionText.trim().match(/(?:agent-browser\s+)?(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/)?.[1];
|
|
64
|
+
if (!version) {
|
|
65
|
+
await rm(directory, { recursive: true, force: true });
|
|
66
|
+
throw new Error("Browser driver did not report a semantic version");
|
|
67
|
+
}
|
|
68
|
+
const connection = await createPageAgentBrowserConnection(binding.endpoint, binding.isCurrent).catch(async (error) => {
|
|
69
|
+
await rm(directory, { recursive: true, force: true });
|
|
70
|
+
throw error;
|
|
71
|
+
});
|
|
72
|
+
let initialized = false;
|
|
73
|
+
let closed = false;
|
|
74
|
+
let owner;
|
|
75
|
+
const processIdentity = async (pid) => {
|
|
76
|
+
if (process.platform === "win32")
|
|
77
|
+
return undefined;
|
|
78
|
+
return (await exec("ps", ["-p", String(pid), "-o", "lstart=", "-o", "comm="], { timeout: 2_000, maxBuffer: 4096 }).catch(() => ({ stdout: "" }))).stdout.trim() || undefined;
|
|
79
|
+
};
|
|
80
|
+
const captureOwner = async () => {
|
|
81
|
+
const pid = Number((await readFile(join(root, id + ".pid"), "utf8").catch(() => "")).trim());
|
|
82
|
+
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
83
|
+
return;
|
|
84
|
+
const identity = await processIdentity(pid);
|
|
85
|
+
if (identity)
|
|
86
|
+
owner = { pid, identity };
|
|
87
|
+
};
|
|
88
|
+
const ownerAlive = async () => Boolean(owner && await processIdentity(owner.pid) === owner.identity);
|
|
89
|
+
const ownerCurrent = async () => await ownerAlive() && Number((await readFile(join(root, id + ".pid"), "utf8").catch(() => "")).trim()) === owner?.pid;
|
|
90
|
+
const invoke = async (args, pin, timeout = 30_000) => {
|
|
91
|
+
const commandId = String(++commandSeq);
|
|
92
|
+
event("command.start", { commandId, command: args[0] });
|
|
93
|
+
let stdout;
|
|
94
|
+
try {
|
|
95
|
+
const pending = exec(binary, ["--config", config, "--session", id, "--cdp", connection.endpoint,
|
|
96
|
+
pin ? "--pin-tab" : "--no-pin-tab", "--idle-timeout", "900000", "--no-webmcp", "--no-auto-dialog", "--json", ...args], { cwd: directory, env, timeout, maxBuffer: MAX_BYTES, windowsHide: true, encoding: "buffer" });
|
|
97
|
+
teeDiagnosticStream(pending.child.stderr, join(logRoot, "instances", String(binding.browserGeneration), "drivers", id, commandId + ".stderr.log"));
|
|
98
|
+
stdout = (await pending).stdout.toString("utf8");
|
|
99
|
+
event("command.exit", { commandId });
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
event("command.error", { commandId, code: error.code });
|
|
103
|
+
const failure = error;
|
|
104
|
+
if (failure.killed || !failure.stdout)
|
|
105
|
+
throw new PageAutomationError("outcome_unknown", "Browser driver did not return a complete response; do not replay the action", { cause: error });
|
|
106
|
+
stdout = failure.stdout.toString();
|
|
107
|
+
}
|
|
108
|
+
if (initialized && process.platform !== "win32" && !await ownerCurrent()) {
|
|
109
|
+
await captureOwner();
|
|
110
|
+
throw new PageAutomationError("driver_restarted", "Browser helper process changed; take a new snapshot before further actions");
|
|
111
|
+
}
|
|
112
|
+
if (args.includes("--help") || args.includes("-h") || args[0] === "help" || args[0] === "--version" || args[0] === "-V")
|
|
113
|
+
return { help: stdout };
|
|
114
|
+
let result;
|
|
115
|
+
try {
|
|
116
|
+
result = JSON.parse(stdout);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
throw new PageAutomationError("outcome_unknown", "Browser driver returned an invalid response; do not replay the action", { cause: error });
|
|
120
|
+
}
|
|
121
|
+
if (result.success !== true || !result.data || typeof result.data !== "object") {
|
|
122
|
+
// Return bounded upstream diagnostics to the authorized caller only;
|
|
123
|
+
// never write command arguments or response bodies to daemon logs.
|
|
124
|
+
throw new PageAutomationError("command_failed", typeof result.error === "string" ? result.error.slice(0, 2048) : "agent-browser command failed; run browser exec -- --help", { cause: result.error });
|
|
125
|
+
}
|
|
126
|
+
const lifecycle = result.data.lifecycle;
|
|
127
|
+
if (initialized && (lifecycle?.launched || lifecycle?.restartedBackground || lifecycle?.relaunchedBrowser)) {
|
|
128
|
+
await captureOwner();
|
|
129
|
+
throw new PageAutomationError("driver_restarted", "Browser helper restarted; run browser exec -- snapshot -i again");
|
|
130
|
+
}
|
|
131
|
+
return result.data;
|
|
132
|
+
};
|
|
133
|
+
const cleanup = async () => {
|
|
134
|
+
initialized = false;
|
|
135
|
+
if (!owner)
|
|
136
|
+
await captureOwner();
|
|
137
|
+
// Do not launch a new helper merely to close a helper that already exited.
|
|
138
|
+
if (await ownerAlive() || process.platform === "win32")
|
|
139
|
+
await invoke(["close"], true, 5_000).catch(() => undefined);
|
|
140
|
+
if (await ownerAlive()) {
|
|
141
|
+
const signalOwner = (signal) => {
|
|
142
|
+
try {
|
|
143
|
+
process.kill(owner.pid, signal);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error.code !== "ESRCH")
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
signalOwner("SIGTERM");
|
|
151
|
+
for (let n = 0; n < 20 && await ownerAlive(); n++)
|
|
152
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
153
|
+
if (await ownerAlive()) {
|
|
154
|
+
signalOwner("SIGKILL");
|
|
155
|
+
for (let n = 0; n < 20 && await ownerAlive(); n++)
|
|
156
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
157
|
+
if (await ownerAlive())
|
|
158
|
+
throw new Error("Owned browser helper did not terminate");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
await connection.close();
|
|
162
|
+
await rm(directory, { recursive: true, force: true });
|
|
163
|
+
for (const extension of ["pid", "sock", "port", "version", "config", "target"])
|
|
164
|
+
await rm(join(root, id + "." + extension), { force: true });
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
if (!binding.isCurrent())
|
|
168
|
+
throw new PageAutomationError("not_found", "Browser Page closed before attachment");
|
|
169
|
+
await invoke(["tab", binding.pageTargetId], false);
|
|
170
|
+
await captureOwner();
|
|
171
|
+
initialized = true;
|
|
172
|
+
// Establish the sticky pin before the first command; this also detects a
|
|
173
|
+
// helper crash between initial target selection and the first user action.
|
|
174
|
+
await invoke(["tab", "list"], true);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
try {
|
|
178
|
+
await cleanup();
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
await connection.close();
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
const execute = async (args, beforeInvoke) => {
|
|
186
|
+
if (closed || !binding.isCurrent())
|
|
187
|
+
throw new PageAutomationError("not_found", "Browser Page is no longer available");
|
|
188
|
+
if (process.platform !== "win32" && !await ownerCurrent())
|
|
189
|
+
throw new PageAutomationError("driver_restarted", "Browser helper process ended; take a new snapshot before further actions");
|
|
190
|
+
// Read-only preflight catches implicit helper restarts before any write.
|
|
191
|
+
const inventory = await invoke(["tab", "list"], true);
|
|
192
|
+
const tabs = inventory.tabs;
|
|
193
|
+
if (!Array.isArray(tabs) || !tabs.some((tab) => tab.active === true && tab.targetId === binding.pageTargetId)) {
|
|
194
|
+
throw new PageAutomationError("not_found", "Browser helper is no longer bound to the requested Page");
|
|
195
|
+
}
|
|
196
|
+
if (!binding.isCurrent())
|
|
197
|
+
throw new PageAutomationError("not_found", "Browser Page closed before command admission");
|
|
198
|
+
beforeInvoke?.();
|
|
199
|
+
return invoke(args, true);
|
|
200
|
+
};
|
|
201
|
+
return {
|
|
202
|
+
version,
|
|
203
|
+
execute,
|
|
204
|
+
async close() {
|
|
205
|
+
if (closed)
|
|
206
|
+
return;
|
|
207
|
+
closed = true;
|
|
208
|
+
try {
|
|
209
|
+
await cleanup();
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await connection.close();
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { PageAutomationError } from "./page-agent-browser-driver.js";
|
|
2
|
+
// Ownership boundary, not an allowlist of page commands or page options.
|
|
3
|
+
const OWNER_OPTIONS = new Set([
|
|
4
|
+
"--cdp", "--session", "--session-name", "--namespace", "--config", "--provider", "-p",
|
|
5
|
+
"--auto-connect", "--pin-tab", "--no-pin-tab", "--executable-path", "--profile",
|
|
6
|
+
"--state", "--args", "--engine", "--plugins", "--extension", "--extensions",
|
|
7
|
+
"--enable", "--init-script", "--headed", "--idle-timeout", "--proxy",
|
|
8
|
+
"--proxy-bypass", "--proxy-username", "--proxy-password", "--headers",
|
|
9
|
+
"--allowed-domains", "--action-policy", "--confirm-actions", "--device",
|
|
10
|
+
"--auto-dialog", "--restore-save", "--restore-check-url", "--restore-check-text",
|
|
11
|
+
"--restore-check-fn", "--restore", "--allow-file-access", "--ignore-https-errors",
|
|
12
|
+
"--user-agent", "--color-scheme", "--webgpu", "--no-xvfb", "--json",
|
|
13
|
+
]);
|
|
14
|
+
const OWNER_COMMANDS = new Set([
|
|
15
|
+
"connect", "attach", "disconnect", "close", "quit", "exit", "install", "uninstall",
|
|
16
|
+
"update", "upgrade", "session", "tab", "window", "batch", "run", "recipe",
|
|
17
|
+
"dashboard", "auth", "plugin", "plugins", "doctor",
|
|
18
|
+
]);
|
|
19
|
+
export function assertPageAgentBrowserArguments(argv) {
|
|
20
|
+
for (const arg of argv) {
|
|
21
|
+
// Match the native parser, not arbitrary option-looking page text. In
|
|
22
|
+
// 0.36, --session=value is literal text; --restore=value is a real flag.
|
|
23
|
+
if (OWNER_OPTIONS.has(arg) || arg.startsWith("--restore="))
|
|
24
|
+
throw new PageAutomationError("invalid_request", "Rynx owns Browser connection, configuration and Session options; remove the override");
|
|
25
|
+
}
|
|
26
|
+
// Command-first means we need not duplicate upstream's evolving option arities.
|
|
27
|
+
const command = argv[0];
|
|
28
|
+
if (!command || (command.startsWith("-") && !["--help", "-h", "--version", "-V"].includes(command)))
|
|
29
|
+
throw new PageAutomationError("invalid_request", "Put the agent-browser command first, followed by its arguments and options");
|
|
30
|
+
if (OWNER_COMMANDS.has(command) || (command === "state" && argv[1] !== "save"))
|
|
31
|
+
throw new PageAutomationError("invalid_request", "This command changes helper/Browser ownership; use rynx browser resource commands instead");
|
|
32
|
+
}
|
|
33
|
+
/** Unknown commands pass through, but conservatively participate as writes. */
|
|
34
|
+
export function isPageAgentBrowserRead(argv) {
|
|
35
|
+
return argv.includes("--help") || argv.includes("-h") ||
|
|
36
|
+
["--version", "-V", "help", "snapshot", "screenshot", "get", "is", "frame", "mainframe"].includes(argv[0]);
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SessionBrowserAutomationBinding, SessionBrowserService } from "@rynx-ai/server";
|
|
2
|
+
import { type RuntimeBrowserAutomationCommand, type RuntimeBrowserAutomationResult } from "@rynx-ai/protocol/runtime-browser-bootstrap";
|
|
3
|
+
import { type PageAutomationDriver } from "./page-agent-browser-driver.js";
|
|
4
|
+
export interface PageAutomationManagerOptions {
|
|
5
|
+
createDriver?: (root: string, binding: SessionBrowserAutomationBinding) => Promise<PageAutomationDriver>;
|
|
6
|
+
idleMs?: number;
|
|
7
|
+
isHumanControlled?: (binding: SessionBrowserAutomationBinding) => boolean;
|
|
8
|
+
onCleanupError?: (error: unknown) => void;
|
|
9
|
+
}
|
|
10
|
+
/** The Runtime owns Page routing and queues. The upstream CLI owns argv, refs and results. */
|
|
11
|
+
export declare class PageAutomationManager {
|
|
12
|
+
private readonly browsers;
|
|
13
|
+
private readonly options;
|
|
14
|
+
private readonly runs;
|
|
15
|
+
private readonly browserTails;
|
|
16
|
+
private root?;
|
|
17
|
+
private stopped;
|
|
18
|
+
private readonly timer;
|
|
19
|
+
constructor(browsers: Pick<SessionBrowserService, "resolveAutomationPage">, options?: PageAutomationManagerOptions);
|
|
20
|
+
execute(sessionId: string, input: RuntimeBrowserAutomationCommand): Promise<RuntimeBrowserAutomationResult>;
|
|
21
|
+
private assertCurrent;
|
|
22
|
+
private assertAdmission;
|
|
23
|
+
private enqueueBrowser;
|
|
24
|
+
private reportCleanupError;
|
|
25
|
+
private directory;
|
|
26
|
+
private retire;
|
|
27
|
+
private sweep;
|
|
28
|
+
shutdown(): Promise<void>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseRuntimeBrowserAutomationCommand } from "@rynx-ai/protocol/runtime-browser-bootstrap";
|
|
5
|
+
import { createPageAgentBrowserDriver, PageAutomationError } from "./page-agent-browser-driver.js";
|
|
6
|
+
import { assertPageAgentBrowserArguments, isPageAgentBrowserRead } from "./page-agent-browser-policy.js";
|
|
7
|
+
/** The Runtime owns Page routing and queues. The upstream CLI owns argv, refs and results. */
|
|
8
|
+
export class PageAutomationManager {
|
|
9
|
+
browsers;
|
|
10
|
+
options;
|
|
11
|
+
runs = new Map();
|
|
12
|
+
browserTails = new Map();
|
|
13
|
+
root;
|
|
14
|
+
stopped = false;
|
|
15
|
+
timer;
|
|
16
|
+
constructor(browsers, options = {}) {
|
|
17
|
+
this.browsers = browsers;
|
|
18
|
+
this.options = options;
|
|
19
|
+
this.timer = setInterval(() => { void this.sweep(); }, 2_000);
|
|
20
|
+
this.timer.unref?.();
|
|
21
|
+
}
|
|
22
|
+
async execute(sessionId, input) {
|
|
23
|
+
const command = parseRuntimeBrowserAutomationCommand(input);
|
|
24
|
+
assertPageAgentBrowserArguments(command.argv);
|
|
25
|
+
if (this.stopped)
|
|
26
|
+
throw new PageAutomationError("not_found", "Browser automation is stopping");
|
|
27
|
+
// Pin before queueing: a UI tab switch cannot redirect a queued command.
|
|
28
|
+
const binding = await this.browsers.resolveAutomationPage(sessionId, command.pageId);
|
|
29
|
+
const key = JSON.stringify([sessionId, binding.browserGeneration, binding.pageId, binding.pageTargetId, binding.endpoint]);
|
|
30
|
+
let run = this.runs.get(key);
|
|
31
|
+
if (!run || run.retired) {
|
|
32
|
+
if (this.runs.size >= 64)
|
|
33
|
+
throw new PageAutomationError("capacity", "Browser automation page capacity reached");
|
|
34
|
+
run = { binding, tail: Promise.resolve(), pending: 0, lastUsed: Date.now(), retired: false };
|
|
35
|
+
this.runs.set(key, run);
|
|
36
|
+
}
|
|
37
|
+
const selected = run;
|
|
38
|
+
if (selected.pending >= 32)
|
|
39
|
+
throw new PageAutomationError("capacity", "Browser Page command queue is full");
|
|
40
|
+
selected.pending++;
|
|
41
|
+
let submittedMutation = false;
|
|
42
|
+
const isRead = isPageAgentBrowserRead(command.argv);
|
|
43
|
+
const result = this.enqueueBrowser(binding, async () => {
|
|
44
|
+
let visibility;
|
|
45
|
+
try {
|
|
46
|
+
this.assertAdmission(selected, isRead);
|
|
47
|
+
visibility = await selected.binding.acquireAutomationVisibility(() => !this.options.isHumanControlled?.(selected.binding));
|
|
48
|
+
this.assertAdmission(selected, isRead);
|
|
49
|
+
if (!selected.driver) {
|
|
50
|
+
const driver = await (this.options.createDriver ?? createPageAgentBrowserDriver)(await this.directory(), selected.binding);
|
|
51
|
+
if (selected.retired || this.stopped) {
|
|
52
|
+
await driver.close();
|
|
53
|
+
this.assertCurrent(selected);
|
|
54
|
+
}
|
|
55
|
+
selected.driver = driver;
|
|
56
|
+
}
|
|
57
|
+
this.assertAdmission(selected, isRead);
|
|
58
|
+
const args = command.argv;
|
|
59
|
+
const data = await selected.driver.execute(args, () => {
|
|
60
|
+
// Re-check takeover after helper preflight without disabling human-safe reads.
|
|
61
|
+
this.assertAdmission(selected, isRead);
|
|
62
|
+
submittedMutation = !isRead;
|
|
63
|
+
});
|
|
64
|
+
this.assertCurrent(selected);
|
|
65
|
+
return {
|
|
66
|
+
schemaVersion: 2, completed: true, sessionId,
|
|
67
|
+
browserGeneration: selected.binding.browserGeneration, pageId: selected.binding.pageId,
|
|
68
|
+
...(selected.driver.version ? { driverVersion: selected.driver.version } : {}), data,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// Retire uncertain helpers before releasing shared rendering; never replay a command.
|
|
73
|
+
if (!(error instanceof PageAutomationError) || ["outcome_unknown", "driver_restarted", "not_found"].includes(error.code))
|
|
74
|
+
await this.retire(key, selected);
|
|
75
|
+
if (submittedMutation && (!(error instanceof PageAutomationError) || ["not_found", "driver_restarted"].includes(error.code)))
|
|
76
|
+
throw new PageAutomationError("outcome_unknown", "Browser action may have completed; inspect a new snapshot and do not replay it", { cause: error });
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await visibility?.release(!this.options.isHumanControlled?.(selected.binding)).catch((error) => this.reportCleanupError(error));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
selected.tail = result.then(() => undefined, () => undefined);
|
|
84
|
+
try {
|
|
85
|
+
return await result;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
selected.pending--;
|
|
89
|
+
selected.lastUsed = Date.now();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
assertCurrent(run) {
|
|
93
|
+
if (this.stopped || run.retired || !run.binding.isCurrent())
|
|
94
|
+
throw new PageAutomationError("not_found", "Browser Page closed or was replaced; run browser pages");
|
|
95
|
+
}
|
|
96
|
+
assertAdmission(run, isRead) {
|
|
97
|
+
this.assertCurrent(run);
|
|
98
|
+
if (!isRead && this.options.isHumanControlled?.(run.binding))
|
|
99
|
+
throw new PageAutomationError("human_control_active", "Release Browser Control before Agent actions");
|
|
100
|
+
}
|
|
101
|
+
enqueueBrowser(binding, execute) {
|
|
102
|
+
const key = JSON.stringify([binding.sessionId, binding.browserGeneration]);
|
|
103
|
+
const result = (this.browserTails.get(key) ?? Promise.resolve()).then(execute);
|
|
104
|
+
const tail = result.then(() => undefined, () => undefined);
|
|
105
|
+
this.browserTails.set(key, tail);
|
|
106
|
+
void tail.then(() => { if (this.browserTails.get(key) === tail)
|
|
107
|
+
this.browserTails.delete(key); });
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
reportCleanupError(error) {
|
|
111
|
+
try {
|
|
112
|
+
this.options.onCleanupError?.(error);
|
|
113
|
+
}
|
|
114
|
+
catch { /* Preserve the command outcome. */ }
|
|
115
|
+
}
|
|
116
|
+
directory() {
|
|
117
|
+
return this.root ??= mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "rynx-ab-"));
|
|
118
|
+
}
|
|
119
|
+
async retire(key, run) {
|
|
120
|
+
if (run.retired)
|
|
121
|
+
return;
|
|
122
|
+
run.retired = true;
|
|
123
|
+
if (this.runs.get(key) === run)
|
|
124
|
+
this.runs.delete(key);
|
|
125
|
+
try {
|
|
126
|
+
await run.driver?.close();
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
this.reportCleanupError(error);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async sweep() {
|
|
133
|
+
await Promise.all([...this.runs].filter(([, run]) => !run.binding.isCurrent() || (run.pending === 0 && Date.now() - run.lastUsed > (this.options.idleMs ?? 600_000))).map(([key, run]) => this.retire(key, run)));
|
|
134
|
+
}
|
|
135
|
+
async shutdown() {
|
|
136
|
+
this.stopped = true;
|
|
137
|
+
clearInterval(this.timer);
|
|
138
|
+
const runs = [...this.runs];
|
|
139
|
+
await Promise.all(runs.map(([key, run]) => this.retire(key, run)));
|
|
140
|
+
await Promise.all(runs.map(([, run]) => run.tail));
|
|
141
|
+
if (this.root)
|
|
142
|
+
await rm(await this.root, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
4
|
+
const CHILD_TYPES = new Set(["iframe", "worker", "service_worker", "shared_worker"]);
|
|
5
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
const TARGET_METHODS = new Set(["Target.getTargets", "Target.getTargetInfo", "Target.setDiscoverTargets", "Target.setAutoAttach", "Target.attachToTarget", "Target.detachFromTarget", "Target.activateTarget"]);
|
|
7
|
+
const BROWSER_METHODS = new Set(["Browser.getVersion", "Browser.getWindowForTarget"]);
|
|
8
|
+
/** A capability for one existing native target, never the whole Chrome process. */
|
|
9
|
+
export async function createPageCdpGateway(upstreamEndpoint, targetId, isCurrent) {
|
|
10
|
+
const path = "/devtools/browser/" + randomBytes(18).toString("hex");
|
|
11
|
+
let endpoint = "";
|
|
12
|
+
let stopped = false;
|
|
13
|
+
let downstream;
|
|
14
|
+
let upstream;
|
|
15
|
+
// The daemon hands the nonce URL directly to its helper. Never publish that
|
|
16
|
+
// capability on an unauthenticated discovery endpoint.
|
|
17
|
+
const server = createServer((_request, response) => { response.writeHead(404).end(); });
|
|
18
|
+
const sockets = new WebSocketServer({ noServer: true, maxPayload: MAX_BYTES });
|
|
19
|
+
server.on("upgrade", (request, socket, head) => {
|
|
20
|
+
if (stopped || !isCurrent() || request.url !== path || downstream ||
|
|
21
|
+
request.headers.origin !== undefined || request.headers.host !== new URL(endpoint).host) {
|
|
22
|
+
socket.destroy();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
sockets.handleUpgrade(request, socket, head, (client) => sockets.emit("connection", client));
|
|
26
|
+
});
|
|
27
|
+
sockets.on("connection", (client) => {
|
|
28
|
+
downstream = client;
|
|
29
|
+
const native = new WebSocket(upstreamEndpoint, { maxPayload: MAX_BYTES, handshakeTimeout: 5_000 });
|
|
30
|
+
upstream = native;
|
|
31
|
+
const sessions = new Set();
|
|
32
|
+
const children = new Map();
|
|
33
|
+
const unacknowledged = new Set();
|
|
34
|
+
const pending = new Map();
|
|
35
|
+
const early = [];
|
|
36
|
+
const send = (message) => { if (client.readyState === WebSocket.OPEN)
|
|
37
|
+
client.send(JSON.stringify(message)); };
|
|
38
|
+
const reply = (request, result = {}) => send({ id: request.id, ...(request.sessionId ? { sessionId: request.sessionId } : {}), result });
|
|
39
|
+
const close = () => {
|
|
40
|
+
if (downstream === client)
|
|
41
|
+
downstream = undefined;
|
|
42
|
+
if (upstream === native)
|
|
43
|
+
upstream = undefined;
|
|
44
|
+
client.terminate();
|
|
45
|
+
native.terminate();
|
|
46
|
+
};
|
|
47
|
+
client.on("close", close);
|
|
48
|
+
client.on("error", close);
|
|
49
|
+
native.on("close", close);
|
|
50
|
+
native.on("error", close);
|
|
51
|
+
native.on("open", () => { for (const message of early)
|
|
52
|
+
native.send(message); early.length = 0; });
|
|
53
|
+
client.on("message", (bytes, binary) => {
|
|
54
|
+
let request;
|
|
55
|
+
try {
|
|
56
|
+
if (binary)
|
|
57
|
+
throw new Error();
|
|
58
|
+
request = JSON.parse(bytes.toString());
|
|
59
|
+
if (!request || typeof request !== "object" || Array.isArray(request))
|
|
60
|
+
throw new Error();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
close();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
if (!isCurrent() || !Number.isSafeInteger(request.id) || typeof request.method !== "string")
|
|
68
|
+
throw new Error("Page capability expired or invalid command");
|
|
69
|
+
if (request.sessionId && !sessions.has(request.sessionId))
|
|
70
|
+
throw new Error("Unknown Page child session");
|
|
71
|
+
const method = request.method;
|
|
72
|
+
const params = request.params ?? {};
|
|
73
|
+
if (method === "Browser.close") {
|
|
74
|
+
reply(request);
|
|
75
|
+
client.close(1000, "helper disconnected");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if ((method.startsWith("Target.") && !TARGET_METHODS.has(method)) ||
|
|
79
|
+
(method.startsWith("Browser.") && !BROWSER_METHODS.has(method)) ||
|
|
80
|
+
(!request.sessionId && !TARGET_METHODS.has(method) && !BROWSER_METHODS.has(method)))
|
|
81
|
+
throw new Error("Rynx owns Browser-wide CDP and Page lifecycle");
|
|
82
|
+
if (method === "Target.setAutoAttach") {
|
|
83
|
+
if (!request.sessionId) {
|
|
84
|
+
reply(request);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
request.params = { ...params, flatten: true, filter: [...CHILD_TYPES].map((type) => ({ type })).concat([{ exclude: true }]) };
|
|
88
|
+
}
|
|
89
|
+
if (method === "Target.getTargets")
|
|
90
|
+
request.params = {};
|
|
91
|
+
if (["Target.attachToTarget", "Target.getTargetInfo", "Target.activateTarget", "Browser.getWindowForTarget"].includes(method)) {
|
|
92
|
+
if (params.targetId !== undefined && params.targetId !== targetId && ![...children.values()].some((event) => event.params?.targetInfo?.targetId === params.targetId))
|
|
93
|
+
throw new Error("Target is outside this Page");
|
|
94
|
+
request.params = { ...params, targetId: params.targetId ?? targetId };
|
|
95
|
+
}
|
|
96
|
+
if (method === "Target.detachFromTarget" && !sessions.has(params.sessionId))
|
|
97
|
+
throw new Error("Unknown Page child session");
|
|
98
|
+
// Physical activation belongs to the render lease, not helper tab pinning.
|
|
99
|
+
if (method === "Target.activateTarget" || method === "Page.bringToFront") {
|
|
100
|
+
reply(request);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (request.sessionId && /^(Runtime|Network|DOM|Accessibility)\.enable$/.test(method))
|
|
104
|
+
unacknowledged.delete(request.sessionId);
|
|
105
|
+
// Some clients subscribe only after connect has enabled auto-attach.
|
|
106
|
+
// Replay unacknowledged child attachments before a later command; the
|
|
107
|
+
// first child domain enable acknowledges ownership. Never resume a pause.
|
|
108
|
+
if (method === "Runtime.evaluate")
|
|
109
|
+
for (const sid of unacknowledged) {
|
|
110
|
+
const event = children.get(sid);
|
|
111
|
+
if (event)
|
|
112
|
+
send({ ...event, params: { ...event.params, waitingForDebugger: false } });
|
|
113
|
+
}
|
|
114
|
+
if (pending.size >= 256 || pending.has(request.id))
|
|
115
|
+
throw new Error("Page CDP command capacity reached");
|
|
116
|
+
pending.set(request.id, request);
|
|
117
|
+
const serialized = JSON.stringify(request);
|
|
118
|
+
if (native.readyState === WebSocket.OPEN)
|
|
119
|
+
native.send(serialized);
|
|
120
|
+
else if (native.readyState === WebSocket.CONNECTING && early.length < 64)
|
|
121
|
+
early.push(serialized);
|
|
122
|
+
else {
|
|
123
|
+
pending.delete(request.id);
|
|
124
|
+
throw new Error("Page CDP is disconnected");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
send({ id: request.id, ...(request.sessionId ? { sessionId: request.sessionId } : {}), error: { code: -32000, message: error instanceof Error ? error.message : "Page command rejected" } });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
native.on("message", (bytes, binary) => {
|
|
132
|
+
let message;
|
|
133
|
+
try {
|
|
134
|
+
if (binary)
|
|
135
|
+
throw new Error();
|
|
136
|
+
message = JSON.parse(bytes.toString());
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
close();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (message.id !== undefined) {
|
|
143
|
+
const request = pending.get(message.id);
|
|
144
|
+
pending.delete(message.id);
|
|
145
|
+
if (!request)
|
|
146
|
+
return;
|
|
147
|
+
if (request.method === "Target.getTargets" && message.result)
|
|
148
|
+
message.result.targetInfos = (message.result.targetInfos ?? []).filter((target) => target.targetId === targetId);
|
|
149
|
+
if (request.method === "Target.attachToTarget" && typeof message.result?.sessionId === "string")
|
|
150
|
+
sessions.add(message.result.sessionId);
|
|
151
|
+
send(message);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const params = message.params ?? {};
|
|
155
|
+
if (message.method === "Target.attachedToTarget") {
|
|
156
|
+
const info = params.targetInfo ?? {};
|
|
157
|
+
if (info.targetId !== targetId && (!message.sessionId || !sessions.has(message.sessionId) || !CHILD_TYPES.has(info.type)))
|
|
158
|
+
return;
|
|
159
|
+
if (typeof params.sessionId !== "string" || sessions.size >= 256) {
|
|
160
|
+
close();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
sessions.add(params.sessionId);
|
|
164
|
+
if (info.targetId !== targetId) {
|
|
165
|
+
children.set(params.sessionId, message);
|
|
166
|
+
unacknowledged.add(params.sessionId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (message.method === "Target.detachedFromTarget") {
|
|
170
|
+
if (!sessions.delete(params.sessionId))
|
|
171
|
+
return;
|
|
172
|
+
children.delete(params.sessionId);
|
|
173
|
+
unacknowledged.delete(params.sessionId);
|
|
174
|
+
}
|
|
175
|
+
else if (message.method?.startsWith("Target.target")) {
|
|
176
|
+
if ((params.targetInfo?.targetId ?? params.targetId) !== targetId)
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
else if (message.sessionId && !sessions.has(message.sessionId))
|
|
180
|
+
return;
|
|
181
|
+
else if (!message.sessionId)
|
|
182
|
+
return;
|
|
183
|
+
send(message);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
await new Promise((resolve, reject) => {
|
|
187
|
+
server.once("error", reject);
|
|
188
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
|
|
189
|
+
});
|
|
190
|
+
const address = server.address();
|
|
191
|
+
if (!address || typeof address === "string")
|
|
192
|
+
throw new Error("Page CDP did not bind");
|
|
193
|
+
endpoint = "ws://127.0.0.1:" + address.port + path;
|
|
194
|
+
return {
|
|
195
|
+
endpoint,
|
|
196
|
+
async close() {
|
|
197
|
+
if (stopped)
|
|
198
|
+
return;
|
|
199
|
+
stopped = true;
|
|
200
|
+
downstream?.terminate();
|
|
201
|
+
upstream?.terminate();
|
|
202
|
+
await Promise.all([new Promise((r) => sockets.close(() => r())), new Promise((r) => server.close(() => r()))]);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|