@arnilo/prism-tools 0.0.0-stage → 0.13.0

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 (331) hide show
  1. package/CHANGELOG.md +410 -0
  2. package/LICENSE +21 -0
  3. package/README.md +44 -2
  4. package/browser/README.md +98 -0
  5. package/browser/skills/browser-automation/SKILL.md +14 -0
  6. package/browser/src/actions/cdp.ts +131 -0
  7. package/browser/src/actions/dialog.ts +25 -0
  8. package/browser/src/actions/download_release.ts +22 -0
  9. package/browser/src/actions/index.ts +37 -0
  10. package/browser/src/actions/locator.ts +70 -0
  11. package/browser/src/actions/navigate.ts +23 -0
  12. package/browser/src/actions/screenshot.ts +27 -0
  13. package/browser/src/actions/scroll.ts +24 -0
  14. package/browser/src/actions/select_page.ts +21 -0
  15. package/browser/src/actions/types.ts +114 -0
  16. package/browser/src/actions/upload.ts +44 -0
  17. package/browser/src/actions/wait.ts +45 -0
  18. package/browser/src/browser.ts +197 -0
  19. package/browser/src/cdp.ts +178 -0
  20. package/browser/src/checkpoint.ts +189 -0
  21. package/browser/src/config.ts +72 -0
  22. package/browser/src/downloads.ts +253 -0
  23. package/browser/src/errors.ts +31 -0
  24. package/browser/src/evaluate.ts +101 -0
  25. package/browser/src/index.ts +129 -0
  26. package/browser/src/limits.ts +271 -0
  27. package/browser/src/manager.ts +730 -0
  28. package/browser/src/network.ts +263 -0
  29. package/browser/src/observe.ts +178 -0
  30. package/browser/src/policy.ts +133 -0
  31. package/browser/src/screenshot.ts +107 -0
  32. package/browser/src/shared-sandbox.ts +53 -0
  33. package/browser/src/snapshot.ts +99 -0
  34. package/browser/src/targets.ts +165 -0
  35. package/browser/src/tools.ts +480 -0
  36. package/browser/src/types.ts +316 -0
  37. package/browser/src/uploads.ts +174 -0
  38. package/coding/README.md +337 -0
  39. package/coding/src/agent/acp-operations.ts +146 -0
  40. package/coding/src/agent/artifacts.ts +38 -0
  41. package/coding/src/agent/ask-user-decision.ts +623 -0
  42. package/coding/src/agent/atomic-write.ts +23 -0
  43. package/coding/src/agent/bounded-file.ts +21 -0
  44. package/coding/src/agent/checks.ts +301 -0
  45. package/coding/src/agent/coding-checkpoint.ts +828 -0
  46. package/coding/src/agent/delete.ts +237 -0
  47. package/coding/src/agent/diagnostics-tool.ts +163 -0
  48. package/coding/src/agent/diagnostics.ts +262 -0
  49. package/coding/src/agent/edit-diff.ts +382 -0
  50. package/coding/src/agent/edit.ts +322 -0
  51. package/coding/src/agent/effects.ts +102 -0
  52. package/coding/src/agent/env.ts +28 -0
  53. package/coding/src/agent/execution-policy.ts +89 -0
  54. package/coding/src/agent/file-mutation-queue.ts +72 -0
  55. package/coding/src/agent/forge/github.ts +603 -0
  56. package/coding/src/agent/forge/index.ts +16 -0
  57. package/coding/src/agent/forge/types.ts +185 -0
  58. package/coding/src/agent/git-aware-repository.ts +305 -0
  59. package/coding/src/agent/git-exec.ts +326 -0
  60. package/coding/src/agent/git-status.ts +177 -0
  61. package/coding/src/agent/git-tools.ts +360 -0
  62. package/coding/src/agent/git.ts +1074 -0
  63. package/coding/src/agent/glob-match.ts +146 -0
  64. package/coding/src/agent/glob.ts +182 -0
  65. package/coding/src/agent/goal-verify.ts +385 -0
  66. package/coding/src/agent/grep-worker.ts +74 -0
  67. package/coding/src/agent/grep.ts +459 -0
  68. package/coding/src/agent/identity.ts +48 -0
  69. package/coding/src/agent/index.ts +676 -0
  70. package/coding/src/agent/language/client.ts +429 -0
  71. package/coding/src/agent/language/framing.ts +190 -0
  72. package/coding/src/agent/language/index.ts +23 -0
  73. package/coding/src/agent/language/intelligence.ts +682 -0
  74. package/coding/src/agent/language/types.ts +187 -0
  75. package/coding/src/agent/lifecycle-events.ts +121 -0
  76. package/coding/src/agent/lifecycle.ts +166 -0
  77. package/coding/src/agent/limits.ts +214 -0
  78. package/coding/src/agent/move.ts +178 -0
  79. package/coding/src/agent/mutation-path.ts +52 -0
  80. package/coding/src/agent/output-accumulator.ts +304 -0
  81. package/coding/src/agent/path-utils.ts +120 -0
  82. package/coding/src/agent/process/index.ts +58 -0
  83. package/coding/src/agent/process/recovery.ts +508 -0
  84. package/coding/src/agent/process/sessions-host.ts +222 -0
  85. package/coding/src/agent/process/sessions-monitor.ts +141 -0
  86. package/coding/src/agent/process/sessions-recovery.ts +241 -0
  87. package/coding/src/agent/process/sessions-spawn.ts +613 -0
  88. package/coding/src/agent/process/sessions-teardown.ts +281 -0
  89. package/coding/src/agent/process/sessions.ts +30 -0
  90. package/coding/src/agent/process/types.ts +358 -0
  91. package/coding/src/agent/read-path-set.ts +126 -0
  92. package/coding/src/agent/read.ts +713 -0
  93. package/coding/src/agent/repository/glob.ts +147 -0
  94. package/coding/src/agent/repository/indexed-search.ts +480 -0
  95. package/coding/src/agent/repository/list.ts +131 -0
  96. package/coding/src/agent/repository/operations.ts +22 -0
  97. package/coding/src/agent/repository/path.ts +98 -0
  98. package/coding/src/agent/repository/search.ts +277 -0
  99. package/coding/src/agent/repository/types.ts +243 -0
  100. package/coding/src/agent/repository/walk.ts +126 -0
  101. package/coding/src/agent/repository.ts +12 -0
  102. package/coding/src/agent/shell-background.ts +264 -0
  103. package/coding/src/agent/shell-output.ts +75 -0
  104. package/coding/src/agent/shell.ts +526 -0
  105. package/coding/src/agent/todo.ts +399 -0
  106. package/coding/src/agent/truncate.ts +251 -0
  107. package/coding/src/agent/write.ts +170 -0
  108. package/coding/src/ask-user-host.ts +48 -0
  109. package/coding/src/config.ts +278 -0
  110. package/coding/src/hard-deny.ts +97 -0
  111. package/coding/src/index.ts +318 -0
  112. package/coding/src/principal-guard.ts +84 -0
  113. package/coding/src/sandbox.ts +92 -0
  114. package/coding/src/security/approval.ts +144 -0
  115. package/coding/src/security/command-rules.ts +57 -0
  116. package/coding/src/security/docker-cli.ts +222 -0
  117. package/coding/src/security/docker-sandbox.ts +1205 -0
  118. package/coding/src/security/e2b-sandbox.ts +853 -0
  119. package/coding/src/security/egress/dns-pin.ts +88 -0
  120. package/coding/src/security/egress/index.ts +32 -0
  121. package/coding/src/security/egress/limits.ts +57 -0
  122. package/coding/src/security/egress/policy.ts +137 -0
  123. package/coding/src/security/egress/proxy.ts +599 -0
  124. package/coding/src/security/egress/types.ts +30 -0
  125. package/coding/src/security/index.ts +199 -0
  126. package/coding/src/security/native-sandbox.ts +639 -0
  127. package/coding/src/security/path-containment.ts +55 -0
  128. package/coding/src/security/sandbox-coding-operations.ts +392 -0
  129. package/coding/src/security/sandbox-fs-operations.ts +644 -0
  130. package/coding/src/security/sandbox-limits.ts +147 -0
  131. package/coding/src/security/sandbox-tar.ts +308 -0
  132. package/coding/src/security/sandbox.ts +179 -0
  133. package/coding/src/security/semaphore.ts +55 -0
  134. package/coding/src/targets.ts +110 -0
  135. package/coding/src/workspace/diff.ts +78 -0
  136. package/coding/src/workspace/errors.ts +28 -0
  137. package/coding/src/workspace/index.ts +5 -0
  138. package/coding/src/workspace/merge.ts +151 -0
  139. package/coding/src/workspace/routing.ts +76 -0
  140. package/coding/src/workspace/service.ts +545 -0
  141. package/computer-use/README.md +105 -0
  142. package/computer-use/skills/computer-use-linux/SKILL.md +17 -0
  143. package/computer-use/src/classify.ts +53 -0
  144. package/computer-use/src/command.ts +29 -0
  145. package/computer-use/src/config.ts +69 -0
  146. package/computer-use/src/create.ts +170 -0
  147. package/computer-use/src/devices.ts +247 -0
  148. package/computer-use/src/env.ts +28 -0
  149. package/computer-use/src/index.ts +157 -0
  150. package/computer-use/src/skill.ts +21 -0
  151. package/openapi/README.md +97 -0
  152. package/openapi/src/compile.ts +386 -0
  153. package/openapi/src/config.ts +73 -0
  154. package/openapi/src/errors.ts +16 -0
  155. package/openapi/src/execute.ts +241 -0
  156. package/openapi/src/index.ts +124 -0
  157. package/openapi/src/limits.ts +63 -0
  158. package/openapi/src/specs.ts +86 -0
  159. package/openapi/src/tools.ts +60 -0
  160. package/openapi/src/types.ts +61 -0
  161. package/package.json +171 -4
  162. package/research/README.md +72 -0
  163. package/research/skills/research-report/SKILL.md +12 -0
  164. package/research/skills/research-scoping/SKILL.md +10 -0
  165. package/research/skills/research-synthesis/SKILL.md +10 -0
  166. package/research/skills/source-triage/SKILL.md +10 -0
  167. package/research/src/check.ts +71 -0
  168. package/research/src/config.ts +41 -0
  169. package/research/src/index.ts +147 -0
  170. package/research/src/ledger.ts +157 -0
  171. package/research/src/urls.ts +21 -0
  172. package/web/README.md +287 -0
  173. package/web/skills/web-research/SKILL.md +17 -0
  174. package/web/src/brave.ts +66 -0
  175. package/web/src/cache.ts +151 -0
  176. package/web/src/config.ts +210 -0
  177. package/web/src/controls.ts +128 -0
  178. package/web/src/domains.ts +114 -0
  179. package/web/src/exa.ts +60 -0
  180. package/web/src/firecrawl.ts +160 -0
  181. package/web/src/index.ts +387 -0
  182. package/web/src/kagi.ts +73 -0
  183. package/web/src/limits.ts +55 -0
  184. package/web/src/native/convert.ts +84 -0
  185. package/web/src/native/fetch.ts +226 -0
  186. package/web/src/normalize.ts +131 -0
  187. package/web/src/obscura/cdp.ts +201 -0
  188. package/web/src/obscura/classify.ts +35 -0
  189. package/web/src/obscura/cli.ts +254 -0
  190. package/web/src/obscura/errors.ts +8 -0
  191. package/web/src/obscura/index.ts +59 -0
  192. package/web/src/obscura/limits.ts +92 -0
  193. package/web/src/obscura/mcp.ts +104 -0
  194. package/web/src/obscura/playwright.ts +133 -0
  195. package/web/src/obscura/process.ts +235 -0
  196. package/web/src/obscura/search-profile.ts +73 -0
  197. package/web/src/obscura/types.ts +48 -0
  198. package/web/src/obscura/web.ts +419 -0
  199. package/web/src/prompt-fetch.ts +88 -0
  200. package/web/src/render.ts +75 -0
  201. package/web/src/searxng.ts +78 -0
  202. package/web/src/slice.ts +139 -0
  203. package/web/src/tavily.ts +77 -0
  204. package/web/src/tools.ts +182 -0
  205. package/web/src/transport.ts +310 -0
  206. package/web/src/types.ts +199 -0
  207. package/work/README.md +77 -0
  208. package/work/THIRD_PARTY_NOTICES +24 -0
  209. package/work/docling/ocr.py +150 -0
  210. package/work/sandbox/Dockerfile +36 -0
  211. package/work/sandbox/soffice.sh +12 -0
  212. package/work/src/command.ts +29 -0
  213. package/work/src/config.ts +138 -0
  214. package/work/src/connectors/cli.ts +343 -0
  215. package/work/src/connectors/drafts.ts +647 -0
  216. package/work/src/connectors/errors.ts +8 -0
  217. package/work/src/connectors/file-bytes.ts +149 -0
  218. package/work/src/connectors/google-workspace-http.ts +399 -0
  219. package/work/src/connectors/google-workspace.ts +465 -0
  220. package/work/src/connectors/gws-tools.ts +378 -0
  221. package/work/src/connectors/http.ts +123 -0
  222. package/work/src/connectors/idempotency.ts +235 -0
  223. package/work/src/connectors/identity.ts +34 -0
  224. package/work/src/connectors/index.ts +93 -0
  225. package/work/src/connectors/limits.ts +50 -0
  226. package/work/src/connectors/m365-tools.ts +308 -0
  227. package/work/src/connectors/microsoft365-http.ts +428 -0
  228. package/work/src/connectors/microsoft365.ts +402 -0
  229. package/work/src/connectors/normalize.ts +120 -0
  230. package/work/src/connectors/tool-helpers.ts +398 -0
  231. package/work/src/connectors/tools.ts +42 -0
  232. package/work/src/connectors/types.ts +399 -0
  233. package/work/src/diagrams/canonicalize.ts +166 -0
  234. package/work/src/diagrams/caps.ts +52 -0
  235. package/work/src/diagrams/embed.ts +340 -0
  236. package/work/src/diagrams/errors.ts +76 -0
  237. package/work/src/diagrams/index.ts +81 -0
  238. package/work/src/diagrams/messages.ts +150 -0
  239. package/work/src/diagrams/origin.ts +52 -0
  240. package/work/src/diagrams/telemetry.ts +34 -0
  241. package/work/src/diagrams/xml.ts +267 -0
  242. package/work/src/document-extraction/index.ts +563 -0
  243. package/work/src/document-reader/ambient.d.ts +2 -0
  244. package/work/src/document-reader/errors.ts +7 -0
  245. package/work/src/document-reader/index.ts +181 -0
  246. package/work/src/document-reader/mistral-ocr.ts +318 -0
  247. package/work/src/document-reader/parsers.ts +171 -0
  248. package/work/src/documents/caps.ts +135 -0
  249. package/work/src/documents/diff.ts +269 -0
  250. package/work/src/documents/errors.ts +44 -0
  251. package/work/src/documents/fidelity.ts +98 -0
  252. package/work/src/documents/generate.ts +96 -0
  253. package/work/src/documents/hash.ts +8 -0
  254. package/work/src/documents/index.ts +14 -0
  255. package/work/src/documents/model-schema.ts +639 -0
  256. package/work/src/documents/parse.ts +252 -0
  257. package/work/src/documents/patch-history.ts +81 -0
  258. package/work/src/documents/patch.ts +476 -0
  259. package/work/src/documents/preview-html.ts +245 -0
  260. package/work/src/documents/preview.ts +227 -0
  261. package/work/src/documents/telemetry.ts +36 -0
  262. package/work/src/documents/translate/docx.ts +292 -0
  263. package/work/src/documents/translate/pptx.ts +304 -0
  264. package/work/src/documents/translate/xlsx.ts +137 -0
  265. package/work/src/documents/types.ts +188 -0
  266. package/work/src/index.ts +311 -0
  267. package/work/src/runtime/worker-pool-entry.ts +102 -0
  268. package/work/src/runtime/worker-pool.ts +267 -0
  269. package/work/src/sandbox/composition.ts +212 -0
  270. package/work/src/sandbox/image.ts +6 -0
  271. package/work/src/sandbox/index.ts +14 -0
  272. package/work/src/sheets/caps.ts +68 -0
  273. package/work/src/sheets/csv.ts +351 -0
  274. package/work/src/sheets/decimal.ts +388 -0
  275. package/work/src/sheets/errors.ts +52 -0
  276. package/work/src/sheets/index.ts +62 -0
  277. package/work/src/sheets/inference.ts +378 -0
  278. package/work/src/sheets/telemetry.ts +34 -0
  279. package/work/src/sheets/types.ts +162 -0
  280. package/work/src/sheets/xlsx.ts +388 -0
  281. package/work/src/skills/index.ts +8 -0
  282. package/work/src/skills/load.ts +43 -0
  283. package/work/src/tools/filesystem.ts +21 -0
  284. package/work/src/tools/index.ts +1 -0
  285. package/work/src/tools/local-filesystem.ts +33 -0
  286. package/work/src/tools/office.ts +311 -0
  287. package/work/vendor/hermes-agent/LICENSE +21 -0
  288. package/work/vendor/hermes-agent/skills/productivity/docx/LICENSE +21 -0
  289. package/work/vendor/hermes-agent/skills/productivity/docx/SKILL.md +196 -0
  290. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_comments.py +289 -0
  291. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_common.py +94 -0
  292. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_create.py +177 -0
  293. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_edit.py +250 -0
  294. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_read.py +149 -0
  295. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_revisions.py +147 -0
  296. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_template.py +70 -0
  297. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_validate.py +156 -0
  298. package/work/vendor/hermes-agent/skills/productivity/pdf/LICENSE +21 -0
  299. package/work/vendor/hermes-agent/skills/productivity/pdf/SKILL.md +125 -0
  300. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/_raster.py +76 -0
  301. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/extract_marker.py +88 -0
  302. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/extract_pymupdf.py +101 -0
  303. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_create.py +130 -0
  304. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_fill_form.py +97 -0
  305. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_form_layout.py +168 -0
  306. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_make_form.py +145 -0
  307. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_merge.py +50 -0
  308. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_meta.py +115 -0
  309. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_page_image.py +99 -0
  310. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_read.py +153 -0
  311. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_secure.py +71 -0
  312. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_split.py +84 -0
  313. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_stamp.py +143 -0
  314. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_watermark.py +51 -0
  315. package/work/vendor/hermes-agent/skills/productivity/powerpoint/LICENSE +21 -0
  316. package/work/vendor/hermes-agent/skills/productivity/powerpoint/SKILL.md +220 -0
  317. package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_create.py +214 -0
  318. package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_edit.py +436 -0
  319. package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_from_template.py +88 -0
  320. package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_read.py +131 -0
  321. package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_render.py +93 -0
  322. package/work/vendor/hermes-agent/skills/productivity/xlsx/LICENSE +21 -0
  323. package/work/vendor/hermes-agent/skills/productivity/xlsx/SKILL.md +196 -0
  324. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/csv_to_xlsx.py +104 -0
  325. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_create.py +259 -0
  326. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_edit.py +263 -0
  327. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_read.py +160 -0
  328. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_recalc.py +111 -0
  329. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_restructure.py +337 -0
  330. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_to_csv.py +69 -0
  331. package/work/vendor-lock.json +11 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * HTML-to-Markdown conversion for the native fetch backend.
3
+ *
4
+ * `linkedom`, `@mozilla/readability`, and `turndown` are imported on the first conversion only: a
5
+ * host that reads JSON or never fetches pays nothing for them. Readability keeps the article body
6
+ * (navigation, footers, and sidebars are dropped); when it finds nothing the whole `<body>` is
7
+ * converted. Relative links become absolute against the page URL, and a link whose scheme is not
8
+ * http(s) loses its attribute rather than becoming a `javascript:` Markdown link.
9
+ *
10
+ * Scripts never run: linkedom does not execute them and the converter removes their elements.
11
+ */
12
+ const CHARSET_IN_TYPE = /charset\s*=\s*["']?([\w.:-]+)/iu;
13
+ const CHARSET_IN_META = /<meta[^>]+charset\s*=\s*["']?([\w.:-]+)/iu;
14
+ /** A charset declaration lives in the head; scanning further would decode binary as text. */
15
+ const META_SCAN_BYTES = 1024;
16
+
17
+ export interface HtmlConversion {
18
+ readonly markdown: string;
19
+ readonly title?: string;
20
+ }
21
+
22
+ /** The header charset, else `<meta charset>` from the first 1 KiB, else UTF-8. Invalid bytes are replaced. */
23
+ export function decodeBody(bytes: Uint8Array, contentType: string | null): string {
24
+ const declared = CHARSET_IN_TYPE.exec(contentType ?? "")?.[1] ?? metaCharset(bytes) ?? "utf-8";
25
+ try {
26
+ return new TextDecoder(declared, { fatal: false }).decode(bytes);
27
+ } catch {
28
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
29
+ }
30
+ }
31
+
32
+ /** Article HTML to Markdown, loaded lazily. `sourceUrl` resolves relative links in the output. */
33
+ export async function convertHtml(html: string, sourceUrl: string): Promise<HtmlConversion> {
34
+ const [{ parseHTML }, { Readability }, { default: TurndownService }] = await Promise.all([
35
+ import("linkedom"),
36
+ import("@mozilla/readability"),
37
+ import("turndown"),
38
+ ]);
39
+ const { document } = parseHTML(html);
40
+ const article = new Readability(document as unknown as Document, { charThreshold: 0 }).parse();
41
+ const content = article?.content ?? document.body?.innerHTML;
42
+ if (content === undefined || content === "") return { markdown: "" };
43
+ const { document: contentDocument } = parseHTML(content);
44
+ absolutize(contentDocument, sourceUrl);
45
+ const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-" });
46
+ turndown.remove(["script", "style", "noscript", "iframe", "object", "embed", "form"]);
47
+ // A content fragment parses outside `<body>`, so convert the root element rather than the body.
48
+ const markdown = turndown.turndown((contentDocument.documentElement ?? content) as unknown as HTMLElement).trim();
49
+ const title = article?.title ?? document.title;
50
+ return { markdown, ...(title ? { title } : {}) };
51
+ }
52
+
53
+ function metaCharset(bytes: Uint8Array): string | undefined {
54
+ const head = new TextDecoder("latin1").decode(bytes.subarray(0, META_SCAN_BYTES));
55
+ return CHARSET_IN_META.exec(head)?.[1];
56
+ }
57
+
58
+ interface AttributeElement {
59
+ getAttribute(name: string): string | null;
60
+ setAttribute(name: string, value: string): void;
61
+ removeAttribute(name: string): void;
62
+ }
63
+
64
+ /** Links and images only: absolute http(s) or a drop. `#fragment` targets are page-local and kept. */
65
+ function absolutize(document: { querySelectorAll(selector: string): ArrayLike<AttributeElement> }, base: string): void {
66
+ for (const [selector, attribute] of [
67
+ ["a[href]", "href"],
68
+ ["img[src]", "src"],
69
+ ] as const) {
70
+ const elements = document.querySelectorAll(selector);
71
+ for (let index = 0; index < elements.length; index++) {
72
+ const element = elements[index]!;
73
+ const raw = element.getAttribute(attribute);
74
+ if (raw === null || raw.startsWith("#")) continue;
75
+ try {
76
+ const absolute = new URL(raw, base);
77
+ if (absolute.protocol !== "http:" && absolute.protocol !== "https:") element.removeAttribute(attribute);
78
+ else element.setAttribute(attribute, absolute.toString());
79
+ } catch {
80
+ element.removeAttribute(attribute);
81
+ }
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The keyless `native` fetch backend: `pinnedFetch`'s DNS pinning and SSRF gate, with redirects
3
+ * followed by hand so every hop is checked again.
4
+ *
5
+ * `pinnedFetch` itself refuses 3xx responses, so the loop here reuses its pieces instead: validate
6
+ * the URL, resolve and pin one address, request it, and repeat for `Location`. A hop to a private
7
+ * address, a loopback, a plaintext `http:` URL, or a non-http(s) scheme fails before any socket is
8
+ * opened. HTML becomes Markdown through `./convert.ts` (loaded on the first page only); text, JSON,
9
+ * and XML pass through.
10
+ */
11
+ import { assertSsrfAllowedUrl, MediaContentError, type MediaHostnameResolver, type SsrfPolicy } from "@arnilo/prism";
12
+ import { defaultResolver, requestPinned, resolvePinnedAddress } from "@arnilo/prism/net";
13
+ import type { DomainPolicy } from "../domains.js";
14
+ import { resolveWebLimits } from "../limits.js";
15
+ import { citation } from "../normalize.js";
16
+ import { truncateUtf8Bytes } from "../slice.js";
17
+ import { WebToolError } from "../transport.js";
18
+ import type { ResolvedWebLimits, WebFetchAdapter, WebLimits } from "../types.js";
19
+ import { convertHtml, decodeBody } from "./convert.js";
20
+
21
+ export const DEFAULT_MAX_REDIRECTS = 5;
22
+ export const HARD_MAX_REDIRECTS = 10;
23
+ /** One pinned, redirect-free request. Injected in tests; the default is the kernel's primitive. */
24
+ export type NativeFetchRequest = (url: URL, init: RequestInit) => Promise<Response>;
25
+
26
+ const HTML_TYPES = new Set(["text/html", "application/xhtml+xml"]);
27
+ const TEXT_TYPES = new Set(["text/plain", "text/markdown", "text/csv", "application/json", "application/xml", "text/xml"]);
28
+ const JSON_PRETTY_BYTES = 1024 * 1024;
29
+
30
+ export interface NativeFetchOptions {
31
+ /** Sent as `user-agent` on every request. The package passes its own name and version. */
32
+ readonly userAgent: string;
33
+ /** Redirects followed before `ERR_PRISM_WEB_REDIRECT`; default 5, hard maximum 10. */
34
+ readonly maxRedirects?: number;
35
+ readonly limits?: WebLimits;
36
+ readonly ssrf?: SsrfPolicy;
37
+ /** Custom DNS resolver, as `pinnedFetch` takes; defaults to `node:dns/promises`. */
38
+ readonly resolver?: MediaHostnameResolver;
39
+ /** Replaces the pinned request in tests; the SSRF gate still runs on the URL. */
40
+ readonly request?: NativeFetchRequest;
41
+ /** Host domain policy; the first URL and every redirect hop must be permitted. */
42
+ readonly domains?: DomainPolicy;
43
+ }
44
+
45
+ export function createNativeFetch(options: NativeFetchOptions): WebFetchAdapter {
46
+ if (typeof options.userAgent !== "string" || options.userAgent.length === 0)
47
+ throw new WebToolError("ERR_PRISM_WEB_INPUT", "native fetch needs a userAgent");
48
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
49
+ if (!Number.isSafeInteger(maxRedirects) || maxRedirects < 0 || maxRedirects > HARD_MAX_REDIRECTS)
50
+ throw new WebToolError("ERR_PRISM_WEB_INPUT", `maxRedirects must be an integer between 0 and ${HARD_MAX_REDIRECTS}`);
51
+ const limits = resolveWebLimits(options.limits);
52
+ const request = options.request ?? pinnedRequest(options, limits);
53
+ return {
54
+ provider: "native",
55
+ async fetch(input, call = {}) {
56
+ const headers = { "user-agent": options.userAgent, accept: "text/html, text/markdown;q=0.9, text/plain;q=0.8, */*;q=0.1" };
57
+ const signal = AbortSignal.any([...(call.signal === undefined ? [] : [call.signal]), AbortSignal.timeout(limits.timeoutMs)]);
58
+ try {
59
+ const { response, url, redirects } = await follow(input, request, {
60
+ ssrf: options.ssrf,
61
+ maxRedirects,
62
+ signal,
63
+ headers,
64
+ ...(options.domains === undefined ? {} : { domains: options.domains }),
65
+ });
66
+ const body = await readBody(response, limits.maxResponseBytes, signal);
67
+ const header = response.headers.get("content-type");
68
+ const type = header?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
69
+ let markdown: string;
70
+ let title: string | undefined;
71
+ if (HTML_TYPES.has(type)) {
72
+ const converted = await convertHtml(decodeBody(body, header), url.toString());
73
+ markdown = converted.markdown;
74
+ title = converted.title;
75
+ } else if (TEXT_TYPES.has(type)) {
76
+ markdown = decodeBody(body, header);
77
+ if (type === "application/json" && body.byteLength <= JSON_PRETTY_BYTES) markdown = prettyJson(markdown);
78
+ } else {
79
+ throw new WebToolError("ERR_PRISM_WEB_CONTENT_TYPE", `native fetch does not read ${type || "an unstated content type"}`);
80
+ }
81
+ // `maxBytes` never lifts the limit, it only lowers it; an over-long page is cut and flagged.
82
+ const limit = Math.min(call.maxBytes ?? limits.maxMarkdownBytes, limits.maxMarkdownBytes);
83
+ const sourceTruncated = Buffer.byteLength(markdown, "utf8") > limit;
84
+ return {
85
+ ...citation("native", url.toString()),
86
+ ...(title === undefined ? {} : { title }),
87
+ markdown: sourceTruncated ? truncateUtf8Bytes(markdown, limit) : markdown,
88
+ ...(sourceTruncated ? { sourceTruncated: true } : {}),
89
+ ...(redirects.length === 0 ? {} : { redirects }),
90
+ retrievedAt: new Date().toISOString(),
91
+ untrusted: true,
92
+ };
93
+ } catch (error) {
94
+ if (error instanceof WebToolError) throw error;
95
+ if (signal.aborted)
96
+ throw new WebToolError("ERR_PRISM_WEB_ABORTED", call.signal?.aborted === true ? "web fetch aborted" : "web fetch timed out");
97
+ if (error instanceof MediaContentError) throw translate(error);
98
+ throw new WebToolError("ERR_PRISM_WEB_REQUEST", `native fetch failed: ${error instanceof Error ? error.message : String(error)}`);
99
+ }
100
+ },
101
+ };
102
+ }
103
+
104
+ /** The default request: one DNS resolve, every candidate checked, the socket pinned to one address. */
105
+ function pinnedRequest(options: NativeFetchOptions, limits: ResolvedWebLimits): NativeFetchRequest {
106
+ const resolver = options.resolver ?? defaultResolver;
107
+ return async (url, init) => {
108
+ const address = await resolvePinnedAddress(url, resolver, init.signal, false, options.ssrf, "Web fetch");
109
+ const response = await requestPinned(url, address, init, "Web fetch");
110
+ const declared = Number(response.headers.get("content-length"));
111
+ if (Number.isFinite(declared) && declared > limits.maxResponseBytes) {
112
+ await response.body?.cancel();
113
+ throw new WebToolError("ERR_PRISM_WEB_LIMIT", `web response exceeds ${limits.maxResponseBytes} bytes`);
114
+ }
115
+ return response;
116
+ };
117
+ }
118
+
119
+ interface FollowOptions {
120
+ readonly ssrf: SsrfPolicy | undefined;
121
+ readonly maxRedirects: number;
122
+ readonly signal: AbortSignal;
123
+ readonly headers: Record<string, string>;
124
+ readonly domains?: DomainPolicy;
125
+ }
126
+
127
+ /** At most `maxRedirects` hops, each target resolved against the current URL and gated again. */
128
+ async function follow(
129
+ input: string,
130
+ request: NativeFetchRequest,
131
+ options: FollowOptions,
132
+ ): Promise<{ response: Response; url: URL; redirects: readonly string[] }> {
133
+ const start = parseTarget(input);
134
+ if (start === undefined) throw new WebToolError("ERR_PRISM_WEB_INPUT", "url must be an absolute http(s) URL");
135
+ let url: URL = start;
136
+ const seen = new Set<string>([url.toString()]);
137
+ const redirects: string[] = [];
138
+ for (;;) {
139
+ // Host policy first: a redirect is a new target and is gated exactly like the first URL.
140
+ options.domains?.assert(url.toString());
141
+ assertPublicTarget(url, options.ssrf);
142
+ const response = await request(url, { headers: options.headers, signal: options.signal, redirect: "manual" });
143
+ if (response.status < 300 || response.status >= 400) return { response, url, redirects };
144
+ await response.body?.cancel();
145
+ const location = response.headers.get("location");
146
+ const next: URL | undefined = location === null ? undefined : parseTarget(location, url);
147
+ if (next === undefined)
148
+ throw new WebToolError("ERR_PRISM_WEB_REDIRECT", `native fetch got status ${response.status} without a usable Location`);
149
+ if (redirects.length >= options.maxRedirects)
150
+ throw new WebToolError("ERR_PRISM_WEB_REDIRECT", `native fetch stopped after ${options.maxRedirects} redirects`);
151
+ if (seen.has(next.toString())) throw new WebToolError("ERR_PRISM_WEB_REDIRECT", "native fetch redirect loop");
152
+ seen.add(next.toString());
153
+ redirects.push(next.toString());
154
+ url = next;
155
+ }
156
+ }
157
+
158
+ /** https only (no loopback option here), no embedded credentials, and a public address. */
159
+ function assertPublicTarget(url: URL, ssrf: SsrfPolicy | undefined): void {
160
+ if (url.protocol !== "https:") throw new WebToolError("ERR_PRISM_WEB_REQUEST", `native fetch requires https: (got ${url.protocol})`);
161
+ if (url.username || url.password) throw new WebToolError("ERR_PRISM_WEB_INPUT", "url must not embed credentials");
162
+ url.hash = "";
163
+ try {
164
+ assertSsrfAllowedUrl(url.toString(), ssrf);
165
+ } catch (error) {
166
+ const reason = error instanceof Error ? error.message : String(error);
167
+ throw new WebToolError("ERR_PRISM_WEB_REQUEST", `native fetch refused ${url.hostname}: ${reason}`);
168
+ }
169
+ }
170
+
171
+ function parseTarget(input: string, base?: URL): URL | undefined {
172
+ let url: URL;
173
+ try {
174
+ url = base === undefined ? new URL(input) : new URL(input, base);
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ return url.protocol === "https:" || url.protocol === "http:" ? url : undefined;
179
+ }
180
+
181
+ /** Reads at most `maxBytes`; a larger body fails instead of filling memory. */
182
+ async function readBody(response: Response, maxBytes: number, signal: AbortSignal): Promise<Uint8Array> {
183
+ if (response.body === null) return new Uint8Array();
184
+ const reader = response.body.getReader();
185
+ const chunks: Uint8Array[] = [];
186
+ let total = 0;
187
+ try {
188
+ for (;;) {
189
+ signal.throwIfAborted();
190
+ const { done, value } = await reader.read();
191
+ if (done) break;
192
+ total += value.byteLength;
193
+ if (total > maxBytes) {
194
+ await reader.cancel();
195
+ throw new WebToolError("ERR_PRISM_WEB_LIMIT", `web response exceeds ${maxBytes} bytes`);
196
+ }
197
+ chunks.push(value);
198
+ }
199
+ } finally {
200
+ try {
201
+ reader.releaseLock();
202
+ } catch {
203
+ /* Already released after cancel. */
204
+ }
205
+ }
206
+ const bytes = new Uint8Array(total);
207
+ let offset = 0;
208
+ for (const chunk of chunks) {
209
+ bytes.set(chunk, offset);
210
+ offset += chunk.byteLength;
211
+ }
212
+ return bytes;
213
+ }
214
+
215
+ function prettyJson(text: string): string {
216
+ try {
217
+ return JSON.stringify(JSON.parse(text), null, 2);
218
+ } catch {
219
+ return text;
220
+ }
221
+ }
222
+
223
+ function translate(error: MediaContentError): WebToolError {
224
+ if (error.code === "redirect") return new WebToolError("ERR_PRISM_WEB_REDIRECT", "native fetch redirects are followed by the caller");
225
+ return new WebToolError("ERR_PRISM_WEB_REQUEST", `native fetch failed: ${error.message}`);
226
+ }
@@ -0,0 +1,131 @@
1
+ import { createHash } from "node:crypto";
2
+ import { type ArtifactCitation, HARD_CITATION_EXCERPT_BYTES } from "@arnilo/prism/artifacts";
3
+ import { WebToolError } from "./transport.js";
4
+ import type { WebCitation, WebProvider, WebProviderMetadata, WebSearchProvider, WebSearchResult } from "./types.js";
5
+
6
+ export function canonicalUrl(input: string): string {
7
+ let url: URL;
8
+ try {
9
+ url = new URL(input);
10
+ } catch {
11
+ throw new WebToolError("ERR_PRISM_WEB_RESPONSE", "Provider returned an invalid URL");
12
+ }
13
+ if (!/^https?:$/u.test(url.protocol) || url.username || url.password)
14
+ throw new WebToolError("ERR_PRISM_WEB_RESPONSE", "Provider returned an unsafe URL");
15
+ url.hash = "";
16
+ return url.toString();
17
+ }
18
+ export function citation(provider: WebProvider, inputUrl: string, sourceId?: string): WebCitation {
19
+ const url = canonicalUrl(inputUrl);
20
+ return {
21
+ provider,
22
+ sourceId,
23
+ url,
24
+ citationId: sourceId ? `web:${provider}:${sourceId}` : `web:${provider}:${createHash("sha256").update(url).digest("hex")}`,
25
+ };
26
+ }
27
+
28
+ /** Hash an already-fetched body. Never refetches, never stores credentials. */
29
+ export function snapshotWebEvidence(input: {
30
+ readonly url: string;
31
+ readonly body: string | Uint8Array;
32
+ readonly provider: WebProvider;
33
+ readonly retrievedAt?: string;
34
+ readonly title?: string;
35
+ readonly sourceId?: string;
36
+ readonly tenantId?: string;
37
+ readonly excerpt?: string;
38
+ readonly span?: { readonly start: number; readonly end: number };
39
+ readonly revision?: string;
40
+ }): ArtifactCitation {
41
+ const base = citation(input.provider, input.url, input.sourceId);
42
+ const bytes = typeof input.body === "string" ? Buffer.from(input.body, "utf8") : Buffer.from(input.body);
43
+ if (input.excerpt !== undefined && Buffer.byteLength(input.excerpt, "utf8") > HARD_CITATION_EXCERPT_BYTES) {
44
+ throw new WebToolError("ERR_PRISM_WEB_LIMIT", `excerpt exceeds ${HARD_CITATION_EXCERPT_BYTES} bytes`);
45
+ }
46
+ return {
47
+ uri: base.url,
48
+ kind: "web",
49
+ sourceId: base.sourceId ?? base.citationId,
50
+ revision: input.revision ?? "1",
51
+ contentHash: createHash("sha256").update(bytes).digest("hex"),
52
+ retrievedAt: input.retrievedAt ?? new Date().toISOString(),
53
+ support: "unverified",
54
+ ...(input.title === undefined ? {} : { title: input.title }),
55
+ ...(input.tenantId === undefined ? {} : { tenantId: input.tenantId }),
56
+ ...(input.excerpt === undefined ? {} : { excerpt: input.excerpt }),
57
+ ...(input.span === undefined ? {} : { span: input.span }),
58
+ };
59
+ }
60
+ /** Result rows only: provider/rate facts stay once at the response's top level, never per result. */
61
+ export function normalizeSearchResults(
62
+ provider: WebSearchProvider,
63
+ _query: string,
64
+ raw: unknown,
65
+ maxResults: number,
66
+ ): readonly WebSearchResult[] {
67
+ const root = object(raw),
68
+ fields = SEARCH_FIELDS[provider],
69
+ list = fields.list(root);
70
+ if (!Array.isArray(list)) throw new WebToolError("ERR_PRISM_WEB_RESPONSE", `${provider} response has no results`);
71
+ const seen = new Set<string>(),
72
+ retrievedAt = new Date().toISOString(),
73
+ out: WebSearchResult[] = [];
74
+ for (const entry of list) {
75
+ if (out.length >= maxResults) break;
76
+ const item = object(entry);
77
+ if (typeof item.url !== "string") continue;
78
+ const base = citation(provider, item.url, typeof item.id === "string" ? item.id : undefined);
79
+ if (seen.has(base.url)) continue;
80
+ seen.add(base.url);
81
+ const highlights = Array.isArray(item.highlights)
82
+ ? item.highlights.filter((value): value is string => typeof value === "string").slice(0, 16)
83
+ : undefined;
84
+ out.push({
85
+ ...base,
86
+ title: text(item.title),
87
+ snippet: text(item[fields.snippet]),
88
+ highlights,
89
+ publishedAt: text(item[fields.publishedAt]),
90
+ retrievedAt,
91
+ });
92
+ }
93
+ return out;
94
+ }
95
+ /** Where each backend keeps its rows, snippet, and publish date. One table instead of per-provider ternaries. */
96
+ const SEARCH_FIELDS: Readonly<
97
+ Record<
98
+ WebSearchProvider,
99
+ {
100
+ readonly list: (root: Record<string, unknown>) => unknown;
101
+ readonly snippet: string;
102
+ readonly publishedAt: string;
103
+ }
104
+ >
105
+ > = {
106
+ brave: { list: (root) => object(root.web).results, snippet: "description", publishedAt: "page_age" },
107
+ exa: { list: (root) => root.results, snippet: "text", publishedAt: "publishedDate" },
108
+ searxng: { list: (root) => root.results, snippet: "content", publishedAt: "publishedDate" },
109
+ tavily: { list: (root) => root.results, snippet: "content", publishedAt: "published_date" },
110
+ kagi: { list: (root) => object(root.data).search, snippet: "snippet", publishedAt: "time" },
111
+ };
112
+
113
+ export function object(value: unknown): Record<string, unknown> {
114
+ if (typeof value !== "object" || value === null || Array.isArray(value))
115
+ throw new WebToolError("ERR_PRISM_WEB_RESPONSE", "Provider returned an invalid object");
116
+ return value as Record<string, unknown>;
117
+ }
118
+ export function text(value: unknown): string | undefined {
119
+ return typeof value === "string" && value ? value : undefined;
120
+ }
121
+ export function providerFacts(raw: unknown, base?: WebProviderMetadata): WebProviderMetadata | undefined {
122
+ const root = object(raw);
123
+ const requestId = text(root.requestId) ?? base?.requestId;
124
+ const cost = root.costDollars ?? root.creditsUsed;
125
+ return requestId || cost !== undefined || base?.rate ? { requestId, cost: toJson(cost), rate: base?.rate } : undefined;
126
+ }
127
+ function toJson(value: unknown): import("@arnilo/prism").JsonValue | undefined {
128
+ return value === null || ["string", "number", "boolean"].includes(typeof value)
129
+ ? (value as import("@arnilo/prism").JsonValue)
130
+ : undefined;
131
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Managed/external CDP connectivity and Playwright composition for Obscura.
3
+ *
4
+ * `obscura serve` exposes a CDP endpoint (default ws://127.0.0.1:9222); hosts connect
5
+ * with their own Playwright via chromium.connectOverCDP — never connect() or launch.
6
+ * The resulting browser plugs straight into `@arnilo/prism-tools/browser`'s
7
+ * createBrowserTools/createBrowserManager; raw CDP (screenshots, PDF, screencast)
8
+ * stays available through Playwright's own CDP session APIs. No command allow-list
9
+ * is added here and Puppeteer is out of scope.
10
+ */
11
+ import { isIP } from "node:net";
12
+ import { ObscuraError } from "./errors.js";
13
+ import type { PlaywrightBrowser } from "./playwright.js";
14
+ import { spawnObscuraProcess } from "./process.js";
15
+ import type { ObscuraProcessOptions, OwnedObscuraProcess } from "./types.js";
16
+
17
+ /** Structural chromium surface this package needs — only `connectOverCDP`, never `connect` or `launch`. */
18
+ export interface ObscuraChromium {
19
+ connectOverCDP(endpoint: string, options?: { timeout?: number }): Promise<PlaywrightBrowser>;
20
+ }
21
+
22
+ export interface ObscuraPlaywright {
23
+ readonly chromium: ObscuraChromium;
24
+ }
25
+
26
+ export interface ConnectObscuraCdpOptions
27
+ extends Omit<Pick<ObscuraProcessOptions, "command" | "args" | "env" | "cwd" | "stderr" | "allowInsecureFlags" | "limits">, "command"> {
28
+ /** Managed mode: absolute command to spawn (e.g. the obscura binary, or /usr/bin/docker). */
29
+ readonly command?: string;
30
+ /**
31
+ * External mode: attach to an already-running CDP endpoint (ws://, wss://, http://,
32
+ * https://). Loopback-only unless `allowRemoteEndpoint` is set. Remote endpoints have
33
+ * no authentication — front them with an authenticated tunnel/proxy.
34
+ */
35
+ readonly endpoint?: string;
36
+ /** Explicit opt-in for non-loopback CDP endpoints. Off by default; rejected otherwise. */
37
+ readonly allowRemoteEndpoint?: boolean;
38
+ /**
39
+ * Host-supplied Playwright. When omitted, the optional `playwright-core` peer is
40
+ * imported (see docs/peer-dependencies.md for the pinned version). Prism never
41
+ * launches browsers.
42
+ */
43
+ readonly playwright?: ObscuraPlaywright;
44
+ readonly signal?: AbortSignal;
45
+ }
46
+
47
+ export interface ObscuraCdpSession {
48
+ /** Pass to `createBrowserTools({ browser })` / `createBrowserManager({ browser })`. */
49
+ readonly browser: PlaywrightBrowser;
50
+ readonly endpoint: string;
51
+ /** Defined only when this session spawned the server; close() terminates it. */
52
+ readonly process: OwnedObscuraProcess | undefined;
53
+ /** Close the Playwright browser first, then any owned process. Idempotent. */
54
+ close(): Promise<void>;
55
+ }
56
+
57
+ function isLoopbackHost(host: string): boolean {
58
+ const normalized = host.toLowerCase();
59
+ if (normalized === "localhost") return true;
60
+ if (isIP(normalized) === 4) return normalized.startsWith("127.");
61
+ return normalized === "::1";
62
+ }
63
+
64
+ /** Loopback-only, credential-free, ws(s)/http(s)-only endpoint validation. */
65
+ export function validateObscuraEndpoint(raw: string, allowRemote: boolean): URL {
66
+ let url: URL;
67
+ try {
68
+ url = new URL(raw);
69
+ } catch {
70
+ throw new ObscuraError("ERR_OBSCURA_INPUT", "endpoint must be an absolute URL");
71
+ }
72
+ if (!["ws:", "wss:", "http:", "https:"].includes(url.protocol)) {
73
+ throw new ObscuraError("ERR_OBSCURA_INPUT", "endpoint scheme must be ws(s) or http(s)");
74
+ }
75
+ if (url.username || url.password) {
76
+ throw new ObscuraError("ERR_OBSCURA_INPUT", "endpoint must not carry credentials; use an authenticated tunnel/proxy");
77
+ }
78
+ if (!isLoopbackHost(url.hostname)) {
79
+ if (!allowRemote) {
80
+ throw new ObscuraError(
81
+ "ERR_OBSCURA_INSECURE_FLAG",
82
+ `non-loopback endpoint ${JSON.stringify(url.hostname)} requires allowRemoteEndpoint`,
83
+ );
84
+ }
85
+ if (url.protocol === "ws:" || url.protocol === "http:") {
86
+ throw new ObscuraError("ERR_OBSCURA_INSECURE_FLAG", "remote CDP has no authentication; require an authenticated wss/https tunnel");
87
+ }
88
+ }
89
+ return url;
90
+ }
91
+
92
+ /** Derive ws://host:port from managed `serve` args (--port, optional --host). */
93
+ export function endpointFromServeArgs(args: readonly string[]): string {
94
+ let port: number | undefined;
95
+ let host = "127.0.0.1";
96
+ for (let i = 0; i < args.length; i += 1) {
97
+ const arg = args[i]!;
98
+ const value = arg === "--port" ? args[i + 1] : arg.startsWith("--port=") ? arg.slice("--port=".length) : undefined;
99
+ if (value !== undefined) {
100
+ const parsed = Number(value);
101
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
102
+ throw new ObscuraError("ERR_OBSCURA_INPUT", `invalid --port ${JSON.stringify(value)}`);
103
+ }
104
+ port = parsed;
105
+ }
106
+ const hostValue = arg === "--host" ? args[i + 1] : arg.startsWith("--host=") ? arg.slice("--host=".length) : undefined;
107
+ if (hostValue !== undefined) host = hostValue;
108
+ }
109
+ if (port === undefined) {
110
+ throw new ObscuraError("ERR_OBSCURA_INPUT", "managed mode needs --port in args or an explicit endpoint");
111
+ }
112
+ return `ws://${host}:${port}`;
113
+ }
114
+
115
+ async function loadPlaywright(): Promise<ObscuraPlaywright> {
116
+ try {
117
+ const mod = (await import("playwright-core")) as unknown as { default?: unknown } & Partial<ObscuraPlaywright>;
118
+ const playwright = (mod.default ?? mod) as ObscuraPlaywright;
119
+ if (typeof playwright.chromium?.connectOverCDP !== "function") {
120
+ throw new ObscuraError("ERR_OBSCURA_INPUT", "installed playwright-core does not expose chromium.connectOverCDP");
121
+ }
122
+ return playwright;
123
+ } catch (error) {
124
+ if (error instanceof ObscuraError) throw error;
125
+ throw new ObscuraError(
126
+ "ERR_OBSCURA_INPUT",
127
+ "optional peer playwright-core is not installed; install it or supply connectObscuraCdp({ playwright })",
128
+ );
129
+ }
130
+ }
131
+
132
+ /** HTTP readiness probe against the CDP endpoint's /json/version. */
133
+ function makeProbe(endpoint: URL): () => Promise<boolean> {
134
+ const httpUrl = new URL(endpoint.toString());
135
+ httpUrl.protocol = endpoint.protocol === "wss:" ? "https:" : "http:";
136
+ httpUrl.pathname = "/json/version";
137
+ return async () => {
138
+ try {
139
+ const response = await fetch(httpUrl, { signal: AbortSignal.timeout(1000) });
140
+ await response.arrayBuffer();
141
+ return response.ok;
142
+ } catch {
143
+ return false;
144
+ }
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Attach to an external CDP endpoint, or spawn `obscura serve` and attach once it
150
+ * is ready (bounded, abortable readiness — no fixed post-start sleep). Exactly one
151
+ * connectOverCDP call is made and its browser is returned for @arnilo/prism-tools/browser.
152
+ */
153
+ export async function connectObscuraCdp(options: ConnectObscuraCdpOptions): Promise<ObscuraCdpSession> {
154
+ let endpointRaw: string | undefined = options.endpoint;
155
+ let process: OwnedObscuraProcess | undefined;
156
+
157
+ if (options.command !== undefined) {
158
+ if (endpointRaw === undefined) endpointRaw = endpointFromServeArgs(options.args ?? []);
159
+ process = spawnObscuraProcess({
160
+ command: options.command,
161
+ ...(options.args === undefined ? {} : { args: options.args }),
162
+ ...(options.env === undefined ? {} : { env: options.env }),
163
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
164
+ ...(options.stderr === undefined ? {} : { stderr: options.stderr }),
165
+ ...(options.allowInsecureFlags === undefined ? {} : { allowInsecureFlags: options.allowInsecureFlags }),
166
+ ...(options.limits === undefined ? {} : { limits: options.limits }),
167
+ });
168
+ } else if (endpointRaw === undefined) {
169
+ throw new ObscuraError("ERR_OBSCURA_INPUT", "connectObscuraCdp requires endpoint or command");
170
+ }
171
+
172
+ const endpoint = validateObscuraEndpoint(endpointRaw, options.allowRemoteEndpoint ?? false);
173
+
174
+ try {
175
+ if (process !== undefined) {
176
+ await process.waitReady(makeProbe(endpoint), { signal: options.signal });
177
+ } else if (options.signal?.aborted) {
178
+ throw new ObscuraError("ERR_OBSCURA_ABORTED", "connect aborted");
179
+ }
180
+ const endpointString = endpoint.toString().replace(/\/$/, "");
181
+ const playwright = options.playwright ?? (await loadPlaywright());
182
+ const browser = await playwright.chromium.connectOverCDP(endpointString);
183
+ return {
184
+ browser,
185
+ endpoint: endpointString,
186
+ process,
187
+ close: async () => {
188
+ try {
189
+ await browser.close?.();
190
+ } catch {
191
+ // already disconnected
192
+ }
193
+ await process?.close();
194
+ },
195
+ };
196
+ } catch (error) {
197
+ // Only resources this call created are terminated; external servers stay alive.
198
+ await process?.close();
199
+ throw error;
200
+ }
201
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Obscura tool classification. Reads are proven side-effect-free against the live
3
+ * session; every other tool (including unknown future upstream tools) falls back to
4
+ * the bridge's conservative external-mutation default. `browser_search` is in-page
5
+ * text search — it reads the current page and mutates nothing.
6
+ */
7
+ const READ_TOOLS: ReadonlySet<string> = new Set([
8
+ // Read the page
9
+ "browser_snapshot",
10
+ "browser_markdown",
11
+ "browser_links",
12
+ "browser_extract",
13
+ "browser_interactive_elements",
14
+ "browser_detect_forms",
15
+ "browser_get_attribute",
16
+ "browser_count",
17
+ "browser_search",
18
+ // Diagnostics
19
+ "browser_console_messages",
20
+ "browser_network_requests",
21
+ // Tabs/cookies/storage reads
22
+ "browser_tab_list",
23
+ "browser_get_cookies",
24
+ "browser_storage_state",
25
+ // Waiters (observe only)
26
+ "browser_wait_for",
27
+ "browser_wait_for_text",
28
+ // Render captures (render-enabled builds)
29
+ "browser_screenshot",
30
+ "browser_pdf",
31
+ ]);
32
+
33
+ export function isObscuraReadTool(name: string): boolean {
34
+ return READ_TOOLS.has(name);
35
+ }