@celestea/tools 2.7.1
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/LICENSE +21 -0
- package/dist/args.d.ts +15 -0
- package/dist/args.js +55 -0
- package/dist/attachments/image-header.d.ts +28 -0
- package/dist/attachments/image-header.js +148 -0
- package/dist/attachments/store.d.ts +56 -0
- package/dist/attachments/store.js +148 -0
- package/dist/browser/cdp.d.ts +162 -0
- package/dist/browser/cdp.js +269 -0
- package/dist/browser/launch.d.ts +92 -0
- package/dist/browser/launch.js +232 -0
- package/dist/browser/memory-guard.d.ts +55 -0
- package/dist/browser/memory-guard.js +210 -0
- package/dist/browser/session.d.ts +146 -0
- package/dist/browser/session.js +371 -0
- package/dist/browser/snapshot.d.ts +69 -0
- package/dist/browser/snapshot.js +163 -0
- package/dist/browser/types.d.ts +51 -0
- package/dist/browser/types.js +8 -0
- package/dist/builtin.d.ts +54 -0
- package/dist/builtin.js +77 -0
- package/dist/desc.d.ts +20 -0
- package/dist/desc.js +22 -0
- package/dist/disclosure.d.ts +113 -0
- package/dist/disclosure.js +141 -0
- package/dist/env.d.ts +10 -0
- package/dist/env.js +33 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +60 -0
- package/dist/exposure.d.ts +145 -0
- package/dist/exposure.js +244 -0
- package/dist/fn-tool.d.ts +14 -0
- package/dist/fn-tool.js +14 -0
- package/dist/fs/file-io.d.ts +78 -0
- package/dist/fs/file-io.js +239 -0
- package/dist/guard/path-guard.d.ts +144 -0
- package/dist/guard/path-guard.js +289 -0
- package/dist/guard/paths.d.ts +35 -0
- package/dist/guard/paths.js +100 -0
- package/dist/http/errors.d.ts +24 -0
- package/dist/http/errors.js +64 -0
- package/dist/http/headers.d.ts +19 -0
- package/dist/http/headers.js +62 -0
- package/dist/http/redirects.d.ts +31 -0
- package/dist/http/redirects.js +76 -0
- package/dist/http/ssrf.d.ts +105 -0
- package/dist/http/ssrf.js +272 -0
- package/dist/http/transport.d.ts +50 -0
- package/dist/http/transport.js +130 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +129 -0
- package/dist/memory/log.d.ts +75 -0
- package/dist/memory/log.js +157 -0
- package/dist/memory/store.d.ts +47 -0
- package/dist/memory/store.js +61 -0
- package/dist/platform/exec.d.ts +79 -0
- package/dist/platform/exec.js +218 -0
- package/dist/platform/index.d.ts +12 -0
- package/dist/platform/index.js +12 -0
- package/dist/platform/paths.d.ts +51 -0
- package/dist/platform/paths.js +60 -0
- package/dist/platform/quote.d.ts +72 -0
- package/dist/platform/quote.js +102 -0
- package/dist/plugin.d.ts +96 -0
- package/dist/plugin.js +101 -0
- package/dist/process/buffers.d.ts +33 -0
- package/dist/process/buffers.js +86 -0
- package/dist/process/registry.d.ts +98 -0
- package/dist/process/registry.js +282 -0
- package/dist/registry.d.ts +52 -0
- package/dist/registry.js +161 -0
- package/dist/run-code/broker.d.ts +68 -0
- package/dist/run-code/broker.js +465 -0
- package/dist/run-code/limits.d.ts +69 -0
- package/dist/run-code/limits.js +88 -0
- package/dist/run-code/lines.d.ts +69 -0
- package/dist/run-code/lines.js +199 -0
- package/dist/run-code/sdk-ts.d.ts +34 -0
- package/dist/run-code/sdk-ts.js +276 -0
- package/dist/run-code/sdk.d.ts +39 -0
- package/dist/run-code/sdk.js +294 -0
- package/dist/sandbox/async.d.ts +10 -0
- package/dist/sandbox/async.js +26 -0
- package/dist/sandbox/bwrap-argv.d.ts +62 -0
- package/dist/sandbox/bwrap-argv.js +113 -0
- package/dist/sandbox/bwrap.d.ts +94 -0
- package/dist/sandbox/bwrap.js +159 -0
- package/dist/sandbox/child.d.ts +38 -0
- package/dist/sandbox/child.js +98 -0
- package/dist/sandbox/config.d.ts +89 -0
- package/dist/sandbox/config.js +149 -0
- package/dist/sandbox/fake-sandbox.d.ts +57 -0
- package/dist/sandbox/fake-sandbox.js +110 -0
- package/dist/sandbox/launch.d.ts +51 -0
- package/dist/sandbox/launch.js +134 -0
- package/dist/sandbox/limits.d.ts +63 -0
- package/dist/sandbox/limits.js +113 -0
- package/dist/sandbox/probe.d.ts +46 -0
- package/dist/sandbox/probe.js +102 -0
- package/dist/sandbox/provider.d.ts +83 -0
- package/dist/sandbox/provider.js +126 -0
- package/dist/sandbox/rlimit.d.ts +60 -0
- package/dist/sandbox/rlimit.js +76 -0
- package/dist/sandbox/seccomp.d.ts +48 -0
- package/dist/sandbox/seccomp.js +115 -0
- package/dist/sandbox/userspace.d.ts +65 -0
- package/dist/sandbox/userspace.js +107 -0
- package/dist/sandbox/workdir.d.ts +13 -0
- package/dist/sandbox/workdir.js +44 -0
- package/dist/schema.d.ts +20 -0
- package/dist/schema.js +135 -0
- package/dist/testing/platform-gates.d.ts +54 -0
- package/dist/testing/platform-gates.js +62 -0
- package/dist/tool-failure.d.ts +13 -0
- package/dist/tool-failure.js +19 -0
- package/dist/tools/ask-user.d.ts +32 -0
- package/dist/tools/ask-user.js +145 -0
- package/dist/tools/browser.d.ts +24 -0
- package/dist/tools/browser.js +132 -0
- package/dist/tools/http-request.d.ts +31 -0
- package/dist/tools/http-request.js +117 -0
- package/dist/tools/list-dir.d.ts +9 -0
- package/dist/tools/list-dir.js +45 -0
- package/dist/tools/load-skill.d.ts +37 -0
- package/dist/tools/load-skill.js +76 -0
- package/dist/tools/memory.d.ts +46 -0
- package/dist/tools/memory.js +131 -0
- package/dist/tools/process-control.d.ts +14 -0
- package/dist/tools/process-control.js +49 -0
- package/dist/tools/read-file.d.ts +11 -0
- package/dist/tools/read-file.js +81 -0
- package/dist/tools/read-image.d.ts +33 -0
- package/dist/tools/read-image.js +144 -0
- package/dist/tools/run-code.d.ts +48 -0
- package/dist/tools/run-code.js +115 -0
- package/dist/tools/run-shell.d.ts +22 -0
- package/dist/tools/run-shell.js +81 -0
- package/dist/tools/write-file.d.ts +8 -0
- package/dist/tools/write-file.js +31 -0
- package/package.json +28 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Chrome DevTools Protocol client (F4 slice 1).
|
|
3
|
+
*
|
|
4
|
+
* Design: the TRANSPORT is a seam. \`WebSocketTransport\` is the production
|
|
5
|
+
* implementation (Node's global WebSocket), while a test supplies an in-memory
|
|
6
|
+
* fake and never needs a browser or a WebSocket server. This file owns request
|
|
7
|
+
* id correlation, response/error settling, event fan-out, per-request
|
|
8
|
+
* deadlines and close semantics.
|
|
9
|
+
*
|
|
10
|
+
* Only the domains the F4 tools need are wrapped; \`send\` stays public for
|
|
11
|
+
* anything else. Zero npm dependencies.
|
|
12
|
+
*/
|
|
13
|
+
/** Default per-request deadline. */
|
|
14
|
+
export const DEFAULT_CDP_TIMEOUT_MS = 15_000;
|
|
15
|
+
/** Default WebSocket open deadline. */
|
|
16
|
+
export const DEFAULT_OPEN_TIMEOUT_MS = 10_000;
|
|
17
|
+
/** The transport could not be created / connected / used. */
|
|
18
|
+
export class CdpTransportError extends Error {
|
|
19
|
+
code = "cdp_transport";
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "CdpTransportError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** A request exceeded its deadline. */
|
|
26
|
+
export class CdpTimeoutError extends Error {
|
|
27
|
+
code = "cdp_timeout";
|
|
28
|
+
constructor(method, timeoutMs) {
|
|
29
|
+
super("CDP " + method + " timed out after " + timeoutMs + "ms");
|
|
30
|
+
this.name = "CdpTimeoutError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The browser answered with a protocol error (negative code). */
|
|
34
|
+
export class CdpProtocolError extends Error {
|
|
35
|
+
code;
|
|
36
|
+
data;
|
|
37
|
+
constructor(error) {
|
|
38
|
+
super(error.message);
|
|
39
|
+
this.name = "CdpProtocolError";
|
|
40
|
+
this.code = error.code;
|
|
41
|
+
this.data = error.data;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The client (or its transport) is closed; no further requests are accepted. */
|
|
45
|
+
export class CdpClosedError extends Error {
|
|
46
|
+
code = "cdp_closed";
|
|
47
|
+
constructor() {
|
|
48
|
+
super("CDP client is closed");
|
|
49
|
+
this.name = "CdpClosedError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Production transport over Node's global WebSocket. */
|
|
53
|
+
export class WebSocketTransport {
|
|
54
|
+
socket;
|
|
55
|
+
constructor(socket) {
|
|
56
|
+
this.socket = socket;
|
|
57
|
+
}
|
|
58
|
+
send(data) {
|
|
59
|
+
this.socket.send(data);
|
|
60
|
+
}
|
|
61
|
+
subscribe(handlers) {
|
|
62
|
+
this.socket.addEventListener("message", (event) => handlers.onMessage(stringifyData(event.data)));
|
|
63
|
+
this.socket.addEventListener("error", () => handlers.onError(new CdpTransportError("websocket transport error")));
|
|
64
|
+
this.socket.addEventListener("close", () => handlers.onClose());
|
|
65
|
+
}
|
|
66
|
+
close() {
|
|
67
|
+
try {
|
|
68
|
+
this.socket.close();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// already closed: closing twice is not an error
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function stringifyData(data) {
|
|
76
|
+
return typeof data === "string" ? data : String(data);
|
|
77
|
+
}
|
|
78
|
+
/** The runtime WebSocket constructor, or a structured error when absent. */
|
|
79
|
+
export function defaultWebSocketFactory() {
|
|
80
|
+
const ctor = globalThis.WebSocket;
|
|
81
|
+
if (ctor === undefined)
|
|
82
|
+
throw new CdpTransportError("global WebSocket is unavailable in this Node runtime");
|
|
83
|
+
return (url) => new ctor(url);
|
|
84
|
+
}
|
|
85
|
+
/** Connect and wait for the socket to open (sending before open would throw). */
|
|
86
|
+
export async function openWebSocketTransport(url, options = {}) {
|
|
87
|
+
const factory = options.factory ?? defaultWebSocketFactory();
|
|
88
|
+
const socket = factory(url);
|
|
89
|
+
await waitForOpen(socket, options.openTimeoutMs ?? DEFAULT_OPEN_TIMEOUT_MS);
|
|
90
|
+
return new WebSocketTransport(socket);
|
|
91
|
+
}
|
|
92
|
+
function waitForOpen(socket, timeoutMs) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
let settled = false;
|
|
95
|
+
const finish = (error) => {
|
|
96
|
+
if (settled)
|
|
97
|
+
return;
|
|
98
|
+
settled = true;
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
if (error === null)
|
|
101
|
+
resolve();
|
|
102
|
+
else
|
|
103
|
+
reject(error);
|
|
104
|
+
};
|
|
105
|
+
const timer = setTimeout(() => finish(new CdpTransportError("websocket did not open within " + timeoutMs + "ms")), timeoutMs);
|
|
106
|
+
timer.unref();
|
|
107
|
+
socket.addEventListener("open", () => finish(null));
|
|
108
|
+
socket.addEventListener("error", () => finish(new CdpTransportError("websocket failed to connect")));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** A tiny CDP client over an injected transport. */
|
|
112
|
+
export class CdpClient {
|
|
113
|
+
transport;
|
|
114
|
+
timeoutMs;
|
|
115
|
+
seq = 0;
|
|
116
|
+
pending = new Map();
|
|
117
|
+
handlers = new Map();
|
|
118
|
+
closed = false;
|
|
119
|
+
constructor(options) {
|
|
120
|
+
this.transport = options.transport;
|
|
121
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_CDP_TIMEOUT_MS;
|
|
122
|
+
this.transport.subscribe({
|
|
123
|
+
onMessage: (data) => this.receive(data),
|
|
124
|
+
onError: (error) => this.failAll(error),
|
|
125
|
+
onClose: () => this.failAll(new CdpClosedError()),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/** Send one request; the promise settles on its correlated response. */
|
|
129
|
+
send(method, params = {}, sessionId) {
|
|
130
|
+
if (this.closed)
|
|
131
|
+
return Promise.reject(new CdpClosedError());
|
|
132
|
+
const id = ++this.seq;
|
|
133
|
+
const envelope = { id, method, params };
|
|
134
|
+
if (sessionId !== undefined)
|
|
135
|
+
envelope["sessionId"] = sessionId;
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
this.pending.delete(id);
|
|
139
|
+
reject(new CdpTimeoutError(method, this.timeoutMs));
|
|
140
|
+
}, this.timeoutMs);
|
|
141
|
+
timer.unref();
|
|
142
|
+
this.pending.set(id, { method, resolve, reject, timer });
|
|
143
|
+
this.transport.send(JSON.stringify(envelope));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** Subscribe to a CDP event (method name, or "*" for every event). */
|
|
147
|
+
on(event, handler) {
|
|
148
|
+
const set = this.handlers.get(event) ?? new Set();
|
|
149
|
+
set.add(handler);
|
|
150
|
+
this.handlers.set(event, set);
|
|
151
|
+
return () => set.delete(handler);
|
|
152
|
+
}
|
|
153
|
+
/** Close the transport and reject every in-flight request. */
|
|
154
|
+
close() {
|
|
155
|
+
if (this.closed)
|
|
156
|
+
return;
|
|
157
|
+
this.closed = true;
|
|
158
|
+
try {
|
|
159
|
+
this.transport.close();
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// best effort: the close path must never throw
|
|
163
|
+
}
|
|
164
|
+
this.failAll(new CdpClosedError());
|
|
165
|
+
}
|
|
166
|
+
get isClosed() {
|
|
167
|
+
return this.closed;
|
|
168
|
+
}
|
|
169
|
+
// ---- domain wrappers (only what the F4 tools need) -------------------------
|
|
170
|
+
async createTarget(url) {
|
|
171
|
+
const result = await this.send("Target.createTarget", { url });
|
|
172
|
+
return { targetId: String(result["targetId"]) };
|
|
173
|
+
}
|
|
174
|
+
async attachToTarget(targetId) {
|
|
175
|
+
const result = await this.send("Target.attachToTarget", { targetId, flatten: true });
|
|
176
|
+
return { sessionId: String(result["sessionId"]) };
|
|
177
|
+
}
|
|
178
|
+
async closeTarget(targetId) {
|
|
179
|
+
await this.send("Target.closeTarget", { targetId });
|
|
180
|
+
}
|
|
181
|
+
async pageEnable(sessionId) {
|
|
182
|
+
await this.send("Page.enable", {}, sessionId);
|
|
183
|
+
}
|
|
184
|
+
async runtimeEnable(sessionId) {
|
|
185
|
+
await this.send("Runtime.enable", {}, sessionId);
|
|
186
|
+
}
|
|
187
|
+
async accessibilityEnable(sessionId) {
|
|
188
|
+
await this.send("Accessibility.enable", {}, sessionId);
|
|
189
|
+
}
|
|
190
|
+
async navigate(url, sessionId) {
|
|
191
|
+
return this.send("Page.navigate", { url }, sessionId);
|
|
192
|
+
}
|
|
193
|
+
async evaluate(expression, sessionId, options = {}) {
|
|
194
|
+
const result = await this.send("Runtime.evaluate", { expression, returnByValue: options.returnByValue ?? true, awaitPromise: options.awaitPromise ?? false }, sessionId);
|
|
195
|
+
const remote = result["result"];
|
|
196
|
+
return remote === undefined ? undefined : remote.value;
|
|
197
|
+
}
|
|
198
|
+
async captureScreenshot(sessionId, options = {}) {
|
|
199
|
+
const result = await this.send("Page.captureScreenshot", { format: "png", ...options }, sessionId);
|
|
200
|
+
const data = typeof result["data"] === "string" ? result["data"] : "";
|
|
201
|
+
return { data, bytes: Buffer.byteLength(data, "base64") };
|
|
202
|
+
}
|
|
203
|
+
async dispatchMouseEvent(params, sessionId) {
|
|
204
|
+
await this.send("Input.dispatchMouseEvent", { ...params }, sessionId);
|
|
205
|
+
}
|
|
206
|
+
async insertText(text, sessionId) {
|
|
207
|
+
await this.send("Input.insertText", { text }, sessionId);
|
|
208
|
+
}
|
|
209
|
+
async dispatchKeyEvent(params, sessionId) {
|
|
210
|
+
await this.send("Input.dispatchKeyEvent", { ...params }, sessionId);
|
|
211
|
+
}
|
|
212
|
+
async setDeviceMetricsOverride(metrics, sessionId) {
|
|
213
|
+
await this.send("Emulation.setDeviceMetricsOverride", { deviceScaleFactor: 1, mobile: false, ...metrics }, sessionId);
|
|
214
|
+
}
|
|
215
|
+
async getFullAXTree(sessionId) {
|
|
216
|
+
const result = await this.send("Accessibility.getFullAXTree", {}, sessionId);
|
|
217
|
+
const nodes = result["nodes"];
|
|
218
|
+
return Array.isArray(nodes) ? nodes : [];
|
|
219
|
+
}
|
|
220
|
+
async getBoxModel(backendNodeId, sessionId) {
|
|
221
|
+
const result = await this.send("DOM.getBoxModel", { backendNodeId }, sessionId);
|
|
222
|
+
const model = result["model"];
|
|
223
|
+
return model === undefined ? null : model;
|
|
224
|
+
}
|
|
225
|
+
receive(data) {
|
|
226
|
+
let message;
|
|
227
|
+
try {
|
|
228
|
+
message = JSON.parse(data);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return; // malformed frame: ignore, a transport error would be separate
|
|
232
|
+
}
|
|
233
|
+
if (typeof message["id"] === "number")
|
|
234
|
+
this.settle(message["id"], message);
|
|
235
|
+
else if (typeof message["method"] === "string")
|
|
236
|
+
this.emit(message);
|
|
237
|
+
}
|
|
238
|
+
settle(id, message) {
|
|
239
|
+
const pending = this.pending.get(id);
|
|
240
|
+
if (pending === undefined)
|
|
241
|
+
return;
|
|
242
|
+
this.pending.delete(id);
|
|
243
|
+
clearTimeout(pending.timer);
|
|
244
|
+
const error = message["error"];
|
|
245
|
+
if (error !== undefined)
|
|
246
|
+
pending.reject(new CdpProtocolError(error));
|
|
247
|
+
else
|
|
248
|
+
pending.resolve(message["result"] ?? {});
|
|
249
|
+
}
|
|
250
|
+
emit(message) {
|
|
251
|
+
const method = message["method"];
|
|
252
|
+
const params = message["params"] ?? {};
|
|
253
|
+
const sessionId = typeof message["sessionId"] === "string" ? message["sessionId"] : undefined;
|
|
254
|
+
for (const key of [method, "*"]) {
|
|
255
|
+
const set = this.handlers.get(key);
|
|
256
|
+
if (set === undefined)
|
|
257
|
+
continue;
|
|
258
|
+
for (const handler of [...set])
|
|
259
|
+
handler(params, sessionId);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
failAll(error) {
|
|
263
|
+
for (const pending of this.pending.values()) {
|
|
264
|
+
clearTimeout(pending.timer);
|
|
265
|
+
pending.reject(error);
|
|
266
|
+
}
|
|
267
|
+
this.pending.clear();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser launch / attach (F4 slice 1).
|
|
3
|
+
*
|
|
4
|
+
* Two entry points:
|
|
5
|
+
* - launchBrowser(): spawn chrome-headless-shell with
|
|
6
|
+
* --headless --no-sandbox --remote-debugging-port=0 --user-data-dir=<tmp>,
|
|
7
|
+
* read the ws:// DevTools endpoint off stderr, and return a handle whose
|
|
8
|
+
* close() reclaims the whole process tree and removes the profile dir.
|
|
9
|
+
* - attachBrowser(): connect to an externally supplied --cdp-endpoint (future
|
|
10
|
+
* real Chrome / a browser the operator started).
|
|
11
|
+
*
|
|
12
|
+
* The executable path, the process spawner and the transport opener are all
|
|
13
|
+
* injectable, so the module is unit-testable without a browser and can grow a
|
|
14
|
+
* platform-specific locator later without touching this logic.
|
|
15
|
+
*/
|
|
16
|
+
import { CdpClient, type CdpTransport, type OpenTransportOptions } from "./cdp.js";
|
|
17
|
+
/** Default time to wait for the DevTools endpoint. */
|
|
18
|
+
export declare const DEFAULT_STARTUP_TIMEOUT_MS = 15000;
|
|
19
|
+
/** Default grace between SIGTERM and SIGKILL when closing. */
|
|
20
|
+
export declare const DEFAULT_SHUTDOWN_GRACE_MS = 3000;
|
|
21
|
+
/** Flags every launched browser gets (callers append via extraArgs). */
|
|
22
|
+
export declare const DEFAULT_BROWSER_ARGS: readonly string[];
|
|
23
|
+
/** The stderr marker that carries the endpoint. */
|
|
24
|
+
export declare const ENDPOINT_MARKER = "DevTools listening on ";
|
|
25
|
+
/** No headless-shell executable could be located. */
|
|
26
|
+
export declare class BrowserNotFoundError extends Error {
|
|
27
|
+
readonly code = "browser_not_found";
|
|
28
|
+
constructor(message: string);
|
|
29
|
+
}
|
|
30
|
+
/** The browser died (or stayed silent) before it printed an endpoint. */
|
|
31
|
+
export declare class BrowserStartupError extends Error {
|
|
32
|
+
readonly code = "browser_startup";
|
|
33
|
+
readonly stderrTail: string;
|
|
34
|
+
constructor(message: string, stderrTail: string);
|
|
35
|
+
}
|
|
36
|
+
/** The minimal process surface launch/close needs (spawn is injectable). */
|
|
37
|
+
export interface BrowserProcess {
|
|
38
|
+
pid?: number | undefined;
|
|
39
|
+
stderr: {
|
|
40
|
+
on(event: string, listener: (chunk: unknown) => void): unknown;
|
|
41
|
+
} | null;
|
|
42
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
43
|
+
once(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
44
|
+
}
|
|
45
|
+
/** How a browser process is created (tests inject a fake). */
|
|
46
|
+
export type BrowserSpawn = (program: string, args: readonly string[]) => BrowserProcess;
|
|
47
|
+
export interface FindShellInput {
|
|
48
|
+
root?: string;
|
|
49
|
+
platform?: string;
|
|
50
|
+
arch?: string;
|
|
51
|
+
list?: (dir: string) => string[];
|
|
52
|
+
exists?: (path: string) => boolean;
|
|
53
|
+
}
|
|
54
|
+
export interface AttachOptions {
|
|
55
|
+
openTransport?: (url: string, options?: OpenTransportOptions) => Promise<CdpTransport>;
|
|
56
|
+
clientTimeoutMs?: number;
|
|
57
|
+
}
|
|
58
|
+
export interface LaunchOptions extends AttachOptions {
|
|
59
|
+
executablePath?: string;
|
|
60
|
+
userDataDir?: string;
|
|
61
|
+
extraArgs?: readonly string[];
|
|
62
|
+
startupTimeoutMs?: number;
|
|
63
|
+
shutdownGraceMs?: number;
|
|
64
|
+
spawn?: BrowserSpawn;
|
|
65
|
+
findExecutable?: () => string | null;
|
|
66
|
+
}
|
|
67
|
+
/** A browser connected over CDP; close() also reclaims the process. */
|
|
68
|
+
export interface LaunchedBrowser {
|
|
69
|
+
endpoint: string;
|
|
70
|
+
pid: number | null;
|
|
71
|
+
userDataDir: string;
|
|
72
|
+
client: CdpClient;
|
|
73
|
+
close(): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
/** An externally launched browser (no process we own). */
|
|
76
|
+
export interface AttachedBrowser {
|
|
77
|
+
endpoint: string;
|
|
78
|
+
client: CdpClient;
|
|
79
|
+
close(): void;
|
|
80
|
+
}
|
|
81
|
+
/** The ws:// endpoint in a stderr buffer, or null. */
|
|
82
|
+
export declare function parseDevToolsEndpoint(stderr: string): string | null;
|
|
83
|
+
/** Playwright's per-platform subdirectory under a chromium_headless_shell-* dir. */
|
|
84
|
+
export declare function headlessShellSubdir(platform?: string, arch?: string): string;
|
|
85
|
+
/** Highest-versioned chrome-headless-shell under the Playwright cache. */
|
|
86
|
+
export declare function findHeadlessShell(input?: FindShellInput): string | null;
|
|
87
|
+
/** Attach to an already-running CDP endpoint. */
|
|
88
|
+
export declare function attachBrowser(endpoint: string, options?: AttachOptions): Promise<AttachedBrowser>;
|
|
89
|
+
/** Spawn a browser, wait for its endpoint, attach. */
|
|
90
|
+
export declare function launchBrowser(options?: LaunchOptions): Promise<LaunchedBrowser>;
|
|
91
|
+
/** SIGTERM the process group, wait, then SIGKILL if it is still alive. */
|
|
92
|
+
export declare function terminateBrowserProcess(proc: BrowserProcess, graceMs?: number): Promise<void>;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser launch / attach (F4 slice 1).
|
|
3
|
+
*
|
|
4
|
+
* Two entry points:
|
|
5
|
+
* - launchBrowser(): spawn chrome-headless-shell with
|
|
6
|
+
* --headless --no-sandbox --remote-debugging-port=0 --user-data-dir=<tmp>,
|
|
7
|
+
* read the ws:// DevTools endpoint off stderr, and return a handle whose
|
|
8
|
+
* close() reclaims the whole process tree and removes the profile dir.
|
|
9
|
+
* - attachBrowser(): connect to an externally supplied --cdp-endpoint (future
|
|
10
|
+
* real Chrome / a browser the operator started).
|
|
11
|
+
*
|
|
12
|
+
* The executable path, the process spawner and the transport opener are all
|
|
13
|
+
* injectable, so the module is unit-testable without a browser and can grow a
|
|
14
|
+
* platform-specific locator later without touching this logic.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
18
|
+
import { homedir, tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { CdpClient, openWebSocketTransport } from "./cdp.js";
|
|
21
|
+
/** Default time to wait for the DevTools endpoint. */
|
|
22
|
+
export const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
|
|
23
|
+
/** Default grace between SIGTERM and SIGKILL when closing. */
|
|
24
|
+
export const DEFAULT_SHUTDOWN_GRACE_MS = 3_000;
|
|
25
|
+
/** Flags every launched browser gets (callers append via extraArgs). */
|
|
26
|
+
export const DEFAULT_BROWSER_ARGS = [
|
|
27
|
+
"--headless",
|
|
28
|
+
"--no-sandbox",
|
|
29
|
+
"--disable-gpu",
|
|
30
|
+
"--remote-debugging-port=0",
|
|
31
|
+
];
|
|
32
|
+
/** The stderr marker that carries the endpoint. */
|
|
33
|
+
export const ENDPOINT_MARKER = "DevTools listening on ";
|
|
34
|
+
/** No headless-shell executable could be located. */
|
|
35
|
+
export class BrowserNotFoundError extends Error {
|
|
36
|
+
code = "browser_not_found";
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "BrowserNotFoundError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The browser died (or stayed silent) before it printed an endpoint. */
|
|
43
|
+
export class BrowserStartupError extends Error {
|
|
44
|
+
code = "browser_startup";
|
|
45
|
+
stderrTail;
|
|
46
|
+
constructor(message, stderrTail) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "BrowserStartupError";
|
|
49
|
+
this.stderrTail = stderrTail;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** The ws:// endpoint in a stderr buffer, or null. */
|
|
53
|
+
export function parseDevToolsEndpoint(stderr) {
|
|
54
|
+
const at = stderr.indexOf(ENDPOINT_MARKER);
|
|
55
|
+
if (at < 0)
|
|
56
|
+
return null;
|
|
57
|
+
const rest = stderr.slice(at + ENDPOINT_MARKER.length).trimStart();
|
|
58
|
+
const match = /^(ws:\S+)/.exec(rest);
|
|
59
|
+
return match === null ? null : match[1];
|
|
60
|
+
}
|
|
61
|
+
/** Playwright's per-platform subdirectory under a chromium_headless_shell-* dir. */
|
|
62
|
+
export function headlessShellSubdir(platform = process.platform, arch = process.arch) {
|
|
63
|
+
if (platform === "win32")
|
|
64
|
+
return "chrome-headless-shell-win64";
|
|
65
|
+
if (platform === "darwin")
|
|
66
|
+
return arch === "arm64" ? "chrome-headless-shell-mac-arm64" : "chrome-headless-shell-mac-x64";
|
|
67
|
+
return arch === "arm64" ? "chrome-headless-shell-linux-arm64" : "chrome-headless-shell-linux64";
|
|
68
|
+
}
|
|
69
|
+
/** Highest-versioned chrome-headless-shell under the Playwright cache. */
|
|
70
|
+
export function findHeadlessShell(input = {}) {
|
|
71
|
+
const root = input.root ?? join(homedir(), ".cache", "ms-playwright");
|
|
72
|
+
const list = input.list ?? defaultList;
|
|
73
|
+
const exists = input.exists ?? existsSync;
|
|
74
|
+
const subdir = headlessShellSubdir(input.platform ?? process.platform, input.arch ?? process.arch);
|
|
75
|
+
const versions = list(root).filter((name) => name.startsWith("chromium_headless_shell-")).sort(compareVersionDesc);
|
|
76
|
+
for (const version of versions) {
|
|
77
|
+
const candidate = join(root, version, subdir, "chrome-headless-shell");
|
|
78
|
+
if (exists(candidate))
|
|
79
|
+
return candidate;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
function defaultList(dir) {
|
|
84
|
+
try {
|
|
85
|
+
return readdirSync(dir);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function compareVersionDesc(a, b) {
|
|
92
|
+
return versionTail(b) - versionTail(a);
|
|
93
|
+
}
|
|
94
|
+
function versionTail(name) {
|
|
95
|
+
const match = /-(\d+)$/.exec(name);
|
|
96
|
+
return match === null ? -1 : Number(match[1]);
|
|
97
|
+
}
|
|
98
|
+
/** Attach to an already-running CDP endpoint. */
|
|
99
|
+
export async function attachBrowser(endpoint, options = {}) {
|
|
100
|
+
const open = options.openTransport ?? openWebSocketTransport;
|
|
101
|
+
const transport = await open(endpoint);
|
|
102
|
+
const client = new CdpClient({ transport, timeoutMs: options.clientTimeoutMs });
|
|
103
|
+
return { endpoint, client, close: () => client.close() };
|
|
104
|
+
}
|
|
105
|
+
/** Spawn a browser, wait for its endpoint, attach. */
|
|
106
|
+
export async function launchBrowser(options = {}) {
|
|
107
|
+
const executable = resolveExecutable(options);
|
|
108
|
+
const userDataDir = options.userDataDir ?? mkdtempSync(join(tmpdir(), "celestea-browser-"));
|
|
109
|
+
const ownsDir = options.userDataDir === undefined;
|
|
110
|
+
const grace = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
|
|
111
|
+
const args = [...DEFAULT_BROWSER_ARGS, "--user-data-dir=" + userDataDir, ...(options.extraArgs ?? []), "about:blank"];
|
|
112
|
+
const proc = (options.spawn ?? defaultBrowserSpawn)(executable, args);
|
|
113
|
+
let endpoint;
|
|
114
|
+
try {
|
|
115
|
+
endpoint = await readEndpoint(proc, options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
await terminateBrowserProcess(proc, grace);
|
|
119
|
+
if (ownsDir)
|
|
120
|
+
removeDir(userDataDir);
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
const attached = await attachBrowser(endpoint, options);
|
|
124
|
+
return {
|
|
125
|
+
endpoint,
|
|
126
|
+
pid: proc.pid ?? null,
|
|
127
|
+
userDataDir,
|
|
128
|
+
client: attached.client,
|
|
129
|
+
close: async () => {
|
|
130
|
+
attached.close();
|
|
131
|
+
await terminateBrowserProcess(proc, grace);
|
|
132
|
+
if (ownsDir)
|
|
133
|
+
removeDir(userDataDir);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function resolveExecutable(options) {
|
|
138
|
+
// An injected locator is authoritative (a null from it means "none"), so a
|
|
139
|
+
// test can force the not-found path without touching the real cache.
|
|
140
|
+
const found = options.executablePath ?? (options.findExecutable === undefined ? findHeadlessShell() : options.findExecutable());
|
|
141
|
+
if (found === null || found === undefined || found === "") {
|
|
142
|
+
throw new BrowserNotFoundError("no chrome-headless-shell found; pass executablePath or install Playwright chromium");
|
|
143
|
+
}
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
146
|
+
function readEndpoint(proc, timeoutMs) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
let buffer = "";
|
|
149
|
+
let settled = false;
|
|
150
|
+
const finish = (endpoint, error) => {
|
|
151
|
+
if (settled)
|
|
152
|
+
return;
|
|
153
|
+
settled = true;
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
if (error === null)
|
|
156
|
+
resolve(endpoint);
|
|
157
|
+
else
|
|
158
|
+
reject(error);
|
|
159
|
+
};
|
|
160
|
+
const timer = setTimeout(() => finish(null, new BrowserStartupError("browser did not print a DevTools endpoint within " + timeoutMs + "ms", tail(buffer))), timeoutMs);
|
|
161
|
+
timer.unref();
|
|
162
|
+
proc.stderr?.on("data", (chunk) => {
|
|
163
|
+
buffer += chunkToText(chunk);
|
|
164
|
+
const endpoint = parseDevToolsEndpoint(buffer);
|
|
165
|
+
if (endpoint !== null)
|
|
166
|
+
finish(endpoint, null);
|
|
167
|
+
});
|
|
168
|
+
proc.once("exit", (code) => finish(null, new BrowserStartupError("browser exited (code " + String(code) + ") before a DevTools endpoint", tail(buffer))));
|
|
169
|
+
proc.once("error", (error) => finish(null, error instanceof Error ? error : new Error(String(error))));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function chunkToText(chunk) {
|
|
173
|
+
if (typeof chunk === "string")
|
|
174
|
+
return chunk;
|
|
175
|
+
if (Buffer.isBuffer(chunk))
|
|
176
|
+
return chunk.toString("utf8");
|
|
177
|
+
return String(chunk);
|
|
178
|
+
}
|
|
179
|
+
function tail(text, max = 2000) {
|
|
180
|
+
return text.length > max ? text.slice(text.length - max) : text;
|
|
181
|
+
}
|
|
182
|
+
function defaultBrowserSpawn(program, args) {
|
|
183
|
+
return spawn(program, [...args], { detached: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
184
|
+
}
|
|
185
|
+
/** SIGTERM the process group, wait, then SIGKILL if it is still alive. */
|
|
186
|
+
export async function terminateBrowserProcess(proc, graceMs = DEFAULT_SHUTDOWN_GRACE_MS) {
|
|
187
|
+
const exited = waitForExit(proc, graceMs);
|
|
188
|
+
signalBrowser(proc, "SIGTERM");
|
|
189
|
+
if (!(await exited))
|
|
190
|
+
signalBrowser(proc, "SIGKILL");
|
|
191
|
+
}
|
|
192
|
+
function signalBrowser(proc, signal) {
|
|
193
|
+
const pid = proc.pid;
|
|
194
|
+
if (pid !== undefined) {
|
|
195
|
+
try {
|
|
196
|
+
process.kill(-pid, signal);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// not a group leader (or already gone): fall through to the direct child
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
proc.kill(signal);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// already reaped
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function waitForExit(proc, timeoutMs) {
|
|
211
|
+
return new Promise((resolve) => {
|
|
212
|
+
let settled = false;
|
|
213
|
+
const finish = (exited) => {
|
|
214
|
+
if (settled)
|
|
215
|
+
return;
|
|
216
|
+
settled = true;
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
resolve(exited);
|
|
219
|
+
};
|
|
220
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
221
|
+
timer.unref();
|
|
222
|
+
proc.once("exit", () => finish(true));
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function removeDir(dir) {
|
|
226
|
+
try {
|
|
227
|
+
rmSync(dir, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// a leftover profile dir must never fail close()
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F4 step 2b: the memory backstop for an RLIMIT_AS-exempted browser.
|
|
3
|
+
*
|
|
4
|
+
* Exempting RLIMIT_AS (step 2a) removes the sandbox's only virtual-memory
|
|
5
|
+
* bound, so this module re-establishes a REAL one, in this order:
|
|
6
|
+
*
|
|
7
|
+
* 1. **cgroup v2 memory.max** — exact and kernel-enforced, but only when the
|
|
8
|
+
* service's cgroup is delegated (writable). Measured on the target host:
|
|
9
|
+
* /sys/fs/cgroup is read-only (mkdir -> EACCES), so this path is attempted
|
|
10
|
+
* and honestly reported as unavailable.
|
|
11
|
+
* 2. **bounded RSS watchdog** — sample the process tree's VmRSS on an interval
|
|
12
|
+
* and SIGKILL the tree when it exceeds the cap. This is BOUNDED but not
|
|
13
|
+
* instantaneous: a fast allocator can overshoot by one sampling window.
|
|
14
|
+
* 3. **none** — no /proc, no pid: reported as "none" with the reason. Never
|
|
15
|
+
* silently claimed as protected.
|
|
16
|
+
*
|
|
17
|
+
* The guard is deliberately injectable (readRssKb / killTree / cgroupRoot) so
|
|
18
|
+
* the watchdog logic is unit-tested without allocating real memory.
|
|
19
|
+
*/
|
|
20
|
+
export type MemoryGuardKind = "cgroup-v2" | "rss-watchdog" | "none";
|
|
21
|
+
/** What backstop is actually in force (reported in every browser result). */
|
|
22
|
+
export interface MemoryGuardStatus {
|
|
23
|
+
kind: MemoryGuardKind;
|
|
24
|
+
limit_mb: number | null;
|
|
25
|
+
detail: string;
|
|
26
|
+
/** true once the watchdog killed the tree for exceeding the cap. */
|
|
27
|
+
killed: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface MemoryGuard {
|
|
30
|
+
status(): MemoryGuardStatus;
|
|
31
|
+
dispose(): void;
|
|
32
|
+
}
|
|
33
|
+
export interface MemoryGuardOptions {
|
|
34
|
+
pid: number | null;
|
|
35
|
+
limitMb: number;
|
|
36
|
+
/** Default "/sys/fs/cgroup". */
|
|
37
|
+
cgroupRoot?: string;
|
|
38
|
+
/** Override the current cgroup path (tests). Default: read /proc/self/cgroup. */
|
|
39
|
+
cgroupPath?: string | null;
|
|
40
|
+
/** VmRSS in KiB for the whole tree; null = unreadable. */
|
|
41
|
+
readRssKb?: (pid: number) => number | null;
|
|
42
|
+
/** SIGKILL the whole tree. */
|
|
43
|
+
killTree?: (pid: number) => void;
|
|
44
|
+
intervalMs?: number;
|
|
45
|
+
}
|
|
46
|
+
/** Default cap: 1 GiB of RSS for one browser tree. */
|
|
47
|
+
export declare const DEFAULT_BROWSER_MEMORY_MB = 1024;
|
|
48
|
+
/** Default sampling window. */
|
|
49
|
+
export declare const DEFAULT_RSS_INTERVAL_MS = 500;
|
|
50
|
+
/** Arm the strongest available backstop; always returns a truthful status. */
|
|
51
|
+
export declare function armMemoryGuard(options: MemoryGuardOptions): MemoryGuard;
|
|
52
|
+
/** Read this process's own cgroup v2 path from /proc/self/cgroup. */
|
|
53
|
+
export declare function readOwnCgroupPath(procRoot?: string): string | null;
|
|
54
|
+
/** Sum VmRSS (KiB) over the pid and every descendant, bounded. */
|
|
55
|
+
export declare function readTreeRssKb(pid: number, procRoot?: string, maxPids?: number): number | null;
|