@monster-orange/den 0.0.0 → 0.2.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 (255) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +68 -0
  5. package/docs/distributions.md +499 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +101 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +403 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +124 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +757 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/agent-actor.ts +77 -0
  91. package/src/shared/applications.ts +138 -0
  92. package/src/shared/auth/api-token.ts +416 -0
  93. package/src/shared/auth/auth0-helper.ts +284 -0
  94. package/src/shared/auth/headless-login.ts +265 -0
  95. package/src/shared/auth/jwt-helper.ts +186 -0
  96. package/src/shared/auth/sudo-vault.ts +69 -0
  97. package/src/shared/auth/test-accounts.ts +98 -0
  98. package/src/shared/auth/token-injection.ts +184 -0
  99. package/src/shared/auth/token-providers/auth0.ts +80 -0
  100. package/src/shared/auth/token-providers/index.ts +182 -0
  101. package/src/shared/auth/token-refresh.ts +273 -0
  102. package/src/shared/banner.ts +15 -0
  103. package/src/shared/brand.ts +79 -0
  104. package/src/shared/cli-tabs.ts +131 -0
  105. package/src/shared/constants.ts +128 -0
  106. package/src/shared/den-config.ts +688 -0
  107. package/src/shared/dev-metrics/emit.ts +354 -0
  108. package/src/shared/edge/config.ts +229 -0
  109. package/src/shared/edge/render.ts +221 -0
  110. package/src/shared/edge/runtime.ts +268 -0
  111. package/src/shared/edge/status.ts +74 -0
  112. package/src/shared/edge/types.ts +62 -0
  113. package/src/shared/engine.ts +67 -0
  114. package/src/shared/environment/aws-profiles.ts +49 -0
  115. package/src/shared/environment/env-selector.ts +161 -0
  116. package/src/shared/environment/playwright-env.ts +82 -0
  117. package/src/shared/environment-manager/catalog.ts +77 -0
  118. package/src/shared/environment-manager/clone-guard.ts +163 -0
  119. package/src/shared/environment-manager/dag.ts +286 -0
  120. package/src/shared/environment-manager/default-workspace.ts +182 -0
  121. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  122. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  123. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  124. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  125. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  126. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  127. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  128. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  129. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  130. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  131. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  132. package/src/shared/environment-manager/derivation/text.ts +40 -0
  133. package/src/shared/environment-manager/derivation/types.ts +69 -0
  134. package/src/shared/environment-manager/health.ts +635 -0
  135. package/src/shared/environment-manager/index.ts +6 -0
  136. package/src/shared/environment-manager/load-applications.ts +108 -0
  137. package/src/shared/environment-manager/log-tail.ts +213 -0
  138. package/src/shared/environment-manager/make-targets.ts +78 -0
  139. package/src/shared/environment-manager/opctl.ts +335 -0
  140. package/src/shared/environment-manager/readiness.ts +93 -0
  141. package/src/shared/environment-manager/register-local.ts +72 -0
  142. package/src/shared/environment-manager/secrets-check.ts +260 -0
  143. package/src/shared/environment-manager/service-container.ts +47 -0
  144. package/src/shared/environment-manager/service-env.ts +110 -0
  145. package/src/shared/environment-manager/service-runner.ts +1603 -0
  146. package/src/shared/environment-manager/sync-targets.ts +66 -0
  147. package/src/shared/environment-manager/up-plan.ts +326 -0
  148. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  149. package/src/shared/environment-manager/workspaces.ts +860 -0
  150. package/src/shared/manifest/deps.ts +27 -0
  151. package/src/shared/manifest/merge-override.ts +75 -0
  152. package/src/shared/manifest/migrate-manifest.ts +101 -0
  153. package/src/shared/manifest/placement.ts +321 -0
  154. package/src/shared/manifest/read-manifest.ts +278 -0
  155. package/src/shared/manifest/schema.ts +313 -0
  156. package/src/shared/manifest/service-links.ts +93 -0
  157. package/src/shared/manifest/validate-layers.ts +229 -0
  158. package/src/shared/manifest/validate-manifest.ts +200 -0
  159. package/src/shared/manifest/workspace-manifests.ts +92 -0
  160. package/src/shared/open-url.ts +122 -0
  161. package/src/shared/org.ts +44 -0
  162. package/src/shared/paths.ts +190 -0
  163. package/src/shared/perf/optional-fields.ts +105 -0
  164. package/src/shared/perf/report-normalizer.ts +404 -0
  165. package/src/shared/perf/report-paths.ts +19 -0
  166. package/src/shared/perf/report-store.ts +110 -0
  167. package/src/shared/perf/report-types.ts +70 -0
  168. package/src/shared/perf/test-paths.ts +25 -0
  169. package/src/shared/process/log-rotation.ts +147 -0
  170. package/src/shared/process/spawn-streaming.ts +229 -0
  171. package/src/shared/product.ts +92 -0
  172. package/src/shared/profile.ts +141 -0
  173. package/src/shared/redact.ts +107 -0
  174. package/src/shared/repo-kind.ts +32 -0
  175. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  176. package/src/shared/reporting/slack-reporter.ts +340 -0
  177. package/src/shared/results/result-reader.ts +266 -0
  178. package/src/shared/results/result-writer.ts +168 -0
  179. package/src/shared/secrets/doppler.ts +126 -0
  180. package/src/shared/secrets/env.ts +48 -0
  181. package/src/shared/secrets/index.ts +197 -0
  182. package/src/shared/secrets/keys.ts +33 -0
  183. package/src/shared/secrets/onepassword.ts +75 -0
  184. package/src/shared/secrets/types.ts +52 -0
  185. package/src/shared/service-url.ts +119 -0
  186. package/src/shared/smoke/branch-resolver.ts +141 -0
  187. package/src/shared/smoke/git-remote.ts +70 -0
  188. package/src/shared/smoke/repo-cloner.ts +151 -0
  189. package/src/shared/stacks/config.ts +137 -0
  190. package/src/shared/stacks/manifest-commands.ts +118 -0
  191. package/src/shared/stacks/render.ts +253 -0
  192. package/src/shared/stacks/runtime.ts +334 -0
  193. package/src/shared/stacks/status.ts +99 -0
  194. package/src/shared/stacks/types.ts +53 -0
  195. package/src/shared/terminal/tabs.ts +212 -0
  196. package/src/shared/test-discovery/module-scanner.ts +569 -0
  197. package/src/shared/types/index.ts +701 -0
  198. package/src/shared/ui-marker.ts +19 -0
  199. package/src/ui/cli/applications-command.ts +148 -0
  200. package/src/ui/cli/config-command.ts +68 -0
  201. package/src/ui/cli/create-main.ts +9 -0
  202. package/src/ui/cli/create.ts +360 -0
  203. package/src/ui/cli/edge-command.ts +81 -0
  204. package/src/ui/cli/flag-parser.ts +246 -0
  205. package/src/ui/cli/graph-command.ts +570 -0
  206. package/src/ui/cli/health-display.ts +63 -0
  207. package/src/ui/cli/index.ts +3742 -0
  208. package/src/ui/cli/init.ts +855 -0
  209. package/src/ui/cli/invocation-target.ts +175 -0
  210. package/src/ui/cli/logs-command.ts +104 -0
  211. package/src/ui/cli/mcp-main.ts +13 -0
  212. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  213. package/src/ui/cli/module-selection.ts +31 -0
  214. package/src/ui/cli/prompts.ts +767 -0
  215. package/src/ui/cli/results-viewer.ts +436 -0
  216. package/src/ui/cli/secrets-preflight.ts +91 -0
  217. package/src/ui/cli/stack-command.ts +117 -0
  218. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  219. package/src/ui/cli/test-runner.ts +1319 -0
  220. package/src/ui/cli/web-command.ts +1073 -0
  221. package/src/ui/mcp/api.ts +284 -0
  222. package/src/ui/mcp/guards.ts +141 -0
  223. package/src/ui/mcp/server.ts +147 -0
  224. package/src/ui/mcp/tools.ts +565 -0
  225. package/src/ui/server/audit.ts +71 -0
  226. package/src/ui/server/changelog-parser.ts +105 -0
  227. package/src/ui/server/daemon-reaper.ts +129 -0
  228. package/src/ui/server/editor-manager.ts +301 -0
  229. package/src/ui/server/github-service.ts +223 -0
  230. package/src/ui/server/graph-prefs.ts +89 -0
  231. package/src/ui/server/health-row.ts +15 -0
  232. package/src/ui/server/http-helpers.ts +98 -0
  233. package/src/ui/server/index.ts +3350 -0
  234. package/src/ui/server/log-history.ts +453 -0
  235. package/src/ui/server/perf-report.ts +42 -0
  236. package/src/ui/server/public/environments.html +496 -0
  237. package/src/ui/server/public/favicon.ico +0 -0
  238. package/src/ui/server/public/graph-model.js +416 -0
  239. package/src/ui/server/public/graph.css +976 -0
  240. package/src/ui/server/public/graph.js +986 -0
  241. package/src/ui/server/public/header.js +1373 -0
  242. package/src/ui/server/public/index.html +2595 -0
  243. package/src/ui/server/public/local-dev.html +5344 -0
  244. package/src/ui/server/public/logs.html +1496 -0
  245. package/src/ui/server/public/panel.js +787 -0
  246. package/src/ui/server/public/perf-report.js +359 -0
  247. package/src/ui/server/public/release.html +2111 -0
  248. package/src/ui/server/public/services.html +1103 -0
  249. package/src/ui/server/public/workspace-picker.js +69 -0
  250. package/src/ui/server/readonly-routes.ts +200 -0
  251. package/src/ui/server/release-deploy-session.ts +246 -0
  252. package/src/ui/server/release-service.ts +1125 -0
  253. package/src/ui/server/sudo-store.ts +111 -0
  254. package/src/ui/server/ui-assets.ts +73 -0
  255. package/src/ui/server/workspace-routes.ts +2185 -0
@@ -0,0 +1,914 @@
1
+ /**
2
+ * Journeys browser toolset — @cursor/sdk custom tools over a harness-side
3
+ * Playwright chromium context created from a pre-minted storageState auth
4
+ * file (see browser-session.ts). The agent gets text page summaries with
5
+ * stable element refs; screenshots persist harness-side only (never sent
6
+ * to the model).
7
+ *
8
+ * Result-size posture (cost control): only browser_snapshot and
9
+ * browser_navigate return the FULL interactable-element dump (they (re)issue
10
+ * refs). click/type/select/wait return a COMPACT result — URL + title + a
11
+ * "call browser_snapshot for refs" hint — because returning the full page
12
+ * snapshot after every action made context grow ~linearly per action and
13
+ * dominated token cost (first staging run: 46 actions ≈ 3.2M tokens, agent
14
+ * output only 6K). Refs stay valid until the next full snapshot.
15
+ *
16
+ * Security posture (plan: jrn-browser-tool packet, two critique rounds):
17
+ * - Web-origin policy enforced after EVERY action: any top-level
18
+ * navigation off `baseUrls.web`'s origin — including click-driven
19
+ * redirects — is reverted (page.goBack) and reported as a structured
20
+ * tool error. Popups/new pages are closed immediately; downloads are
21
+ * denied. The page's own subresource/XHR traffic is deliberately never
22
+ * blocked (no route interception exists), so the app keeps working.
23
+ * - Sensitive-input masking: browser_type into password/otp inputs is
24
+ * masked in tool results and SnapshotIndex metadata, and the action's
25
+ * screenshot is SUPPRESSED before page.screenshot() could serialize the
26
+ * frame (critique finding D2-1).
27
+ * - context.on("response") feeds the shared ledger matcher under the
28
+ * data-minimization contract: method + sanitized path + status +
29
+ * extracted id + source tag only. Bodies are parsed transiently for id
30
+ * extraction and discarded; cookies and auth headers are never read —
31
+ * the ONLY header metadata the feed consults is content-type/
32
+ * content-length, to decide whether a body is safe to parse at all
33
+ * (review finding R2-2).
34
+ * - Interception lifecycle is BOUNDED (review finding R2-2): non-JSON or
35
+ * oversized bodies are never parsed (an SSE stream's json() would pend
36
+ * until the stream closes), JSON reads race a hard timeout, and drain()
37
+ * caps its wait — every bound violation surfaces as an interception
38
+ * warning on the ledger, never a silent hang.
39
+ */
40
+
41
+ import { chromium } from "@playwright/test";
42
+ import type { SDKCustomTool, SDKJsonValue } from "@cursor/sdk";
43
+ import { rewriteWebApiUrl, type JourneysWebApiConfig } from "../app-config.js";
44
+ import { RunLedger, sanitizePath, type HttpMethod } from "./ledger.js";
45
+ import type { SnapshotStore } from "./snapshot-store.js";
46
+
47
+ import { product } from "../../../shared/product.js";
48
+ /** Agent-facing tool surface, in mount order. */
49
+ export const BROWSER_TOOL_NAMES = [
50
+ "browser_navigate",
51
+ "browser_snapshot",
52
+ "browser_click",
53
+ "browser_type",
54
+ "browser_select",
55
+ "browser_wait",
56
+ ] as const;
57
+ export type BrowserToolName = (typeof BROWSER_TOOL_NAMES)[number];
58
+
59
+ // Upper bound for an explicit browser_wait; keeps a single tool call from
60
+ // consuming a meaningful share of the journey's wall-clock budget.
61
+ export const WAIT_CAP_MS = 10_000;
62
+
63
+ // Cap on elements returned per snapshot; bounds tool-result token cost on
64
+ // element-dense pages (tables, long lists).
65
+ const MAX_SNAPSHOT_ELEMENTS = 100;
66
+
67
+ // Hard bound on one interception handler's body read (review finding
68
+ // R2-2): a response body that cannot be read within this window (e.g. a
69
+ // stream that never closes) is abandoned and reported, never awaited
70
+ // indefinitely.
71
+ export const INTERCEPTION_BODY_TIMEOUT_MS = 5_000;
72
+
73
+ // Hard bound on drain(): the runner must never block on late network
74
+ // records longer than this — on expiry drain resolves and the gap is
75
+ // reported as an interception warning.
76
+ export const INTERCEPTION_DRAIN_TIMEOUT_MS = 10_000;
77
+
78
+ // Bodies with a declared content-length above this are never parsed; id
79
+ // extraction only needs small JSON creation payloads.
80
+ export const INTERCEPTION_MAX_BODY_BYTES = 1_048_576; // 1 MiB
81
+
82
+ /** Race sentinel distinguishing a timeout from a settled body read. */
83
+ const TIMED_OUT: unique symbol = Symbol("timed-out");
84
+
85
+ const MUTATING_METHODS: ReadonlySet<string> = new Set([
86
+ "POST",
87
+ "PUT",
88
+ "PATCH",
89
+ "DELETE",
90
+ ]);
91
+
92
+ // ── Structural Playwright surface ───────────────────────────────────────────
93
+ // Minimal interfaces the toolset needs; the real Playwright chromium stack
94
+ // satisfies them, and unit tests provide plain-object stubs so no browser
95
+ // ever launches in CI.
96
+
97
+ export interface LocatorLike {
98
+ click(): Promise<void>;
99
+ fill(text: string): Promise<void>;
100
+ press(key: string): Promise<void>;
101
+ selectOption(value: string): Promise<unknown>;
102
+ /** Serialized-script evaluation against the matched element. */
103
+ evaluate(script: string): Promise<unknown>;
104
+ }
105
+
106
+ export interface DownloadLike {
107
+ cancel(): Promise<void>;
108
+ }
109
+
110
+ export interface PageLike {
111
+ url(): string;
112
+ title(): Promise<string>;
113
+ goto(url: string, options?: { waitUntil?: string }): Promise<unknown>;
114
+ goBack(): Promise<unknown>;
115
+ /** Serialized-script evaluation in the page. */
116
+ evaluate(script: string): Promise<unknown>;
117
+ screenshot(options: { path: string }): Promise<unknown>;
118
+ locator(selector: string): LocatorLike;
119
+ waitForLoadState(state?: string): Promise<void>;
120
+ waitForTimeout(milliseconds: number): Promise<void>;
121
+ on(event: "download", handler: (download: DownloadLike) => unknown): unknown;
122
+ close(): Promise<void>;
123
+ }
124
+
125
+ export interface ResponseLike {
126
+ url(): string;
127
+ status(): number;
128
+ request(): { method(): string };
129
+ /**
130
+ * Response headers. The feed reads ONLY content-type/content-length
131
+ * (parse-safety metadata) — cookies and auth headers stay untouched.
132
+ */
133
+ headers(): Record<string, string>;
134
+ json(): Promise<unknown>;
135
+ }
136
+
137
+ export interface ContextLike {
138
+ newPage(): Promise<PageLike>;
139
+ on(event: "page", handler: (page: PageLike) => unknown): unknown;
140
+ on(event: "response", handler: (response: ResponseLike) => unknown): unknown;
141
+ close(): Promise<void>;
142
+ }
143
+
144
+ export interface BrowserLike {
145
+ newContext(options?: {
146
+ storageState?: string;
147
+ acceptDownloads?: boolean;
148
+ }): Promise<ContextLike>;
149
+ close(): Promise<void>;
150
+ }
151
+
152
+ export interface BrowserLauncher {
153
+ launch(options?: { headless?: boolean }): Promise<BrowserLike>;
154
+ }
155
+
156
+ // ── Policy reporting ────────────────────────────────────────────────────────
157
+
158
+ export interface PolicyReport {
159
+ kind: "external-navigation-blocked" | "popup-closed" | "download-denied";
160
+ /** Sanitized detail (origin/path only — never credentials). */
161
+ detail: string;
162
+ ts: string;
163
+ }
164
+
165
+ // ── Options / result types ──────────────────────────────────────────────────
166
+
167
+ export interface BrowserToolsetEnv {
168
+ baseUrls: {
169
+ web: string;
170
+ api: string;
171
+ services: Record<string, string>;
172
+ };
173
+ }
174
+
175
+ /** Interception lifecycle bounds (review finding R2-2); test override. */
176
+ export interface BrowserToolsetTimeouts {
177
+ /** Body-read bound; defaults to INTERCEPTION_BODY_TIMEOUT_MS. */
178
+ bodyMs?: number;
179
+ /** drain() bound; defaults to INTERCEPTION_DRAIN_TIMEOUT_MS. */
180
+ drainMs?: number;
181
+ }
182
+
183
+ export interface BrowserToolsetOptions {
184
+ env: BrowserToolsetEnv;
185
+ /** Pre-minted storageState path from provisionBrowserSession(). */
186
+ authFilePath: string;
187
+ snapshotStore: SnapshotStore;
188
+ /** Ledger fed by network interception (source: browser). */
189
+ ledger: RunLedger;
190
+ /** Injectable launcher for unit tests; defaults to Playwright chromium. */
191
+ launcher?: BrowserLauncher;
192
+ /** Interception lifecycle bounds; production uses the defaults. */
193
+ timeouts?: BrowserToolsetTimeouts;
194
+ /**
195
+ * API calls the app's browser code makes through the WEB origin (a
196
+ * backend-for-frontend proxy), from `journeys.app.web_api`. Unset: only
197
+ * direct API/service-origin calls feed the ledger.
198
+ */
199
+ webApi?: JourneysWebApiConfig;
200
+ }
201
+
202
+ export interface BrowserToolset {
203
+ tools: Record<BrowserToolName, SDKCustomTool>;
204
+ /** Launch + wire the browser stack (idempotent; tools call it lazily). */
205
+ start(): Promise<void>;
206
+ /**
207
+ * Await every pending response-interception handler (review finding
208
+ * 7): the runner drains BEFORE returning its RunOutcome so verify /
209
+ * teardown always observe a complete ledger. Never rejects, and never
210
+ * waits past INTERCEPTION_DRAIN_TIMEOUT_MS — on expiry it records an
211
+ * interception warning instead (review finding R2-2).
212
+ */
213
+ drain(): Promise<void>;
214
+ /** Close page/context/browser; safe to call multiple times. */
215
+ dispose(): Promise<void>;
216
+ /** Origin-policy enforcement events, in occurrence order. */
217
+ readonly policyReports: readonly PolicyReport[];
218
+ }
219
+
220
+ export type BrowserToolErrorCode =
221
+ | "INVALID_ARGS"
222
+ | "UNKNOWN_REF"
223
+ | "EXTERNAL_NAVIGATION_BLOCKED"
224
+ | "ACTION_FAILED";
225
+
226
+ interface ToolResult {
227
+ content: Array<{ type: "text"; text: string }>;
228
+ isError?: boolean;
229
+ }
230
+
231
+ interface PageElementSummary {
232
+ ref: string;
233
+ role: string;
234
+ name: string;
235
+ }
236
+
237
+ // ── Page scripts (serialized strings — the repo's TS config has no DOM lib,
238
+ // and these run inside the browser, not in Node) ────────────────────────────
239
+
240
+ const SNAPSHOT_SCRIPT = `(() => {
241
+ const selector = 'a[href], button, input, select, textarea, ' +
242
+ '[role="button"], [role="link"], [role="tab"], [role="menuitem"], ' +
243
+ '[contenteditable="true"]';
244
+ const nodes = Array.from(document.querySelectorAll(selector));
245
+ const visible = nodes.filter((el) =>
246
+ !el.disabled && el.getClientRects().length > 0);
247
+ return visible.slice(0, ${MAX_SNAPSHOT_ELEMENTS}).map((el, i) => {
248
+ const ref = "e" + (i + 1);
249
+ el.setAttribute("data-${product().cli}-ref", ref);
250
+ const name = (el.getAttribute("aria-label") || el.textContent ||
251
+ el.getAttribute("placeholder") || el.getAttribute("name") || "")
252
+ .trim().slice(0, 80);
253
+ const role = el.getAttribute("role") || el.tagName.toLowerCase();
254
+ return { ref, role, name };
255
+ });
256
+ })()`;
257
+
258
+ const SENSITIVITY_SCRIPT = `((el) => ({
259
+ type: (el.getAttribute("type") || "").toLowerCase(),
260
+ autocomplete: (el.getAttribute("autocomplete") || "").toLowerCase(),
261
+ }))`;
262
+
263
+ function isSensitiveInput(meta: {
264
+ type: string;
265
+ autocomplete: string;
266
+ }): boolean {
267
+ if (meta.type === "password") return true;
268
+ return /password|one-time-code|otp/.test(meta.autocomplete);
269
+ }
270
+
271
+ function textResult(text: string): ToolResult {
272
+ return { content: [{ type: "text", text }] };
273
+ }
274
+
275
+ function errorResult(code: BrowserToolErrorCode, message: string): ToolResult {
276
+ return {
277
+ content: [{ type: "text", text: `ERROR ${code}: ${message}` }],
278
+ isError: true,
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Build the browser custom toolset. Mount `tools` in
284
+ * `Agent.create({local: {customTools}})`; call `dispose()` in the runner's
285
+ * finally block.
286
+ */
287
+ export function buildBrowserToolset(
288
+ options: BrowserToolsetOptions,
289
+ ): BrowserToolset {
290
+ const { env, authFilePath, snapshotStore, ledger, webApi } = options;
291
+ // The Playwright chromium object satisfies BrowserLauncher structurally,
292
+ // but its event-overload signatures don't line up with the minimal
293
+ // interfaces above, so the default goes through a cast.
294
+ const launcher: BrowserLauncher =
295
+ options.launcher ?? (chromium as unknown as BrowserLauncher);
296
+
297
+ const webOrigin = new URL(env.baseUrls.web).origin;
298
+ const apiOrigins = new Set(
299
+ [env.baseUrls.api, ...Object.values(env.baseUrls.services)].map(
300
+ (base) => new URL(base).origin,
301
+ ),
302
+ );
303
+
304
+ const policyReports: PolicyReport[] = [];
305
+ let browser: BrowserLike | undefined;
306
+ let context: ContextLike | undefined;
307
+ let page: PageLike | undefined;
308
+ /** Refs issued by the most recent snapshot generation. */
309
+ let knownRefs = new Set<string>();
310
+
311
+ function report(kind: PolicyReport["kind"], detail: string): void {
312
+ policyReports.push({ kind, detail, ts: new Date().toISOString() });
313
+ }
314
+
315
+ /** Pending interception-handler promises, drained by the runner. */
316
+ const pendingInterceptions = new Set<Promise<void>>();
317
+
318
+ const bodyTimeoutMs =
319
+ options.timeouts?.bodyMs ?? INTERCEPTION_BODY_TIMEOUT_MS;
320
+ const drainTimeoutMs =
321
+ options.timeouts?.drainMs ?? INTERCEPTION_DRAIN_TIMEOUT_MS;
322
+
323
+ /**
324
+ * Bounded body read (review finding R2-2): parse ONLY when the
325
+ * content-type/content-length metadata says it is safe (small JSON —
326
+ * an SSE stream's json() pends until the stream closes), and even then
327
+ * race the read against a hard timeout. Timeouts surface as
328
+ * interception warnings; the abandoned read's settlement is observed so
329
+ * it can never unhandled-reject later.
330
+ */
331
+ async function readBodyBounded(
332
+ response: ResponseLike,
333
+ method: string,
334
+ ): Promise<unknown> {
335
+ const headers = response.headers();
336
+ const contentType = (headers["content-type"] ?? "").toLowerCase();
337
+ if (!contentType.includes("application/json")) {
338
+ return undefined;
339
+ }
340
+ const contentLength = Number(headers["content-length"] ?? Number.NaN);
341
+ if (
342
+ Number.isFinite(contentLength) &&
343
+ contentLength > INTERCEPTION_MAX_BODY_BYTES
344
+ ) {
345
+ return undefined;
346
+ }
347
+
348
+ // json() rejections (malformed JSON) keep the pre-existing degrade
349
+ // path: body undefined → unmanaged-mutation warning via the matcher.
350
+ const read = response.json().catch(() => undefined);
351
+ let timer: NodeJS.Timeout | undefined;
352
+ const cap = new Promise<typeof TIMED_OUT>((resolve) => {
353
+ timer = setTimeout(() => resolve(TIMED_OUT), bodyTimeoutMs);
354
+ });
355
+ try {
356
+ const result = await Promise.race([read, cap]);
357
+ if (result === TIMED_OUT) {
358
+ ledger.recordInterceptionError(
359
+ `interception body read timed out after ${bodyTimeoutMs}ms: ` +
360
+ `${method} ${sanitizePath(response.url())} — recorded without a body`,
361
+ );
362
+ return undefined;
363
+ }
364
+ return result;
365
+ } finally {
366
+ if (timer !== undefined) clearTimeout(timer);
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Data-minimized interception feed: only method + URL (sanitized inside
372
+ * the ledger) + status + transiently-parsed body ever leave the response
373
+ * object; cookies and auth headers are never read (content-type/
374
+ * content-length only — see readBodyBounded).
375
+ *
376
+ * Handlers are TRACKED (review finding 7): the toolset's drain() hook
377
+ * awaits every pending handler so verify/teardown never race a
378
+ * creation that hasn't been recorded yet, and processing failures are
379
+ * surfaced as ledger warnings — never swallowed silently.
380
+ */
381
+ async function onResponse(response: ResponseLike): Promise<void> {
382
+ try {
383
+ const method = response.request().method().toUpperCase();
384
+ if (!MUTATING_METHODS.has(method)) return;
385
+ const url = new URL(response.url());
386
+ // Record mutations on (a) a direct API/service origin, OR (b) the web
387
+ // origin under the app's declared web_api prefix. An app whose browser
388
+ // code calls the API through its own web origin (a backend-for-frontend
389
+ // proxy) never hits an API origin directly, so watching only API
390
+ // origins would leave the ledger empty and every `{<type>_id}`
391
+ // unbound. The ledger's own path patterns still gate what records.
392
+ const onApiOrigin = apiOrigins.has(url.origin);
393
+ const onWebApiPath =
394
+ webApi !== undefined &&
395
+ url.origin === webOrigin &&
396
+ url.pathname.startsWith(webApi.prefix);
397
+ if (!onApiOrigin && !onWebApiPath) return;
398
+
399
+ // Declared ignore prefixes (login/session routes on the web origin)
400
+ // are never a tracked resource mutation — skip them so they neither
401
+ // bind nor raise an unmanaged warning on every browser journey.
402
+ if (
403
+ onWebApiPath &&
404
+ (webApi.ignore ?? []).some((prefix) => url.pathname.startsWith(prefix))
405
+ ) {
406
+ return;
407
+ }
408
+
409
+ // A web-origin call may speak a different path vocabulary than the
410
+ // API; the declared rewrites map it onto the API paths the ledger
411
+ // descriptors and verifier probes use. Direct API-origin calls pass
412
+ // through untouched.
413
+ const pathOrUrl = onWebApiPath
414
+ ? rewriteWebApiUrl(response.url(), webApi)
415
+ : response.url();
416
+
417
+ const body = await readBodyBounded(response, method);
418
+ ledger.record(
419
+ {
420
+ method: method as HttpMethod,
421
+ pathOrUrl,
422
+ status: response.status(),
423
+ body,
424
+ },
425
+ "browser",
426
+ );
427
+ } catch (error) {
428
+ // Interception must never break the page or the journey — but a
429
+ // processing failure means a possible untracked creation, so it
430
+ // lands in the ledger as a warning for the report.
431
+ ledger.recordInterceptionError(
432
+ error instanceof Error ? error.message : String(error),
433
+ );
434
+ }
435
+ }
436
+
437
+ function trackInterception(response: ResponseLike): Promise<void> {
438
+ const pending = onResponse(response).finally(() => {
439
+ pendingInterceptions.delete(pending);
440
+ });
441
+ pendingInterceptions.add(pending);
442
+ return pending;
443
+ }
444
+
445
+ /**
446
+ * Bounded drain (review finding R2-2): waits for pending handlers but
447
+ * never longer than drainTimeoutMs — on expiry it resolves and records
448
+ * an interception warning (late records may be missing from the
449
+ * ledger). Never rejects; called on both normal and failure paths.
450
+ */
451
+ async function drain(): Promise<void> {
452
+ if (pendingInterceptions.size === 0) return;
453
+ // Handlers never reject (onResponse catches everything), so the
454
+ // settle loop below can only resolve or stay pending.
455
+ const settleAll = (async () => {
456
+ // New handlers can be registered while awaiting (late responses),
457
+ // so loop until the pending set is observed empty.
458
+ while (pendingInterceptions.size > 0) {
459
+ await Promise.all([...pendingInterceptions]);
460
+ }
461
+ })();
462
+ let timer: NodeJS.Timeout | undefined;
463
+ const cap = new Promise<typeof TIMED_OUT>((resolve) => {
464
+ timer = setTimeout(() => resolve(TIMED_OUT), drainTimeoutMs);
465
+ });
466
+ try {
467
+ const result = await Promise.race([settleAll, cap]);
468
+ if (result === TIMED_OUT) {
469
+ ledger.recordInterceptionError(
470
+ `interception drain timed out after ${drainTimeoutMs}ms — late ` +
471
+ "network records may be missing from the ledger",
472
+ );
473
+ }
474
+ } finally {
475
+ if (timer !== undefined) clearTimeout(timer);
476
+ }
477
+ }
478
+
479
+ async function ensureStarted(): Promise<void> {
480
+ if (page) return;
481
+ browser = await launcher.launch({ headless: true });
482
+ context = await browser.newContext({
483
+ storageState: authFilePath,
484
+ acceptDownloads: false,
485
+ });
486
+ context.on("response", (response: ResponseLike) =>
487
+ trackInterception(response),
488
+ );
489
+
490
+ page = await context.newPage();
491
+ page.on("download", async (download: DownloadLike) => {
492
+ try {
493
+ await download.cancel();
494
+ } catch {
495
+ // Best effort — acceptDownloads: false already denies persistence.
496
+ }
497
+ report("download-denied", "download attempt cancelled");
498
+ });
499
+
500
+ // Registered AFTER the main page exists so this only ever sees popups
501
+ // and window.open targets — those are closed on sight.
502
+ context.on("page", async (extra: PageLike) => {
503
+ try {
504
+ await extra.close();
505
+ } catch {
506
+ // Page may already be closed.
507
+ }
508
+ report("popup-closed", "new page/popup closed by policy");
509
+ });
510
+
511
+ // new URL(...) normalizes the configured base (adds the trailing slash)
512
+ // so page.url() comparisons stay canonical.
513
+ await page.goto(new URL(env.baseUrls.web).toString(), {
514
+ waitUntil: "domcontentloaded",
515
+ });
516
+ }
517
+
518
+ function requirePage(): PageLike {
519
+ if (!page) throw new Error("browser toolset not started");
520
+ return page;
521
+ }
522
+
523
+ /**
524
+ * Full page snapshot: URL, title, and every interactable element with its
525
+ * ref. This is the ONLY result that (re)issues refs — knownRefs is reset
526
+ * here, so refs are valid until the next full snapshot. Returned by
527
+ * browser_snapshot and browser_navigate (a fresh page always needs refs).
528
+ */
529
+ async function buildSummary(): Promise<string> {
530
+ const current = requirePage();
531
+ const elements = (await current.evaluate(
532
+ SNAPSHOT_SCRIPT,
533
+ )) as PageElementSummary[];
534
+ knownRefs = new Set(elements.map((element) => element.ref));
535
+ const title = await current.title();
536
+ const lines = [
537
+ `URL: ${current.url()}`,
538
+ `Title: ${title}`,
539
+ "Interactable elements:",
540
+ ...elements.map(
541
+ (element) => ` [${element.ref}] ${element.role} "${element.name}"`,
542
+ ),
543
+ ];
544
+ return lines.join("\n");
545
+ }
546
+
547
+ /**
548
+ * Compact post-action result: URL + title only, no element dump. Returned
549
+ * by click/type/select/wait so the agent's context does not accumulate a
550
+ * full ~900-token page snapshot after EVERY action (the dominant cost on
551
+ * the first staging run: 46 actions × full snapshot ≈ 3.2M tokens, output
552
+ * only 6K). The agent takes browser_snapshot when it needs fresh refs;
553
+ * knownRefs is deliberately NOT reset here, so refs from the last full
554
+ * snapshot stay valid across intervening actions.
555
+ */
556
+ async function buildCompactSummary(): Promise<string> {
557
+ const current = requirePage();
558
+ const title = await current.title();
559
+ return [
560
+ `URL: ${current.url()}`,
561
+ `Title: ${title}`,
562
+ "(page changed — call browser_snapshot for fresh element refs)",
563
+ ].join("\n");
564
+ }
565
+
566
+ async function captureSnapshot(
567
+ action: string,
568
+ flags: { failure?: boolean; masked?: boolean; suppress?: boolean } = {},
569
+ ): Promise<void> {
570
+ const current = requirePage();
571
+ try {
572
+ await snapshotStore.persist({
573
+ action,
574
+ url: current.url(),
575
+ capture: (filePath) =>
576
+ current.screenshot({ path: filePath }).then(() => undefined),
577
+ ...flags,
578
+ });
579
+ } catch {
580
+ // persist() records capture failures itself (finding 9); this is a
581
+ // last resort so an unexpected store error cannot fail the action.
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Shared action pipeline: run the action, then (in order) enforce the
587
+ * post-action origin policy, persist the deterministic screenshot
588
+ * (suppressed for masked actions — BEFORE any capture could happen), and
589
+ * return the fresh page summary.
590
+ */
591
+ async function performAction(
592
+ action: string,
593
+ act: () => Promise<void>,
594
+ flags: {
595
+ masked?: boolean;
596
+ resultPrefix?: string;
597
+ /** Return the full element dump (snapshot/navigate) vs a compact
598
+ * URL+title line (click/type/select/wait). Default: compact. */
599
+ full?: boolean;
600
+ } = {},
601
+ ): Promise<ToolResult> {
602
+ await ensureStarted();
603
+ const current = requirePage();
604
+
605
+ try {
606
+ await act();
607
+ } catch (error) {
608
+ // Masking propagates into the FAILURE path too: a sensitive action's
609
+ // failure frame is suppressed pre-capture, and the raw driver error —
610
+ // which can embed the typed value (e.g. a fill/press failure message)
611
+ // — is replaced with a value-free message.
612
+ await captureSnapshot(action, {
613
+ failure: true,
614
+ masked: flags.masked,
615
+ suppress: flags.masked,
616
+ });
617
+ if (flags.masked) {
618
+ return errorResult(
619
+ "ACTION_FAILED",
620
+ `${action} failed on a sensitive input; error details suppressed ` +
621
+ "to avoid leaking the typed value.",
622
+ );
623
+ }
624
+ const message = error instanceof Error ? error.message : String(error);
625
+ return errorResult("ACTION_FAILED", `${action} failed: ${message}`);
626
+ }
627
+
628
+ const landedOrigin = new URL(current.url()).origin;
629
+ if (landedOrigin !== webOrigin) {
630
+ report("external-navigation-blocked", landedOrigin);
631
+ try {
632
+ await current.goBack();
633
+ } catch {
634
+ // If we cannot navigate back the structured error still surfaces.
635
+ }
636
+ await captureSnapshot(action, {
637
+ failure: true,
638
+ masked: flags.masked,
639
+ suppress: flags.masked,
640
+ });
641
+ return errorResult(
642
+ "EXTERNAL_NAVIGATION_BLOCKED",
643
+ `Action navigated to external origin ${landedOrigin}; navigation ` +
644
+ "was reverted. Journeys may only travel the app under test.",
645
+ );
646
+ }
647
+
648
+ await captureSnapshot(action, {
649
+ masked: flags.masked,
650
+ suppress: flags.masked,
651
+ });
652
+
653
+ const summary = flags.full
654
+ ? await buildSummary()
655
+ : await buildCompactSummary();
656
+ return textResult(
657
+ flags.resultPrefix ? `${flags.resultPrefix}\n${summary}` : summary,
658
+ );
659
+ }
660
+
661
+ function refFromArgs(
662
+ args: Record<string, SDKJsonValue>,
663
+ ): string | ToolResult {
664
+ const ref = typeof args.ref === "string" ? args.ref : "";
665
+ if (ref.length === 0) {
666
+ return errorResult("INVALID_ARGS", "ref is required.");
667
+ }
668
+ if (!knownRefs.has(ref)) {
669
+ return errorResult(
670
+ "UNKNOWN_REF",
671
+ `Ref "${ref}" was not issued by the latest snapshot. Take a fresh ` +
672
+ "browser_snapshot and use its refs.",
673
+ );
674
+ }
675
+ return ref;
676
+ }
677
+
678
+ function locatorFor(ref: string): LocatorLike {
679
+ return requirePage().locator(`[data-${product().cli}-ref="${ref}"]`);
680
+ }
681
+
682
+ const refProperty = {
683
+ ref: {
684
+ type: "string",
685
+ description: "Element ref from the latest page snapshot (e.g. e3)",
686
+ },
687
+ } as const;
688
+
689
+ const tools: Record<BrowserToolName, SDKCustomTool> = {
690
+ browser_navigate: {
691
+ description:
692
+ "Navigate the browser to a path or URL on the app under test. " +
693
+ "Only the app's own origin is reachable.",
694
+ inputSchema: {
695
+ type: "object",
696
+ properties: {
697
+ url: {
698
+ type: "string",
699
+ description: "Path (e.g. /settings) or absolute app URL",
700
+ },
701
+ },
702
+ required: ["url"],
703
+ additionalProperties: false,
704
+ },
705
+ execute: async (args: Record<string, SDKJsonValue>) => {
706
+ const raw = typeof args.url === "string" ? args.url : "";
707
+ if (raw.length === 0) {
708
+ return errorResult("INVALID_ARGS", "url is required.");
709
+ }
710
+ let target: URL;
711
+ try {
712
+ target = new URL(raw, env.baseUrls.web);
713
+ } catch {
714
+ return errorResult("INVALID_ARGS", `Malformed URL: ${raw}`);
715
+ }
716
+ if (target.origin !== webOrigin) {
717
+ report("external-navigation-blocked", target.origin);
718
+ return errorResult(
719
+ "EXTERNAL_NAVIGATION_BLOCKED",
720
+ `Origin ${target.origin} is outside the app under test ` +
721
+ `(${webOrigin}); navigation refused.`,
722
+ );
723
+ }
724
+ return performAction(
725
+ "navigate",
726
+ async () => {
727
+ await requirePage().goto(target.toString(), {
728
+ waitUntil: "domcontentloaded",
729
+ });
730
+ },
731
+ // A fresh page: return the full element dump so the agent has refs.
732
+ { full: true },
733
+ );
734
+ },
735
+ },
736
+
737
+ browser_snapshot: {
738
+ description:
739
+ "Take a fresh text snapshot of the current page: URL, title, and " +
740
+ "interactable elements with refs for use in other browser tools.",
741
+ inputSchema: {
742
+ type: "object",
743
+ properties: {},
744
+ additionalProperties: false,
745
+ },
746
+ execute: async () => {
747
+ // The explicit "give me refs" tool → always the full element dump.
748
+ return performAction("snapshot", async () => undefined, {
749
+ full: true,
750
+ });
751
+ },
752
+ },
753
+
754
+ browser_click: {
755
+ description: "Click an element identified by its snapshot ref.",
756
+ inputSchema: {
757
+ type: "object",
758
+ properties: { ...refProperty },
759
+ required: ["ref"],
760
+ additionalProperties: false,
761
+ },
762
+ execute: async (args: Record<string, SDKJsonValue>) => {
763
+ await ensureStarted();
764
+ const ref = refFromArgs(args);
765
+ if (typeof ref !== "string") return ref;
766
+ return performAction("click", () => locatorFor(ref).click());
767
+ },
768
+ },
769
+
770
+ browser_type: {
771
+ description:
772
+ "Type text into an input identified by its snapshot ref. Values " +
773
+ "typed into password/OTP fields are masked in results.",
774
+ inputSchema: {
775
+ type: "object",
776
+ properties: {
777
+ ...refProperty,
778
+ text: { type: "string", description: "Text to type" },
779
+ submit: {
780
+ type: "boolean",
781
+ description: "Press Enter after typing",
782
+ },
783
+ },
784
+ required: ["ref", "text"],
785
+ additionalProperties: false,
786
+ },
787
+ execute: async (args: Record<string, SDKJsonValue>) => {
788
+ await ensureStarted();
789
+ const ref = refFromArgs(args);
790
+ if (typeof ref !== "string") return ref;
791
+ const value = typeof args.text === "string" ? args.text : "";
792
+ const locator = locatorFor(ref);
793
+
794
+ // Sensitivity is decided BEFORE acting so masking/suppression
795
+ // covers the tool result, the SnapshotIndex entry, AND the
796
+ // screenshot pipeline (which is suppressed pre-capture).
797
+ let sensitive = false;
798
+ try {
799
+ const meta = (await locator.evaluate(SENSITIVITY_SCRIPT)) as {
800
+ type?: string;
801
+ autocomplete?: string;
802
+ };
803
+ sensitive = isSensitiveInput({
804
+ type: (meta.type ?? "").toLowerCase(),
805
+ autocomplete: (meta.autocomplete ?? "").toLowerCase(),
806
+ });
807
+ } catch {
808
+ // Unknown metadata — fail toward masking rather than leaking.
809
+ sensitive = true;
810
+ }
811
+
812
+ return performAction(
813
+ "type",
814
+ async () => {
815
+ await locator.fill(value);
816
+ if (args.submit === true) {
817
+ await locator.press("Enter");
818
+ }
819
+ },
820
+ {
821
+ masked: sensitive,
822
+ resultPrefix: sensitive
823
+ ? `Typed [MASKED] into [${ref}]`
824
+ : `Typed ${JSON.stringify(value)} into [${ref}]`,
825
+ },
826
+ );
827
+ },
828
+ },
829
+
830
+ browser_select: {
831
+ description:
832
+ "Select an option (by value or label) in a select element " +
833
+ "identified by its snapshot ref.",
834
+ inputSchema: {
835
+ type: "object",
836
+ properties: {
837
+ ...refProperty,
838
+ value: { type: "string", description: "Option value or label" },
839
+ },
840
+ required: ["ref", "value"],
841
+ additionalProperties: false,
842
+ },
843
+ execute: async (args: Record<string, SDKJsonValue>) => {
844
+ await ensureStarted();
845
+ const ref = refFromArgs(args);
846
+ if (typeof ref !== "string") return ref;
847
+ const value = typeof args.value === "string" ? args.value : "";
848
+ return performAction("select", async () => {
849
+ await locatorFor(ref).selectOption(value);
850
+ });
851
+ },
852
+ },
853
+
854
+ browser_wait: {
855
+ description:
856
+ "Wait for the page to settle (default: load state) or for an " +
857
+ `explicit duration (capped at ${WAIT_CAP_MS} ms).`,
858
+ inputSchema: {
859
+ type: "object",
860
+ properties: {
861
+ milliseconds: {
862
+ type: "number",
863
+ description: `Explicit wait in ms (capped at ${WAIT_CAP_MS})`,
864
+ },
865
+ },
866
+ additionalProperties: false,
867
+ },
868
+ execute: async (args: Record<string, SDKJsonValue>) => {
869
+ return performAction("wait", async () => {
870
+ const current = requirePage();
871
+ if (typeof args.milliseconds === "number") {
872
+ await current.waitForTimeout(
873
+ Math.min(Math.max(args.milliseconds, 0), WAIT_CAP_MS),
874
+ );
875
+ } else {
876
+ await current.waitForLoadState("load");
877
+ }
878
+ });
879
+ },
880
+ },
881
+ };
882
+
883
+ return {
884
+ tools,
885
+ start: ensureStarted,
886
+ drain,
887
+ dispose: async () => {
888
+ const closingPage = page;
889
+ const closingContext = context;
890
+ const closingBrowser = browser;
891
+ page = undefined;
892
+ context = undefined;
893
+ browser = undefined;
894
+ try {
895
+ await closingPage?.close();
896
+ } catch {
897
+ // Already closed.
898
+ }
899
+ try {
900
+ await closingContext?.close();
901
+ } catch {
902
+ // Already closed.
903
+ }
904
+ try {
905
+ await closingBrowser?.close();
906
+ } catch {
907
+ // Already closed.
908
+ }
909
+ },
910
+ get policyReports(): readonly PolicyReport[] {
911
+ return policyReports;
912
+ },
913
+ };
914
+ }