@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.
- package/CHANGELOG.md +204 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +68 -0
- package/docs/distributions.md +499 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +101 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +403 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +124 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +757 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +688 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +635 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +108 -0
- package/src/shared/environment-manager/log-tail.ts +213 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +335 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1603 -0
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +326 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/placement.ts +321 -0
- package/src/shared/manifest/read-manifest.ts +278 -0
- package/src/shared/manifest/schema.ts +313 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +200 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +190 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +118 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +701 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +148 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +81 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +570 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3742 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +117 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1319 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/mcp/api.ts +284 -0
- package/src/ui/mcp/guards.ts +141 -0
- package/src/ui/mcp/server.ts +147 -0
- package/src/ui/mcp/tools.ts +565 -0
- package/src/ui/server/audit.ts +71 -0
- package/src/ui/server/changelog-parser.ts +105 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3350 -0
- package/src/ui/server/log-history.ts +453 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +496 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +976 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +1373 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +5344 -0
- package/src/ui/server/public/logs.html +1496 -0
- package/src/ui/server/public/panel.js +787 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1103 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/readonly-routes.ts +200 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/ui-assets.ts +73 -0
- 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
|
+
}
|