@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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/dist/args.d.ts +15 -0
  3. package/dist/args.js +55 -0
  4. package/dist/attachments/image-header.d.ts +28 -0
  5. package/dist/attachments/image-header.js +148 -0
  6. package/dist/attachments/store.d.ts +56 -0
  7. package/dist/attachments/store.js +148 -0
  8. package/dist/browser/cdp.d.ts +162 -0
  9. package/dist/browser/cdp.js +269 -0
  10. package/dist/browser/launch.d.ts +92 -0
  11. package/dist/browser/launch.js +232 -0
  12. package/dist/browser/memory-guard.d.ts +55 -0
  13. package/dist/browser/memory-guard.js +210 -0
  14. package/dist/browser/session.d.ts +146 -0
  15. package/dist/browser/session.js +371 -0
  16. package/dist/browser/snapshot.d.ts +69 -0
  17. package/dist/browser/snapshot.js +163 -0
  18. package/dist/browser/types.d.ts +51 -0
  19. package/dist/browser/types.js +8 -0
  20. package/dist/builtin.d.ts +54 -0
  21. package/dist/builtin.js +77 -0
  22. package/dist/desc.d.ts +20 -0
  23. package/dist/desc.js +22 -0
  24. package/dist/disclosure.d.ts +113 -0
  25. package/dist/disclosure.js +141 -0
  26. package/dist/env.d.ts +10 -0
  27. package/dist/env.js +33 -0
  28. package/dist/errors.d.ts +25 -0
  29. package/dist/errors.js +60 -0
  30. package/dist/exposure.d.ts +145 -0
  31. package/dist/exposure.js +244 -0
  32. package/dist/fn-tool.d.ts +14 -0
  33. package/dist/fn-tool.js +14 -0
  34. package/dist/fs/file-io.d.ts +78 -0
  35. package/dist/fs/file-io.js +239 -0
  36. package/dist/guard/path-guard.d.ts +144 -0
  37. package/dist/guard/path-guard.js +289 -0
  38. package/dist/guard/paths.d.ts +35 -0
  39. package/dist/guard/paths.js +100 -0
  40. package/dist/http/errors.d.ts +24 -0
  41. package/dist/http/errors.js +64 -0
  42. package/dist/http/headers.d.ts +19 -0
  43. package/dist/http/headers.js +62 -0
  44. package/dist/http/redirects.d.ts +31 -0
  45. package/dist/http/redirects.js +76 -0
  46. package/dist/http/ssrf.d.ts +105 -0
  47. package/dist/http/ssrf.js +272 -0
  48. package/dist/http/transport.d.ts +50 -0
  49. package/dist/http/transport.js +130 -0
  50. package/dist/index.d.ts +114 -0
  51. package/dist/index.js +129 -0
  52. package/dist/memory/log.d.ts +75 -0
  53. package/dist/memory/log.js +157 -0
  54. package/dist/memory/store.d.ts +47 -0
  55. package/dist/memory/store.js +61 -0
  56. package/dist/platform/exec.d.ts +79 -0
  57. package/dist/platform/exec.js +218 -0
  58. package/dist/platform/index.d.ts +12 -0
  59. package/dist/platform/index.js +12 -0
  60. package/dist/platform/paths.d.ts +51 -0
  61. package/dist/platform/paths.js +60 -0
  62. package/dist/platform/quote.d.ts +72 -0
  63. package/dist/platform/quote.js +102 -0
  64. package/dist/plugin.d.ts +96 -0
  65. package/dist/plugin.js +101 -0
  66. package/dist/process/buffers.d.ts +33 -0
  67. package/dist/process/buffers.js +86 -0
  68. package/dist/process/registry.d.ts +98 -0
  69. package/dist/process/registry.js +282 -0
  70. package/dist/registry.d.ts +52 -0
  71. package/dist/registry.js +161 -0
  72. package/dist/run-code/broker.d.ts +68 -0
  73. package/dist/run-code/broker.js +465 -0
  74. package/dist/run-code/limits.d.ts +69 -0
  75. package/dist/run-code/limits.js +88 -0
  76. package/dist/run-code/lines.d.ts +69 -0
  77. package/dist/run-code/lines.js +199 -0
  78. package/dist/run-code/sdk-ts.d.ts +34 -0
  79. package/dist/run-code/sdk-ts.js +276 -0
  80. package/dist/run-code/sdk.d.ts +39 -0
  81. package/dist/run-code/sdk.js +294 -0
  82. package/dist/sandbox/async.d.ts +10 -0
  83. package/dist/sandbox/async.js +26 -0
  84. package/dist/sandbox/bwrap-argv.d.ts +62 -0
  85. package/dist/sandbox/bwrap-argv.js +113 -0
  86. package/dist/sandbox/bwrap.d.ts +94 -0
  87. package/dist/sandbox/bwrap.js +159 -0
  88. package/dist/sandbox/child.d.ts +38 -0
  89. package/dist/sandbox/child.js +98 -0
  90. package/dist/sandbox/config.d.ts +89 -0
  91. package/dist/sandbox/config.js +149 -0
  92. package/dist/sandbox/fake-sandbox.d.ts +57 -0
  93. package/dist/sandbox/fake-sandbox.js +110 -0
  94. package/dist/sandbox/launch.d.ts +51 -0
  95. package/dist/sandbox/launch.js +134 -0
  96. package/dist/sandbox/limits.d.ts +63 -0
  97. package/dist/sandbox/limits.js +113 -0
  98. package/dist/sandbox/probe.d.ts +46 -0
  99. package/dist/sandbox/probe.js +102 -0
  100. package/dist/sandbox/provider.d.ts +83 -0
  101. package/dist/sandbox/provider.js +126 -0
  102. package/dist/sandbox/rlimit.d.ts +60 -0
  103. package/dist/sandbox/rlimit.js +76 -0
  104. package/dist/sandbox/seccomp.d.ts +48 -0
  105. package/dist/sandbox/seccomp.js +115 -0
  106. package/dist/sandbox/userspace.d.ts +65 -0
  107. package/dist/sandbox/userspace.js +107 -0
  108. package/dist/sandbox/workdir.d.ts +13 -0
  109. package/dist/sandbox/workdir.js +44 -0
  110. package/dist/schema.d.ts +20 -0
  111. package/dist/schema.js +135 -0
  112. package/dist/testing/platform-gates.d.ts +54 -0
  113. package/dist/testing/platform-gates.js +62 -0
  114. package/dist/tool-failure.d.ts +13 -0
  115. package/dist/tool-failure.js +19 -0
  116. package/dist/tools/ask-user.d.ts +32 -0
  117. package/dist/tools/ask-user.js +145 -0
  118. package/dist/tools/browser.d.ts +24 -0
  119. package/dist/tools/browser.js +132 -0
  120. package/dist/tools/http-request.d.ts +31 -0
  121. package/dist/tools/http-request.js +117 -0
  122. package/dist/tools/list-dir.d.ts +9 -0
  123. package/dist/tools/list-dir.js +45 -0
  124. package/dist/tools/load-skill.d.ts +37 -0
  125. package/dist/tools/load-skill.js +76 -0
  126. package/dist/tools/memory.d.ts +46 -0
  127. package/dist/tools/memory.js +131 -0
  128. package/dist/tools/process-control.d.ts +14 -0
  129. package/dist/tools/process-control.js +49 -0
  130. package/dist/tools/read-file.d.ts +11 -0
  131. package/dist/tools/read-file.js +81 -0
  132. package/dist/tools/read-image.d.ts +33 -0
  133. package/dist/tools/read-image.js +144 -0
  134. package/dist/tools/run-code.d.ts +48 -0
  135. package/dist/tools/run-code.js +115 -0
  136. package/dist/tools/run-shell.d.ts +22 -0
  137. package/dist/tools/run-shell.js +81 -0
  138. package/dist/tools/write-file.d.ts +8 -0
  139. package/dist/tools/write-file.js +31 -0
  140. package/package.json +28 -0
@@ -0,0 +1,371 @@
1
+ /**
2
+ * F4 step 2b: one SESSION's headless browser, launched THROUGH the sandbox.
3
+ *
4
+ * Why through the sandbox: the browser is the one workload that must run with
5
+ * `noAddressSpaceLimit: true` (step 2a) and, on the bwrap provider, with a
6
+ * SHARED network namespace (the DevTools ws:// endpoint lives on 127.0.0.1 and
7
+ * would be unreachable inside `--unshare-all`). Both facts are asserted here,
8
+ * never inferred: `spawned.sandbox.net_isolated === true` refuses the call with
9
+ * a structured `network_required` error instead of silently failing to connect.
10
+ *
11
+ * Lifecycle: the browser child is registered in the session ProcessRegistry, so
12
+ * the host's existing shutdown hook (`processes.dispose()`) reaps it; the
13
+ * manager ALSO exposes `dispose()` and removes the profile dir as soon as the
14
+ * child exits. No orphan is left behind.
15
+ *
16
+ * The result carries an explicit isolation block: the RLIMIT_AS exemption and
17
+ * the memory backstop are stated in the VALUE, so a caller can never mistake an
18
+ * exempted call for a sandboxed one.
19
+ */
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { contractFailure } from "../errors.js";
24
+ import { resolveShellKind } from "../platform/exec.js";
25
+ import { quoteWord } from "../platform/quote.js";
26
+ import { delay, TIMED_OUT, withTimeout } from "../sandbox/async.js";
27
+ import { CdpClient } from "./cdp.js";
28
+ import { attachBrowser, findHeadlessShell, parseDevToolsEndpoint } from "./launch.js";
29
+ import { armMemoryGuard, DEFAULT_BROWSER_MEMORY_MB } from "./memory-guard.js";
30
+ import { buildAxSnapshot, collectBoxes, isInteractiveRole } from "./snapshot.js";
31
+ /** Default cap on rendered AX nodes / bytes (step 1's limits). */
32
+ export const DEFAULT_BROWSER_MAX_NODES = 200;
33
+ export const DEFAULT_BROWSER_MAX_BYTES = 32 * 1024;
34
+ /** Default wait for the DevTools endpoint. */
35
+ export const DEFAULT_BROWSER_STARTUP_MS = 20_000;
36
+ /** Grace between terminate() and kill() when disposing. */
37
+ export const DEFAULT_BROWSER_DISPOSE_GRACE_MS = 3_000;
38
+ /** How long to wait for document.readyState after navigate. */
39
+ export const DEFAULT_NAVIGATE_TIMEOUT_MS = 20_000;
40
+ /** The model-visible sentence that must appear on every browser result. */
41
+ export const ADDRESS_SPACE_NOTE = "RLIMIT_AS is EXEMPTED for this browser process (noAddressSpaceLimit=true): its virtual address space is NOT bounded by the sandbox.";
42
+ /** Per-session browser owner. One instance is shared by both browser tools. */
43
+ export class BrowserManager {
44
+ options;
45
+ session = null;
46
+ disposed = false;
47
+ constructor(options) {
48
+ this.options = options;
49
+ }
50
+ /** Open (or reuse) the page and return its snapshot + screenshot. */
51
+ async open(url, viewport) {
52
+ const session = await this.ensure();
53
+ if (viewport !== undefined) {
54
+ await session.client.setDeviceMetricsOverride({ width: viewport.width, height: viewport.height }, session.sessionId);
55
+ }
56
+ await this.navigate(session, url);
57
+ return this.capture(session);
58
+ }
59
+ /** Act on the page opened by [open]; returns the updated snapshot. */
60
+ async act(request) {
61
+ const session = this.session;
62
+ if (session === null || session.exited) {
63
+ throw contractFailure("browser", "no_page", "no browser page is open; call browser_open first");
64
+ }
65
+ await this.perform(session, request);
66
+ await delay(150);
67
+ return this.capture(session);
68
+ }
69
+ /** Reclaim the browser: terminate the tree, wait, kill, remove the profile. */
70
+ async dispose() {
71
+ const session = this.session;
72
+ this.session = null;
73
+ this.disposed = true;
74
+ if (session === null)
75
+ return;
76
+ session.guard.dispose();
77
+ session.client.close();
78
+ try {
79
+ session.child.terminate();
80
+ }
81
+ catch {
82
+ /* already gone */
83
+ }
84
+ const exit = await withTimeout(session.child.wait(), this.graceMs);
85
+ if (exit === TIMED_OUT) {
86
+ try {
87
+ session.child.kill();
88
+ }
89
+ catch {
90
+ /* already gone */
91
+ }
92
+ await withTimeout(session.child.wait(), this.graceMs);
93
+ }
94
+ removeDir(session.profileDir);
95
+ }
96
+ /** True while a live browser is attached (diagnostics / tests). */
97
+ get running() {
98
+ return this.session !== null && !this.session.exited;
99
+ }
100
+ // ---- launch / attach -------------------------------------------------------
101
+ async ensure() {
102
+ if (this.session !== null && !this.session.exited)
103
+ return this.session;
104
+ if (this.disposed)
105
+ throw contractFailure("browser", "disposed", "this browser manager was disposed");
106
+ const executable = (this.options.findExecutable ?? findHeadlessShell)();
107
+ if (executable === null || executable === undefined || executable === "") {
108
+ throw contractFailure("browser", "browser_unavailable", "no chrome-headless-shell found; install Playwright chromium or set an executable path");
109
+ }
110
+ const profileDir = mkdtempSync(join(tmpdir(), "celestea-browser-"));
111
+ const spawned = await this.spawnBrowser(this.browserCommand(executable, profileDir));
112
+ if (spawned.sandbox.net_isolated) {
113
+ spawned.child.kill();
114
+ removeDir(profileDir);
115
+ throw contractFailure("browser", "network_required", "the sandbox is network-isolated (net_isolated=true), so the browser's DevTools endpoint on 127.0.0.1 is unreachable; grant this session the 'network' capability (grants.network / CELESTEA_SANDBOX_NET=1) so the sandbox shares the host network");
116
+ }
117
+ let client;
118
+ try {
119
+ const endpoint = await this.readEndpoint(spawned.child);
120
+ client = await (this.options.attach ?? defaultAttach)(endpoint);
121
+ }
122
+ catch (error) {
123
+ spawned.child.kill();
124
+ removeDir(profileDir);
125
+ throw error;
126
+ }
127
+ const guard = armMemoryGuard({ pid: spawned.child.pid, limitMb: this.memoryLimitMb });
128
+ const session = {
129
+ child: spawned.child,
130
+ client,
131
+ targetId: "",
132
+ sessionId: "",
133
+ meta: spawned.sandbox,
134
+ profileDir,
135
+ guard,
136
+ refs: new Map(),
137
+ exited: false,
138
+ };
139
+ this.session = session;
140
+ void spawned.child.wait().then(() => {
141
+ session.exited = true;
142
+ guard.dispose();
143
+ removeDir(profileDir);
144
+ });
145
+ this.options.processes?.insert(spawned.child, false);
146
+ const target = await client.createTarget("about:blank");
147
+ const attached = await client.attachToTarget(target.targetId);
148
+ session.targetId = target.targetId;
149
+ session.sessionId = attached.sessionId;
150
+ await client.pageEnable(session.sessionId);
151
+ await client.runtimeEnable(session.sessionId);
152
+ await client.accessibilityEnable(session.sessionId);
153
+ return session;
154
+ }
155
+ spawnBrowser(command) {
156
+ if (this.options.spawn !== undefined)
157
+ return this.options.spawn(command);
158
+ // HARD REQUIREMENT (F4 step 2a): the browser cannot start under RLIMIT_AS.
159
+ return this.options.sandbox.spawn({ command, noAddressSpaceLimit: true });
160
+ }
161
+ browserCommand(executable, profileDir) {
162
+ const kind = resolveShellKind(this.options.sandbox.shell ?? {}).kind;
163
+ const words = [
164
+ executable,
165
+ "--headless",
166
+ "--no-sandbox",
167
+ "--disable-gpu",
168
+ "--remote-debugging-port=0",
169
+ "--user-data-dir=" + profileDir,
170
+ "about:blank",
171
+ ];
172
+ return words.map((word) => quoteWord(kind, word)).join(" ");
173
+ }
174
+ readEndpoint(child) {
175
+ return new Promise((resolve, reject) => {
176
+ let buffer = "";
177
+ let settled = false;
178
+ const finish = (endpoint, error) => {
179
+ if (settled)
180
+ return;
181
+ settled = true;
182
+ clearTimeout(timer);
183
+ if (error === null)
184
+ resolve(endpoint);
185
+ else
186
+ reject(error);
187
+ };
188
+ const timer = setTimeout(() => {
189
+ finish(null, contractFailure("browser", "browser_startup", "browser did not print a DevTools endpoint within " + this.startupMs + "ms; stderr tail: " + tail(buffer)));
190
+ }, this.startupMs);
191
+ timer.unref();
192
+ child.stderr?.on("data", (chunk) => {
193
+ buffer += String(chunk);
194
+ const endpoint = parseDevToolsEndpoint(buffer);
195
+ if (endpoint !== null)
196
+ finish(endpoint, null);
197
+ });
198
+ void child.wait().then((exit) => {
199
+ finish(null, contractFailure("browser", "browser_startup", "browser exited code=" + String(exit.code) + " before a DevTools endpoint; stderr tail: " + tail(buffer)));
200
+ });
201
+ });
202
+ }
203
+ // ---- page operations -------------------------------------------------------
204
+ async navigate(session, url) {
205
+ await session.client.navigate(url, session.sessionId);
206
+ const deadline = Date.now() + this.navigateMs;
207
+ while (Date.now() < deadline) {
208
+ const state = await session.client.evaluate("document.readyState", session.sessionId).catch(() => null);
209
+ if (state === "complete")
210
+ return;
211
+ await delay(50);
212
+ }
213
+ }
214
+ async perform(session, request) {
215
+ const ref = request.ref === undefined ? undefined : session.refs.get(request.ref);
216
+ if (request.action !== "key" && request.action !== "scroll" && ref === undefined) {
217
+ throw contractFailure("browser", "unknown_ref", "ref '" + String(request.ref) + "' is unknown; take a browser_open snapshot first");
218
+ }
219
+ if (request.action === "click")
220
+ await this.click(session, ref);
221
+ else if (request.action === "type")
222
+ await this.typeText(session, ref, request.text ?? "");
223
+ else if (request.action === "key")
224
+ await this.key(session, request.key ?? "Enter");
225
+ else
226
+ await this.scroll(session, ref, request.deltaX ?? 0, request.deltaY ?? 0);
227
+ }
228
+ async click(session, ref) {
229
+ const box = ref.box ?? (await this.boxOf(session, ref));
230
+ if (box === null)
231
+ throw contractFailure("browser", "no_box", "ref '" + ref.ref + "' has no layout box (not visible?)");
232
+ const x = Math.round(box.x + box.width / 2);
233
+ const y = Math.round(box.y + box.height / 2);
234
+ await session.client.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 }, session.sessionId);
235
+ await session.client.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 }, session.sessionId);
236
+ }
237
+ async typeText(session, ref, text) {
238
+ const backend = ref.backendDOMNodeId;
239
+ if (backend === null)
240
+ throw contractFailure("browser", "no_node", "ref '" + ref.ref + "' has no backend node id");
241
+ await session.client.send("DOM.focus", { backendNodeId: backend }, session.sessionId);
242
+ await session.client.insertText(text, session.sessionId);
243
+ }
244
+ async key(session, key) {
245
+ await session.client.dispatchKeyEvent({ type: "keyDown", key }, session.sessionId);
246
+ await session.client.dispatchKeyEvent({ type: "keyUp", key }, session.sessionId);
247
+ }
248
+ async scroll(session, ref, deltaX, deltaY) {
249
+ let x = 400;
250
+ let y = 300;
251
+ if (ref !== undefined) {
252
+ const box = ref.box ?? (await this.boxOf(session, ref));
253
+ if (box !== null) {
254
+ x = Math.round(box.x + box.width / 2);
255
+ y = Math.round(box.y + box.height / 2);
256
+ }
257
+ }
258
+ await session.client.dispatchMouseEvent({ type: "mouseWheel", x, y, deltaX, deltaY }, session.sessionId);
259
+ }
260
+ async boxOf(session, ref) {
261
+ if (ref.backendDOMNodeId === null)
262
+ return null;
263
+ const model = await session.client.getBoxModel(ref.backendDOMNodeId, session.sessionId).catch(() => null);
264
+ if (model === null)
265
+ return null;
266
+ const quad = model.content.map(Number);
267
+ if (quad.length < 8)
268
+ return null;
269
+ const xs = [quad[0], quad[2], quad[4], quad[6]];
270
+ const ys = [quad[1], quad[3], quad[5], quad[7]];
271
+ const minX = Math.min(...xs);
272
+ const minY = Math.min(...ys);
273
+ return { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY };
274
+ }
275
+ // ---- snapshot + screenshot -------------------------------------------------
276
+ async capture(session) {
277
+ const nodes = await session.client.getFullAXTree(session.sessionId);
278
+ const interactive = nodes.filter((node) => node.ignored !== true && isInteractiveRole(node.role?.value ?? "")).slice(0, this.maxNodes);
279
+ const boxes = await collectBoxes(session.client, interactive, session.sessionId);
280
+ const snap = buildAxSnapshot(nodes, { boxes, maxNodes: this.maxNodes, maxBytes: this.maxBytes });
281
+ session.refs = new Map(snap.refs.map((ref) => [ref.ref, ref]));
282
+ const title = await session.client.evaluate("document.title", session.sessionId).catch(() => "");
283
+ const url = await session.client.evaluate("location.href", session.sessionId).catch(() => "");
284
+ const shot = await this.screenshot(session);
285
+ return {
286
+ ok: true,
287
+ url: typeof url === "string" ? url : "",
288
+ title: typeof title === "string" ? title : "",
289
+ snapshot: {
290
+ text: snap.text,
291
+ refs: snap.refs.map((ref) => ({
292
+ ref: ref.ref,
293
+ role: ref.role,
294
+ name: ref.name,
295
+ backend_dom_node_id: ref.backendDOMNodeId,
296
+ box: ref.box,
297
+ })),
298
+ truncated: snap.truncated,
299
+ truncation_reason: snap.truncationReason,
300
+ total_nodes: snap.totalNodes,
301
+ included_nodes: snap.includedNodes,
302
+ },
303
+ ...shot,
304
+ isolation: this.isolation(session),
305
+ notes: this.notes(shot.screenshot),
306
+ };
307
+ }
308
+ async screenshot(session) {
309
+ const store = this.options.attachments;
310
+ if (store === undefined || store === null)
311
+ return { screenshot: null, attachments: [] };
312
+ const shot = await session.client.captureScreenshot(session.sessionId);
313
+ const bytes = Buffer.from(shot.data, "base64");
314
+ const ref = await store.put({ bytes, name: "browser-" + Date.now() + ".png" });
315
+ return {
316
+ screenshot: { attachment_id: ref.attachment_id, media_type: ref.media_type, width: ref.width, height: ref.height, bytes: bytes.length },
317
+ attachments: [ref],
318
+ };
319
+ }
320
+ isolation(session) {
321
+ return {
322
+ provider: session.meta.provider,
323
+ net_isolated: session.meta.net_isolated,
324
+ tmp_private: session.meta.tmp_private,
325
+ seccomp: session.meta.seccomp,
326
+ address_space_limit: "exempted",
327
+ address_space_note: ADDRESS_SPACE_NOTE,
328
+ memory_guard: session.guard.status(),
329
+ };
330
+ }
331
+ notes(screenshot) {
332
+ const notes = [ADDRESS_SPACE_NOTE];
333
+ if (screenshot === null)
334
+ notes.push("no attachment store: the screenshot was not captured (the accessibility snapshot is still returned)");
335
+ return notes;
336
+ }
337
+ // ---- option accessors ------------------------------------------------------
338
+ get startupMs() {
339
+ return this.options.startupTimeoutMs ?? DEFAULT_BROWSER_STARTUP_MS;
340
+ }
341
+ get navigateMs() {
342
+ return this.options.navigateTimeoutMs ?? DEFAULT_NAVIGATE_TIMEOUT_MS;
343
+ }
344
+ get graceMs() {
345
+ return this.options.disposeGraceMs ?? DEFAULT_BROWSER_DISPOSE_GRACE_MS;
346
+ }
347
+ get memoryLimitMb() {
348
+ return this.options.memoryLimitMb ?? DEFAULT_BROWSER_MEMORY_MB;
349
+ }
350
+ get maxNodes() {
351
+ return this.options.maxNodes ?? DEFAULT_BROWSER_MAX_NODES;
352
+ }
353
+ get maxBytes() {
354
+ return this.options.maxBytes ?? DEFAULT_BROWSER_MAX_BYTES;
355
+ }
356
+ }
357
+ async function defaultAttach(endpoint) {
358
+ const attached = await attachBrowser(endpoint);
359
+ return attached.client;
360
+ }
361
+ function removeDir(dir) {
362
+ try {
363
+ rmSync(dir, { recursive: true, force: true });
364
+ }
365
+ catch {
366
+ /* best effort */
367
+ }
368
+ }
369
+ function tail(text, max = 800) {
370
+ return text.length > max ? text.slice(text.length - max) : text;
371
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Accessibility-tree snapshot (F4 slice 1).
3
+ *
4
+ * A raw Accessibility.getFullAXTree is unusable as model input: the measured
5
+ * Studio home page is 3687 nodes / 1.23MB. This module turns it into a BOUNDED
6
+ * text snapshot where every interactive node carries a stable ref (e1, e2, ...)
7
+ * plus its box, so click/type can target a ref instead of raw coordinates.
8
+ *
9
+ * Hard limits: a node cap and a byte cap. Both are explicit in the result
10
+ * (truncated / truncationReason) and the text ends with a visible note -- a
11
+ * silently clipped snapshot would make the model act on an incomplete page.
12
+ *
13
+ * Pure: no CDP, no filesystem. Boxes are injected (collectBoxes fills them from
14
+ * DOM.getBoxModel when a caller has a live session).
15
+ */
16
+ import type { AxNode, BoundingBox, BoxModel } from "./types.js";
17
+ /** Default cap on rendered nodes. */
18
+ export declare const DEFAULT_MAX_NODES = 200;
19
+ /** Default cap on rendered UTF-8 bytes. */
20
+ export declare const DEFAULT_MAX_BYTES: number;
21
+ /** Longest name kept per node (the rest is elided). */
22
+ export declare const MAX_NAME_CHARS = 120;
23
+ /** Roles that receive a ref (clickable / typeable). */
24
+ export declare const INTERACTIVE_ROLES: ReadonlySet<string>;
25
+ /** Roles rendered as context (no ref) when they carry a name. */
26
+ export declare const CONTEXT_ROLES: ReadonlySet<string>;
27
+ /** One addressable element in the snapshot. */
28
+ export interface SnapshotRef {
29
+ ref: string;
30
+ role: string;
31
+ name: string;
32
+ backendDOMNodeId: number | null;
33
+ box: BoundingBox | null;
34
+ }
35
+ /** The bounded result handed to the model. */
36
+ export interface AxSnapshot {
37
+ text: string;
38
+ refs: SnapshotRef[];
39
+ totalNodes: number;
40
+ includedNodes: number;
41
+ omittedNodes: number;
42
+ truncated: boolean;
43
+ truncationReason: string | null;
44
+ }
45
+ export interface SnapshotOptions {
46
+ maxNodes?: number;
47
+ maxBytes?: number;
48
+ /** Boxes keyed by backendDOMNodeId (see collectBoxes). */
49
+ boxes?: ReadonlyMap<number, BoundingBox>;
50
+ }
51
+ /** true when a role can be clicked or typed into. */
52
+ export declare function isInteractiveRole(role: string): boolean;
53
+ /** Role of a node (CDP omits it for some ignored nodes). */
54
+ export declare function roleOf(node: AxNode): string;
55
+ /** Collapsed, trimmed, elided accessible name. */
56
+ export declare function nameOf(node: AxNode): string;
57
+ /**
58
+ * Build the bounded snapshot. Iterates the tree in its given order (CDP returns
59
+ * pre-order), so refs are stable for an unchanged tree.
60
+ */
61
+ export declare function buildAxSnapshot(nodes: readonly AxNode[], options?: SnapshotOptions): AxSnapshot;
62
+ /** The slice of CdpClient collectBoxes needs (structural, avoids a cycle). */
63
+ export interface BoxSource {
64
+ getBoxModel(backendNodeId: number, sessionId: string): Promise<BoxModel | null>;
65
+ }
66
+ /** Fetch a box per distinct backendDOMNodeId; a failed lookup is simply absent. */
67
+ export declare function collectBoxes(source: BoxSource, nodes: readonly AxNode[], sessionId: string): Promise<Map<number, BoundingBox>>;
68
+ /** The axis-aligned bounds of a CDP content quad ([x1,y1,x2,y2,x3,y3,x4,y4]). */
69
+ export declare function quadToBox(quad: readonly number[]): BoundingBox | null;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Accessibility-tree snapshot (F4 slice 1).
3
+ *
4
+ * A raw Accessibility.getFullAXTree is unusable as model input: the measured
5
+ * Studio home page is 3687 nodes / 1.23MB. This module turns it into a BOUNDED
6
+ * text snapshot where every interactive node carries a stable ref (e1, e2, ...)
7
+ * plus its box, so click/type can target a ref instead of raw coordinates.
8
+ *
9
+ * Hard limits: a node cap and a byte cap. Both are explicit in the result
10
+ * (truncated / truncationReason) and the text ends with a visible note -- a
11
+ * silently clipped snapshot would make the model act on an incomplete page.
12
+ *
13
+ * Pure: no CDP, no filesystem. Boxes are injected (collectBoxes fills them from
14
+ * DOM.getBoxModel when a caller has a live session).
15
+ */
16
+ /** Default cap on rendered nodes. */
17
+ export const DEFAULT_MAX_NODES = 200;
18
+ /** Default cap on rendered UTF-8 bytes. */
19
+ export const DEFAULT_MAX_BYTES = 32 * 1024;
20
+ /** Longest name kept per node (the rest is elided). */
21
+ export const MAX_NAME_CHARS = 120;
22
+ /** Roles that receive a ref (clickable / typeable). */
23
+ export const INTERACTIVE_ROLES = new Set([
24
+ "button",
25
+ "link",
26
+ "textbox",
27
+ "searchbox",
28
+ "checkbox",
29
+ "radio",
30
+ "combobox",
31
+ "listbox",
32
+ "option",
33
+ "menuitem",
34
+ "menuitemcheckbox",
35
+ "menuitemradio",
36
+ "tab",
37
+ "switch",
38
+ "slider",
39
+ "spinbutton",
40
+ ]);
41
+ /** Roles rendered as context (no ref) when they carry a name. */
42
+ export const CONTEXT_ROLES = new Set([
43
+ "heading",
44
+ "cell",
45
+ "columnheader",
46
+ "rowheader",
47
+ "article",
48
+ "img",
49
+ "alert",
50
+ ]);
51
+ /** true when a role can be clicked or typed into. */
52
+ export function isInteractiveRole(role) {
53
+ return INTERACTIVE_ROLES.has(role);
54
+ }
55
+ /** Role of a node (CDP omits it for some ignored nodes). */
56
+ export function roleOf(node) {
57
+ return node.role?.value ?? "generic";
58
+ }
59
+ /** Collapsed, trimmed, elided accessible name. */
60
+ export function nameOf(node) {
61
+ const raw = node.name?.value ?? node.value?.value ?? "";
62
+ const collapsed = raw.replace(/\s+/g, " ").trim();
63
+ return collapsed.length > MAX_NAME_CHARS ? collapsed.slice(0, MAX_NAME_CHARS) + "..." : collapsed;
64
+ }
65
+ /**
66
+ * Build the bounded snapshot. Iterates the tree in its given order (CDP returns
67
+ * pre-order), so refs are stable for an unchanged tree.
68
+ */
69
+ export function buildAxSnapshot(nodes, options = {}) {
70
+ const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
71
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
72
+ const lines = [];
73
+ const refs = [];
74
+ let bytes = 0;
75
+ let reason = null;
76
+ for (const node of nodes) {
77
+ if (node.ignored === true)
78
+ continue;
79
+ const role = roleOf(node);
80
+ const name = nameOf(node);
81
+ const interactive = INTERACTIVE_ROLES.has(role);
82
+ const context = CONTEXT_ROLES.has(role) && name !== "";
83
+ if (!interactive && !context)
84
+ continue;
85
+ if (refs.length >= maxNodes) {
86
+ reason = "node cap (" + maxNodes + ") reached";
87
+ break;
88
+ }
89
+ const line = interactive ? renderRefLine(refs.length + 1, role, name, boxOf(node, options.boxes)) : role + ' "' + name + '"';
90
+ const lineBytes = Buffer.byteLength(line, "utf8") + 1;
91
+ if (bytes + lineBytes > maxBytes) {
92
+ reason = "byte cap (" + maxBytes + ") reached";
93
+ break;
94
+ }
95
+ lines.push(line);
96
+ bytes += lineBytes;
97
+ if (interactive) {
98
+ refs.push({
99
+ ref: "e" + (refs.length + 1),
100
+ role,
101
+ name,
102
+ backendDOMNodeId: node.backendDOMNodeId ?? null,
103
+ box: boxOf(node, options.boxes),
104
+ });
105
+ }
106
+ }
107
+ const includedNodes = lines.length;
108
+ const omittedNodes = nodes.length - includedNodes;
109
+ if (reason !== null) {
110
+ lines.push("[snapshot truncated: " + includedNodes + " of " + nodes.length + " nodes included (" + reason + ")]");
111
+ }
112
+ else if (includedNodes === 0) {
113
+ lines.push("(no interactive or named accessibility nodes)");
114
+ }
115
+ return {
116
+ text: lines.join("\n"),
117
+ refs,
118
+ totalNodes: nodes.length,
119
+ includedNodes,
120
+ omittedNodes,
121
+ truncated: reason !== null,
122
+ truncationReason: reason,
123
+ };
124
+ }
125
+ function renderRefLine(index, role, name, box) {
126
+ const head = "[e" + index + "] " + role + ' "' + name + '"';
127
+ return box === null ? head : head + " @ (" + box.x + "," + box.y + "," + box.width + "," + box.height + ")";
128
+ }
129
+ function boxOf(node, boxes) {
130
+ const id = node.backendDOMNodeId;
131
+ if (id === undefined || boxes === undefined)
132
+ return null;
133
+ return boxes.get(id) ?? null;
134
+ }
135
+ /** Fetch a box per distinct backendDOMNodeId; a failed lookup is simply absent. */
136
+ export async function collectBoxes(source, nodes, sessionId) {
137
+ const boxes = new Map();
138
+ for (const node of nodes) {
139
+ if (node.ignored === true)
140
+ continue;
141
+ const id = node.backendDOMNodeId;
142
+ if (id === undefined || boxes.has(id))
143
+ continue;
144
+ const model = await source.getBoxModel(id, sessionId).catch(() => null);
145
+ const box = model === null ? null : quadToBox(model.content);
146
+ if (box !== null)
147
+ boxes.set(id, box);
148
+ }
149
+ return boxes;
150
+ }
151
+ /** The axis-aligned bounds of a CDP content quad ([x1,y1,x2,y2,x3,y3,x4,y4]). */
152
+ export function quadToBox(quad) {
153
+ const q = quad.slice(0, 8).map(Number);
154
+ if (q.length < 8)
155
+ return null;
156
+ const xs = [q[0], q[2], q[4], q[6]];
157
+ const ys = [q[1], q[3], q[5], q[7]];
158
+ const minX = Math.min(...xs);
159
+ const maxX = Math.max(...xs);
160
+ const minY = Math.min(...ys);
161
+ const maxY = Math.max(...ys);
162
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
163
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared CDP protocol shapes (F4 slice 1).
3
+ *
4
+ * Only the fields this layer actually reads are declared: the goal is a
5
+ * zero-dependency client, not a full CDP type surface. Everything here is
6
+ * structural, so a test can hand-build an AX tree without a browser.
7
+ */
8
+ /** A rectangle in CSS pixels, top-left origin. */
9
+ export interface BoundingBox {
10
+ x: number;
11
+ y: number;
12
+ width: number;
13
+ height: number;
14
+ }
15
+ /** CDP DOM.getBoxModel result (content quad + declared size). */
16
+ export interface BoxModel {
17
+ content: number[];
18
+ width?: number;
19
+ height?: number;
20
+ }
21
+ /** A CDP AX string value (role / name / description). */
22
+ export interface AxValue {
23
+ type?: string;
24
+ value?: string;
25
+ }
26
+ /** A CDP AX property (checked, disabled, focused, ...). */
27
+ export interface AxProperty {
28
+ name: string;
29
+ value?: {
30
+ type?: string;
31
+ value?: unknown;
32
+ };
33
+ }
34
+ /** The subset of Accessibility.AXNode this layer reads. */
35
+ export interface AxNode {
36
+ nodeId?: string;
37
+ ignored?: boolean;
38
+ role?: AxValue;
39
+ name?: AxValue;
40
+ value?: AxValue;
41
+ description?: AxValue;
42
+ backendDOMNodeId?: number;
43
+ childIds?: string[];
44
+ properties?: AxProperty[];
45
+ }
46
+ /** A CDP error object as it appears on a response envelope. */
47
+ export interface CdpErrorShape {
48
+ code: number;
49
+ message: string;
50
+ data?: unknown;
51
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shared CDP protocol shapes (F4 slice 1).
3
+ *
4
+ * Only the fields this layer actually reads are declared: the goal is a
5
+ * zero-dependency client, not a full CDP type surface. Everything here is
6
+ * structural, so a test can hand-build an AX tree without a browser.
7
+ */
8
+ export {};