@frontera-sdk/cli 1.52.39 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -0,0 +1,575 @@
1
+ import type { CDPSession, Dialog, Frame, Page } from 'playwright-core'
2
+
3
+ import { formatSnapshot, RefBook, type Snapshot } from './ax-snapshot'
4
+ import { credentialsIn, redactWith, STUDIO_SESSION } from './credentials'
5
+ import { conditionHolds, FRAMES_AGAIN_MS, PageFrames, type Cdp, type PageFrame } from './frames'
6
+ import type { KeyPress } from './keys'
7
+ import { CARET_TO_END, evaluateOnElement, evaluateWrapper, FOCUS, PAGE_TEXT, SELECT_CONTENTS, SELECT_OPTIONS, selectorVisible, textPresent, VISIBLE } from './page-scripts'
8
+ import type { WaitCondition } from './wait'
9
+
10
+ /**
11
+ * One page of the headless browser: a Playwright page driven through its
12
+ * DevTools Protocol session the way Forge drives a tab of its Browser, so a
13
+ * snapshot, its refs and every action behave the same in both. Refs are DOM
14
+ * nodes (`backendNodeId`): a ref keeps naming its element until the page
15
+ * goes to another document. Frames (an App embedded in Studio) are read into
16
+ * the snapshot and acted in through `PageFrames`; a frame Chrome renders in a
17
+ * process of its own is reached through a DevTools session of its own.
18
+ */
19
+
20
+ const CONSOLE_KEEP = 500
21
+ const NETWORK_KEEP = 500
22
+ const CDP_TIMEOUT_MS = 20_000
23
+
24
+ export type DeviceWidth = 'desktop' | 'tablet' | 'phone'
25
+ export const DEVICE_SIZES: Record<DeviceWidth, { label: string; width: number; height: number }> = {
26
+ desktop: { label: 'Desktop · 1440', width: 1440, height: 900 },
27
+ tablet: { label: 'Tablet · 768', width: 768, height: 1024 },
28
+ phone: { label: 'Phone · 375', width: 375, height: 812 },
29
+ }
30
+
31
+ export interface ConsoleEntry {
32
+ level: 'error' | 'warning' | 'info' | 'log' | 'debug'
33
+ text: string
34
+ source: string | null
35
+ at: number
36
+ }
37
+
38
+ export interface NetworkEntry {
39
+ id: string
40
+ method: string
41
+ url: string
42
+ status: number | null
43
+ type: string
44
+ ms: number | null
45
+ failed: string | null
46
+ at: number
47
+ }
48
+
49
+ /** A request that failed: refused, aborted, or answered 400 or above. */
50
+ export const requestFailed = (n: Pick<NetworkEntry, 'failed' | 'status'>): boolean => !!n.failed || (n.status ?? 0) >= 400
51
+
52
+ /**
53
+ * `browser_network`'s answer: the requests, failed ones first (only them with
54
+ * `onlyFailed`), and how many were captured when none is shown, so "none
55
+ * failed" never reads as "nothing was captured".
56
+ */
57
+ export function networkText(entries: readonly NetworkEntry[], onlyFailed: boolean, limit = 200): string {
58
+ const failed = entries.filter(requestFailed)
59
+ const shown = (onlyFailed ? failed : [...failed, ...entries.filter((n) => !requestFailed(n))]).slice(0, limit)
60
+ if (!entries.length) return 'No requests captured since the tab opened.'
61
+ if (!shown.length) return `${entries.length} request${entries.length === 1 ? '' : 's'} captured since the tab opened; none failed.`
62
+ const lines = shown.map((n) => `${n.method} ${n.url.slice(0, 200)} → ${n.failed ?? n.status ?? 'pending'}${n.ms !== null ? ` (${n.ms} ms)` : ''}`)
63
+ // With onlyFailed, the rest are said as a count: what was captured besides the failures.
64
+ const rest = onlyFailed ? entries.length - failed.length : 0
65
+ return [...lines, ...(rest ? [`${rest} more request${rest === 1 ? '' : 's'} captured, none failed.`] : [])].join('\n')
66
+ }
67
+
68
+ export interface PageDialog {
69
+ type: 'alert' | 'confirm' | 'prompt' | 'beforeunload'
70
+ message: string
71
+ defaultPrompt: string
72
+ at: number
73
+ }
74
+
75
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
76
+
77
+ /** A loopback address, or a page with no address: what belongs to the project on this machine. */
78
+ export function isProjectAddress(url: string): boolean {
79
+ try {
80
+ const { hostname, protocol } = new URL(url)
81
+ if (protocol === 'about:' || protocol === 'data:') return true
82
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname.endsWith('.localhost')
83
+ } catch {
84
+ return false
85
+ }
86
+ }
87
+
88
+ /** The site an address belongs to, for an answer: `github.com`. */
89
+ export function siteOf(url: string): string {
90
+ try {
91
+ return new URL(url).host
92
+ } catch {
93
+ return url
94
+ }
95
+ }
96
+
97
+ let nextId = 0
98
+
99
+ export class HeadlessTab {
100
+ readonly id = `h${++nextId}`
101
+ readonly createdAt = Date.now()
102
+ device: DeviceWidth = 'desktop'
103
+ viewport: { width: number; height: number | null } | null = null
104
+ /** When the agent last acted in it. */
105
+ agentAt = Date.now()
106
+ snapshot: Snapshot | null = null
107
+ snapshotAt = 0
108
+ navigatedAt = 0
109
+ readonly refs = new RefBook()
110
+ dialog: PageDialog | null = null
111
+ console: ConsoleEntry[] = []
112
+ network = new Map<string, NetworkEntry>()
113
+ private pendingDialog: Dialog | null = null
114
+ private dialogWaiters = new Set<() => void>()
115
+ /** Each secret this tab has seen, with what it is shown as instead. */
116
+ private secrets = new Map<string, string>()
117
+ private inflight = new Set<string>()
118
+ private requestIds = new WeakMap<object, string>()
119
+ private requestCounter = 0
120
+ private closed = false
121
+ readonly frames: PageFrames
122
+ /** Sessions of the frames Chrome renders in a process of their own, by frame id. */
123
+ private frameSessions = new Map<string, { session: CDPSession; cdp: Cdp }>()
124
+ private frameIds = new WeakMap<Frame, string | null>()
125
+
126
+ private constructor(
127
+ readonly page: Page,
128
+ private readonly session: CDPSession,
129
+ /** The project folder the tab was opened for, where its screenshots go. */
130
+ readonly root: string,
131
+ ) {
132
+ this.frames = new PageFrames({ root: (method, params) => this.cdp(method, params), outOfProcess: (frameId) => this.outOfProcess(frameId) }, 'frontera')
133
+ }
134
+
135
+ /** One DevTools command on a session, bounded. */
136
+ private bounded<T>(session: CDPSession, method: string, params: Record<string, unknown>): Promise<T> {
137
+ let timer: ReturnType<typeof setTimeout> | undefined
138
+ return (Promise.race([
139
+ session.send(method as never, params as never),
140
+ new Promise((_, reject) => {
141
+ timer = setTimeout(() => reject(new Error(`the page did not answer ${method}`)), CDP_TIMEOUT_MS)
142
+ }),
143
+ ]) as Promise<T>).finally(() => clearTimeout(timer))
144
+ }
145
+
146
+ /**
147
+ * A frame Chrome renders in a process of its own has a session of its own;
148
+ * Playwright opens one for such a frame, and refuses for a frame that
149
+ * renders in its parent's process.
150
+ */
151
+ private async outOfProcess(frameId: string): Promise<Cdp | null> {
152
+ const known = this.frameSessions.get(frameId)
153
+ if (known) return known.cdp
154
+ for (const frame of this.page.frames()) {
155
+ if (frame === this.page.mainFrame() || this.frameIds.has(frame)) continue
156
+ try {
157
+ const session = await this.page.context().newCDPSession(frame)
158
+ const tree = await this.bounded<{ frameTree: { frame: { id: string } } }>(session, 'Page.getFrameTree', {})
159
+ const id = tree.frameTree.frame.id
160
+ this.frameIds.set(frame, id)
161
+ const cdp: Cdp = (method, params) => (this.alive ? this.bounded(session, method, params ?? {}) : Promise.reject(new Error('the page is closed')))
162
+ this.frameSessions.set(id, { session, cdp })
163
+ await Promise.all([cdp('DOM.enable'), cdp('Page.enable'), cdp('Runtime.enable')]).catch(() => {})
164
+ } catch {
165
+ this.frameIds.set(frame, null)
166
+ }
167
+ }
168
+ return this.frameSessions.get(frameId)?.cdp ?? null
169
+ }
170
+
171
+ /** A frame that navigated may have moved to another process: look its session up again. */
172
+ private forgetFrame(frame: Frame): void {
173
+ const id = this.frameIds.get(frame)
174
+ this.frameIds.delete(frame)
175
+ if (!id) return
176
+ const held = this.frameSessions.get(id)
177
+ this.frameSessions.delete(id)
178
+ this.frames.frameNavigated(id)
179
+ void held?.session.detach().catch(() => {})
180
+ }
181
+
182
+ static async open(page: Page, root: string): Promise<HeadlessTab> {
183
+ const session = await page.context().newCDPSession(page)
184
+ const tab = new HeadlessTab(page, session, root)
185
+ tab.attach()
186
+ await Promise.all([session.send('DOM.enable'), session.send('Page.enable'), session.send('Runtime.enable')]).catch(() => {})
187
+ return tab
188
+ }
189
+
190
+ get alive(): boolean {
191
+ return !this.closed && !this.page.isClosed()
192
+ }
193
+
194
+ /** One DevTools command, bounded: a page that is gone must not hold the agent. */
195
+ async cdp<T = Record<string, unknown>>(method: string, params: Record<string, unknown> = {}): Promise<T> {
196
+ if (!this.alive) throw new Error('the page is closed')
197
+ return this.bounded<T>(this.session, method, params)
198
+ }
199
+
200
+ private attach(): void {
201
+ const { page } = this
202
+ // Another document: its nodes, and so its refs, are new. Same-document navigations keep them.
203
+ this.session.on('Page.frameNavigated', (p: { frame: { id: string; parentId?: string } }) => {
204
+ if (p.frame.parentId) {
205
+ this.frames.frameNavigated(p.frame.id)
206
+ return
207
+ }
208
+ this.navigatedAt = Date.now()
209
+ this.refs.reset()
210
+ this.frames.reset()
211
+ })
212
+ page.on('framenavigated', (f) => {
213
+ // A frame's address can carry a credential (an App's launch token).
214
+ this.noteSecretsIn(f.url())
215
+ if (f !== page.mainFrame()) this.forgetFrame(f)
216
+ })
217
+ page.on('framedetached', (f) => this.forgetFrame(f))
218
+ page.on('console', (m) => {
219
+ const type = m.type()
220
+ const level: ConsoleEntry['level'] = type === 'error' || type === 'assert' ? 'error' : type === 'warning' ? 'warning' : type === 'info' ? 'info' : type === 'debug' ? 'debug' : 'log'
221
+ const loc = m.location()
222
+ this.pushConsole({ level, text: m.text().slice(0, 2_000), source: loc?.url ? `${loc.url.split('/').pop()}:${(loc.lineNumber ?? 0) + 1}` : null, at: Date.now() })
223
+ })
224
+ page.on('pageerror', (e) => this.pushConsole({ level: 'error', text: `Uncaught ${String(e?.stack ?? e?.message ?? e).slice(0, 2_000)}`, source: null, at: Date.now() }))
225
+ page.on('request', (r) => {
226
+ if (r.url().startsWith('data:')) return
227
+ // Before the request is listed: its address can carry a credential (an App frame's launch token).
228
+ this.noteSecretsIn(r.url())
229
+ const id = `r${++this.requestCounter}`
230
+ this.requestIds.set(r, id)
231
+ this.inflight.add(id)
232
+ this.putNetwork({ id, method: r.method(), url: r.url(), status: null, type: r.resourceType(), ms: null, failed: null, at: Date.now() })
233
+ })
234
+ page.on('response', (res) => {
235
+ const id = this.requestIds.get(res.request())
236
+ const entry = id ? this.network.get(id) : undefined
237
+ if (entry) this.putNetwork({ ...entry, status: res.status() })
238
+ })
239
+ const finish = (r: import('playwright-core').Request, failed: string | null) => {
240
+ const id = this.requestIds.get(r)
241
+ if (!id) return
242
+ this.inflight.delete(id)
243
+ const entry = this.network.get(id)
244
+ if (entry) this.putNetwork({ ...entry, ms: Date.now() - entry.at, failed })
245
+ }
246
+ page.on('requestfinished', (r) => finish(r, null))
247
+ page.on('requestfailed', (r) => finish(r, r.failure()?.errorText ?? 'failed'))
248
+ // With a listener, Playwright leaves the dialog open until it is answered, as a person would see it.
249
+ page.on('dialog', (d) => {
250
+ const type = d.type()
251
+ this.pendingDialog = d
252
+ this.dialog = {
253
+ type: type === 'confirm' || type === 'prompt' || type === 'beforeunload' ? type : 'alert',
254
+ message: this.redact(d.message()).slice(0, 2_000),
255
+ defaultPrompt: d.defaultValue(),
256
+ at: Date.now(),
257
+ }
258
+ for (const wake of this.dialogWaiters) wake()
259
+ })
260
+ page.on('close', () => {
261
+ this.closed = true
262
+ })
263
+ }
264
+
265
+ /** Never show a secret this tab was given or saw: `text` with each one blanked out. */
266
+ redact(text: string): string {
267
+ return redactWith(text, this.secrets)
268
+ }
269
+
270
+ /** A credential on a local address (a signed Studio link, an App's launch token) is a secret from here on. */
271
+ noteSecretsIn(url: string): void {
272
+ for (const c of credentialsIn(url)) this.addSecret(c.secret, c.label)
273
+ }
274
+
275
+ addSecret(secret: string | null | undefined, label = STUDIO_SESSION): void {
276
+ if (secret && secret.length >= 8 && !this.secrets.has(secret)) this.secrets.set(secret, label)
277
+ }
278
+
279
+ private pushConsole(entry: ConsoleEntry): void {
280
+ if (this.secrets.size) entry = { ...entry, text: this.redact(entry.text), source: entry.source ? this.redact(entry.source) : null }
281
+ this.console.push(entry)
282
+ if (this.console.length > CONSOLE_KEEP) this.console.splice(0, this.console.length - CONSOLE_KEEP)
283
+ }
284
+
285
+ private putNetwork(entry: NetworkEntry): void {
286
+ if (this.secrets.size) entry = { ...entry, url: this.redact(entry.url) }
287
+ this.network.set(entry.id, entry)
288
+ if (this.network.size > NETWORK_KEEP) this.network.delete(this.network.keys().next().value!)
289
+ }
290
+
291
+ get url(): string {
292
+ return this.alive ? this.redact(this.page.url()) : ''
293
+ }
294
+
295
+ async title(): Promise<string> {
296
+ return this.alive ? this.redact(await this.page.title().catch(() => '')) : ''
297
+ }
298
+
299
+ /** Load an address and wait for its document, bounded; why it failed, or null. */
300
+ async load(url: string, timeoutMs = 30_000): Promise<string | null> {
301
+ this.noteSecretsIn(url)
302
+ try {
303
+ await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs })
304
+ return null
305
+ } catch (err) {
306
+ const message = (err as Error).message
307
+ // A redirect, or a page that aborted its own load, still shows something.
308
+ if (/ERR_ABORTED/.test(message)) return null
309
+ // A dialog during load holds it; the page is where it is.
310
+ if (this.dialog) return null
311
+ return this.redact(message.split('\n')[0] ?? message).slice(0, 300)
312
+ }
313
+ }
314
+
315
+ /** Back, forward or reload, waiting for the page it went to; why it failed, or null. */
316
+ async go(action: 'back' | 'forward' | 'reload', timeoutMs = 30_000): Promise<string | null> {
317
+ try {
318
+ if (action === 'reload') {
319
+ await this.page.reload({ waitUntil: 'domcontentloaded', timeout: timeoutMs })
320
+ return null
321
+ }
322
+ const h = await this.cdp<{ currentIndex: number; entries: unknown[] }>('Page.getNavigationHistory').catch(() => null)
323
+ if (h && action === 'back' && h.currentIndex <= 0) return 'There is no page to go back to.'
324
+ if (h && action === 'forward' && h.currentIndex >= h.entries.length - 1) return 'There is no page to go forward to.'
325
+ // Null for a step inside the same document, which still moved the page.
326
+ if (action === 'back') await this.page.goBack({ waitUntil: 'domcontentloaded', timeout: timeoutMs })
327
+ else await this.page.goForward({ waitUntil: 'domcontentloaded', timeout: timeoutMs })
328
+ return null
329
+ } catch (err) {
330
+ const message = (err as Error).message
331
+ return /ERR_ABORTED/.test(message) ? null : this.redact(message.split('\n')[0] ?? message).slice(0, 300)
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Wait until no request runs for a moment, bounded: a dev server's
337
+ * websocket never closes, so this never fails. True when the page went
338
+ * quiet, false when the time ran out first.
339
+ */
340
+ async settle(maxMs = 8_000, quietMs = 500): Promise<boolean> {
341
+ const deadline = Date.now() + maxMs
342
+ let quietSince = this.inflight.size === 0 ? Date.now() : 0
343
+ while (Date.now() < deadline && this.alive) {
344
+ await sleep(100)
345
+ if (this.dialog) return false
346
+ if (this.inflight.size) quietSince = 0
347
+ else if (!quietSince) quietSince = Date.now()
348
+ else if (Date.now() - quietSince >= quietMs) return true
349
+ }
350
+ return false
351
+ }
352
+
353
+ /** Evaluate the browser's own expression in the main document's isolated world. */
354
+ evaluate<T>(expression: string): Promise<T | null> {
355
+ return this.frames.evaluate<T>(this.frames.main, expression)
356
+ }
357
+
358
+ /** Call the browser's function on an element (its handle), in its frame's isolated world. */
359
+ callOn<T>(handle: number, fn: string, args: unknown[] = []): Promise<T | null> {
360
+ return this.frames.callOn<T>(handle, fn, args)
361
+ }
362
+
363
+ async takeSnapshot(): Promise<Snapshot> {
364
+ const { nodes, frames } = await this.frames.snapshotTree()
365
+ const snap = formatSnapshot(nodes, { url: this.url, title: await this.title(), refFor: this.refs.refFor, frames })
366
+ this.snapshot = this.secrets.size ? { ...snap, text: this.redact(snap.text) } : snap
367
+ this.snapshotAt = Date.now()
368
+ return this.snapshot
369
+ }
370
+
371
+ nodeForRef(ref: string): number | undefined {
372
+ return this.refs.node(ref)
373
+ }
374
+
375
+ /** The address of the frame an element is in, null for one on the page itself. */
376
+ frameAddressOf(handle: number): string | null {
377
+ return this.frames.addressOf(handle)
378
+ }
379
+
380
+ async isShown(backendNodeId: number): Promise<boolean> {
381
+ try {
382
+ return (await this.callOn<boolean>(backendNodeId, VISIBLE)) === true
383
+ } catch {
384
+ return false
385
+ }
386
+ }
387
+
388
+ private holds(c: WaitCondition, frames: PageFrame[]): Promise<boolean | string> {
389
+ return conditionHolds(this.frames, frames, c, (handle) => this.isShown(handle))
390
+ }
391
+
392
+ /** Wait until the condition holds, bounded: network idle first, then the rest every 200 ms. */
393
+ async waitFor(c: WaitCondition, timeoutMs: number): Promise<{ met: boolean; waitedMs: number; error?: string }> {
394
+ const start = Date.now()
395
+ const deadline = start + timeoutMs
396
+ if (c.networkIdle) {
397
+ const quiet = await this.settle(timeoutMs, 500)
398
+ if (!quiet) return { met: false, waitedMs: Date.now() - start }
399
+ }
400
+ const rest = c.text !== undefined || c.textGone !== undefined || c.selector !== undefined || c.ref !== undefined
401
+ if (!rest) return { met: true, waitedMs: Date.now() - start }
402
+ // Text and selectors are looked for in every frame (an App embedded in Studio), found again now and then.
403
+ const scan = c.text !== undefined || c.textGone !== undefined || c.selector !== undefined
404
+ let frames: PageFrame[] = scan ? await this.frames.all() : [this.frames.main]
405
+ let found = Date.now()
406
+ for (;;) {
407
+ if (this.dialog) return { met: false, waitedMs: Date.now() - start }
408
+ if (scan && Date.now() - found > FRAMES_AGAIN_MS) {
409
+ frames = await this.frames.all()
410
+ found = Date.now()
411
+ }
412
+ const h = await this.holds(c, frames)
413
+ if (typeof h === 'string') return { met: false, waitedMs: Date.now() - start, error: h }
414
+ if (h) return { met: true, waitedMs: Date.now() - start }
415
+ if (Date.now() >= deadline || !this.alive) return { met: false, waitedMs: Date.now() - start }
416
+ await sleep(200)
417
+ }
418
+ }
419
+
420
+ /** Run an input the page may answer with a dialog: it returns when the input is done, or as soon as a dialog opens. */
421
+ private async unlessDialog(action: () => Promise<void>): Promise<void> {
422
+ if (this.dialog) return
423
+ let wake: () => void = () => {}
424
+ const opened = new Promise<'dialog'>((r) => {
425
+ wake = () => r('dialog')
426
+ })
427
+ this.dialogWaiters.add(wake)
428
+ const run = action().then(
429
+ () => null,
430
+ (err: Error) => err,
431
+ )
432
+ try {
433
+ const first = await Promise.race([run, opened])
434
+ if (first instanceof Error && !this.dialog) throw first
435
+ } finally {
436
+ this.dialogWaiters.delete(wake)
437
+ }
438
+ }
439
+
440
+ async answerDialog(accept: boolean, promptText?: string): Promise<void> {
441
+ const d = this.pendingDialog
442
+ if (!d) throw new Error('no dialog is open')
443
+ if (accept) await d.accept(promptText)
444
+ else await d.dismiss()
445
+ this.pendingDialog = null
446
+ this.dialog = null
447
+ }
448
+
449
+ /** The middle of an element on screen, scrolled into view (with the frames it is in). */
450
+ center(handle: number): Promise<{ x: number; y: number }> {
451
+ return this.frames.center(handle)
452
+ }
453
+
454
+ async click(backendNodeId: number, opts: { double?: boolean; button?: 'left' | 'right' | 'middle' } = {}): Promise<void> {
455
+ const at = await this.center(backendNodeId)
456
+ const button = opts.button ?? 'left'
457
+ await this.unlessDialog(async () => {
458
+ await this.cdp('Input.dispatchMouseEvent', { type: 'mouseMoved', x: at.x, y: at.y })
459
+ for (let n = 1; n <= (opts.double ? 2 : 1); n++) {
460
+ await this.cdp('Input.dispatchMouseEvent', { type: 'mousePressed', x: at.x, y: at.y, button, clickCount: n })
461
+ await this.cdp('Input.dispatchMouseEvent', { type: 'mouseReleased', x: at.x, y: at.y, button, clickCount: n })
462
+ }
463
+ })
464
+ }
465
+
466
+ async hover(backendNodeId: number): Promise<void> {
467
+ const at = await this.center(backendNodeId)
468
+ await this.cdp('Input.dispatchMouseEvent', { type: 'mouseMoved', x: at.x, y: at.y })
469
+ }
470
+
471
+ async pressKey(press: KeyPress, backendNodeId?: number): Promise<void> {
472
+ if (backendNodeId !== undefined) {
473
+ await this.center(backendNodeId).catch(() => null)
474
+ await this.frames.focus(backendNodeId, FOCUS)
475
+ }
476
+ await this.unlessDialog(async () => {
477
+ const base = { key: press.key, code: press.code, windowsVirtualKeyCode: press.keyCode, nativeVirtualKeyCode: press.keyCode, modifiers: press.modifiers }
478
+ await this.cdp('Input.dispatchKeyEvent', { type: press.text ? 'keyDown' : 'rawKeyDown', ...base, ...(press.text ? { text: press.text, unmodifiedText: press.text } : {}) })
479
+ await this.cdp('Input.dispatchKeyEvent', { type: 'keyUp', ...base })
480
+ })
481
+ }
482
+
483
+ async selectOptions(backendNodeId: number, values: string[]): Promise<{ values: string[] } | { error: string }> {
484
+ await this.center(backendNodeId).catch(() => null)
485
+ return (await this.callOn<{ values: string[] } | { error: string }>(backendNodeId, SELECT_OPTIONS, [values])) ?? { error: 'the page did not answer' }
486
+ }
487
+
488
+ /**
489
+ * The agent's expression, in the page's own world (where the App's state
490
+ * lives), bounded in time. With an element (its handle), in the world of
491
+ * the frame it is in, a function called with the element.
492
+ */
493
+ async evaluateInPage(expression: string, cap: number, timeoutMs = 10_000, handle?: number): Promise<{ type: string; json: string; length: number } | { error: string }> {
494
+ type Evaluated = { result: { value?: { type: string; json: string; length: number } }; exceptionDetails?: { text?: string; exception?: { description?: string } } }
495
+ const box: { res: Evaluated | null } = { res: null }
496
+ await this.unlessDialog(async () => {
497
+ const run = async (): Promise<Evaluated> => {
498
+ if (handle === undefined) return this.cdp<Evaluated>('Runtime.evaluate', { expression: evaluateWrapper(expression, cap), returnByValue: true, awaitPromise: true, userGesture: false, timeout: timeoutMs })
499
+ const { frame, node } = this.frames.locate(handle)
500
+ const resolved = await frame.cdp<{ object: { objectId?: string } }>('DOM.resolveNode', { backendNodeId: node })
501
+ if (!resolved.object.objectId) throw new Error('the element is gone: take a new browser_snapshot')
502
+ return frame.cdp<Evaluated>('Runtime.callFunctionOn', { objectId: resolved.object.objectId, functionDeclaration: evaluateOnElement(expression, cap), returnByValue: true, awaitPromise: true, userGesture: false })
503
+ }
504
+ box.res = await Promise.race([run().catch((err: Error) => ({ result: {}, exceptionDetails: { text: err.message } }) as Evaluated), sleep(timeoutMs + 1_000).then(() => null)])
505
+ })
506
+ if (this.dialog) return { error: 'it opened a dialog' }
507
+ const res = box.res
508
+ if (!res) return { error: `it did not finish within ${timeoutMs / 1000} seconds` }
509
+ if (res.exceptionDetails) return { error: (res.exceptionDetails.exception?.description ?? res.exceptionDetails.text ?? 'it threw').slice(0, 2_000) }
510
+ return res.result.value ?? { error: 'it answered nothing the page could send back' }
511
+ }
512
+
513
+ async isPassword(handle: number): Promise<boolean> {
514
+ const { frame, node: backendNodeId } = this.frames.locate(handle)
515
+ const { node } = await frame.cdp<{ node: { attributes?: string[] } }>('DOM.describeNode', { backendNodeId })
516
+ const attrs = node.attributes ?? []
517
+ for (let i = 0; i < attrs.length; i += 2) if (attrs[i] === 'type' && attrs[i + 1]?.toLowerCase() === 'password') return true
518
+ return attrs.some((a, i) => i % 2 === 0 && a === 'autocomplete' && /password/i.test(attrs[i + 1] ?? ''))
519
+ }
520
+
521
+ async type(backendNodeId: number, text: string, opts: { clear: boolean; submit: boolean }): Promise<void> {
522
+ await this.center(backendNodeId).catch(() => null)
523
+ await this.unlessDialog(async () => {
524
+ await this.frames.focus(backendNodeId, opts.clear ? SELECT_CONTENTS : CARET_TO_END)
525
+ await this.cdp('Input.insertText', { text })
526
+ if (opts.submit) {
527
+ await this.cdp('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' })
528
+ await this.cdp('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 })
529
+ }
530
+ })
531
+ }
532
+
533
+ async setSize(size: { device: DeviceWidth } | { width: number; height: number | null }): Promise<void> {
534
+ if ('device' in size) {
535
+ this.device = size.device
536
+ this.viewport = null
537
+ const d = DEVICE_SIZES[size.device]
538
+ await this.page.setViewportSize({ width: d.width, height: d.height })
539
+ } else {
540
+ this.device = 'desktop'
541
+ this.viewport = size
542
+ await this.page.setViewportSize({ width: size.width, height: size.height ?? this.page.viewportSize()?.height ?? DEVICE_SIZES.desktop.height })
543
+ }
544
+ }
545
+
546
+ async viewportWidth(): Promise<number> {
547
+ const m = await this.cdp<{ cssLayoutViewport?: { clientWidth: number } }>('Page.getLayoutMetrics').catch(() => null)
548
+ return m?.cssLayoutViewport?.clientWidth ?? this.page.viewportSize()?.width ?? 0
549
+ }
550
+
551
+ /** A PNG, base64; a dialog holds the page, so the capture goes through DevTools rather than wait for it. */
552
+ async screenshot(opts: { fullPage?: boolean } = {}): Promise<string> {
553
+ if (opts.fullPage) {
554
+ const metrics = await this.cdp<{ cssContentSize: { width: number; height: number } }>('Page.getLayoutMetrics')
555
+ const { width, height } = metrics.cssContentSize
556
+ const { data } = await this.cdp<{ data: string }>('Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width: Math.ceil(width), height: Math.min(Math.ceil(height), 16_000), scale: 1 }, captureBeyondViewport: true })
557
+ return data
558
+ }
559
+ const { data } = await this.cdp<{ data: string }>('Page.captureScreenshot', { format: 'png' })
560
+ return data
561
+ }
562
+
563
+ pageText(): Promise<string | null> {
564
+ return this.evaluate<string>(PAGE_TEXT)
565
+ }
566
+
567
+ async close(): Promise<void> {
568
+ this.closed = true
569
+ for (const { session } of this.frameSessions.values()) void session.detach().catch(() => {})
570
+ this.frameSessions.clear()
571
+ // A page waiting on a dialog closes only once it is answered.
572
+ await this.pendingDialog?.dismiss().catch(() => {})
573
+ await this.page.close().catch(() => {})
574
+ }
575
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A key the agent presses (`browser_press_key`), as the DevTools Protocol's
3
+ * `Input.dispatchKeyEvent` takes it. Names follow Playwright's: `Enter`,
4
+ * `ArrowDown`, `a`, and combinations such as `Shift+Tab` or `Meta+a`.
5
+ * Shared by Forge's Browser and the headless one `frontera mcp` runs.
6
+ */
7
+
8
+ export interface KeyPress {
9
+ key: string
10
+ code: string
11
+ keyCode: number
12
+ /** What the key types; none for a key that only moves or edits, or with Control, Alt or Meta held. */
13
+ text?: string
14
+ /** Alt 1, Control 2, Meta 4, Shift 8. */
15
+ modifiers: number
16
+ }
17
+
18
+ const NAMED: Record<string, { code: string; keyCode: number; text?: string }> = {
19
+ Enter: { code: 'Enter', keyCode: 13, text: '\r' },
20
+ Tab: { code: 'Tab', keyCode: 9 },
21
+ Escape: { code: 'Escape', keyCode: 27 },
22
+ Backspace: { code: 'Backspace', keyCode: 8 },
23
+ Delete: { code: 'Delete', keyCode: 46 },
24
+ Space: { code: 'Space', keyCode: 32, text: ' ' },
25
+ ArrowUp: { code: 'ArrowUp', keyCode: 38 },
26
+ ArrowDown: { code: 'ArrowDown', keyCode: 40 },
27
+ ArrowLeft: { code: 'ArrowLeft', keyCode: 37 },
28
+ ArrowRight: { code: 'ArrowRight', keyCode: 39 },
29
+ Home: { code: 'Home', keyCode: 36 },
30
+ End: { code: 'End', keyCode: 35 },
31
+ PageUp: { code: 'PageUp', keyCode: 33 },
32
+ PageDown: { code: 'PageDown', keyCode: 34 },
33
+ Insert: { code: 'Insert', keyCode: 45 },
34
+ ...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`F${i + 1}`, { code: `F${i + 1}`, keyCode: 112 + i }])),
35
+ }
36
+
37
+ const ALIASES: Record<string, string> = { Esc: 'Escape', Return: 'Enter', Up: 'ArrowUp', Down: 'ArrowDown', Left: 'ArrowLeft', Right: 'ArrowRight', Del: 'Delete', ' ': 'Space' }
38
+
39
+ const MODIFIERS: Record<string, number> = { Alt: 1, Option: 1, Control: 2, Ctrl: 2, Meta: 4, Command: 4, Cmd: 4, Shift: 8 }
40
+
41
+ const PUNCTUATION: Record<string, { code: string; keyCode: number }> = {
42
+ '-': { code: 'Minus', keyCode: 189 }, '=': { code: 'Equal', keyCode: 187 }, '[': { code: 'BracketLeft', keyCode: 219 }, ']': { code: 'BracketRight', keyCode: 221 },
43
+ '\\': { code: 'Backslash', keyCode: 220 }, ';': { code: 'Semicolon', keyCode: 186 }, "'": { code: 'Quote', keyCode: 222 }, ',': { code: 'Comma', keyCode: 188 },
44
+ '.': { code: 'Period', keyCode: 190 }, '/': { code: 'Slash', keyCode: 191 }, '`': { code: 'Backquote', keyCode: 192 },
45
+ }
46
+
47
+ /** The key event for a name, or why it is not one. */
48
+ export function parseKey(name: string): KeyPress | { error: string } {
49
+ const raw = name.trim()
50
+ if (!raw) return { error: 'key is required, e.g. Enter, Escape, ArrowDown, a or Shift+Tab.' }
51
+ // `+` alone, or as the last part (`Shift++`), is the plus key.
52
+ const parts = raw === '+' ? ['+'] : raw.endsWith('++') ? [...raw.slice(0, -2).split('+'), '+'] : raw.split('+')
53
+ const last = parts.pop()!
54
+ let modifiers = 0
55
+ for (const p of parts) {
56
+ const m = MODIFIERS[p.trim()]
57
+ if (m === undefined) return { error: `"${p}" is not a modifier: use Shift, Control, Alt or Meta.` }
58
+ modifiers |= m
59
+ }
60
+ const keyName = ALIASES[last] ?? last
61
+ const named = NAMED[keyName]
62
+ const typing = (modifiers & ~8) === 0
63
+ if (named) return { key: keyName === 'Space' ? ' ' : keyName, code: named.code, keyCode: named.keyCode, ...(named.text && typing ? { text: named.text } : {}), modifiers }
64
+ if ([...keyName].length !== 1) return { error: `Unknown key "${last}": use a name such as Enter, Tab, Escape, Backspace, ArrowDown, Home, PageDown or F5, or a single character.` }
65
+ const shift = (modifiers & 8) !== 0
66
+ const char = /^[a-z]$/i.test(keyName) ? (shift ? keyName.toUpperCase() : keyName) : keyName
67
+ const code = /^[a-z]$/i.test(keyName) ? `Key${keyName.toUpperCase()}` : /^\d$/.test(keyName) ? `Digit${keyName}` : (PUNCTUATION[keyName]?.code ?? '')
68
+ const keyCode = /^[a-z0-9]$/i.test(keyName) ? keyName.toUpperCase().charCodeAt(0) : (PUNCTUATION[keyName]?.keyCode ?? 0)
69
+ return { key: char, code, keyCode, ...(typing ? { text: char } : {}), modifiers }
70
+ }