@frontera-sdk/cli 1.52.39 → 1.52.41

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 (235) 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 +49 -5
  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/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The functions the agent's browser runs inside a page, as source text for
3
+ * the Chrome DevTools Protocol, shared by Forge's Browser and the headless
4
+ * one `frontera mcp` runs without Forge. They run in an isolated world except
5
+ * where noted, so the page's scripts can neither see nor change them. The
6
+ * agent's own script (`browser_evaluate`) runs in the page's world, wrapped
7
+ * by `evaluateWrapper`, never here.
8
+ */
9
+
10
+ /** The element's own text, short. Called on the element. */
11
+ export const ELEMENT_TEXT = `function () { return (this.innerText || this.value || this.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 300) }`
12
+
13
+ /** Select what a field holds, so typed text replaces it. Called on the element. */
14
+ export const SELECT_CONTENTS = `function () {
15
+ this.focus()
16
+ if (this.isContentEditable) { const r = document.createRange(); r.selectNodeContents(this); const s = getSelection(); s.removeAllRanges(); s.addRange(r) }
17
+ else if (typeof this.select === 'function') this.select()
18
+ }`
19
+
20
+ /** Put the caret at the end, so typed text is added. Called on the element. */
21
+ export const CARET_TO_END = `function () {
22
+ this.focus()
23
+ if (typeof this.setSelectionRange === 'function' && typeof this.value === 'string') { try { this.setSelectionRange(this.value.length, this.value.length) } catch {} }
24
+ }`
25
+
26
+ /** The page's visible text, clipped. */
27
+ export const PAGE_TEXT = `(document.body ? document.body.innerText : '').trim().slice(0, 4000)`
28
+
29
+ /** Whether the element is in the document and drawn: some size, not hidden. Called on the element. */
30
+ export const VISIBLE = `function () {
31
+ if (!this.isConnected) return false
32
+ const el = this.nodeType === 1 ? this : this.parentElement
33
+ if (!el) return false
34
+ const r = el.getBoundingClientRect()
35
+ const s = getComputedStyle(el)
36
+ return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) !== 0
37
+ }`
38
+
39
+ /** Whether a CSS selector matches a drawn element: true, false, or { error } for a selector that is not one. */
40
+ export function selectorVisible(selector: string): string {
41
+ return `((sel) => {
42
+ let el
43
+ try { el = document.querySelector(sel) } catch (e) { return { error: String(e && e.message || e) } }
44
+ if (!el) return false
45
+ const r = el.getBoundingClientRect()
46
+ const s = getComputedStyle(el)
47
+ return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) !== 0
48
+ })(${JSON.stringify(selector)})`
49
+ }
50
+
51
+ /** Whether the page's visible text holds the text (all of it, not the clipped PAGE_TEXT). */
52
+ export function textPresent(text: string): string {
53
+ return `(document.body ? document.body.innerText : '').includes(${JSON.stringify(text)})`
54
+ }
55
+
56
+ /** Focus the element. Called on the element. */
57
+ export const FOCUS = `function () { if (typeof this.focus === 'function') this.focus() }`
58
+
59
+ /**
60
+ * Choose options in a <select> by value or label, as a person would, and
61
+ * tell the page (input, then change). Answers the chosen values, or { error }.
62
+ * Called on the element with the wanted values.
63
+ */
64
+ export const SELECT_OPTIONS = `function (wanted) {
65
+ if (!this || this.tagName !== 'SELECT') return { error: 'not a <select>: click a custom list box open and click its option instead' }
66
+ if (this.disabled) return { error: 'the list is disabled' }
67
+ const options = [...this.options]
68
+ const picked = []
69
+ for (const w of wanted) {
70
+ const o = options.find((o) => o.value === w) || options.find((o) => o.label === w || o.text.trim() === w)
71
+ if (!o) return { error: 'no option "' + w + '"; there is ' + options.slice(0, 30).map((o) => JSON.stringify(o.label || o.value)).join(', ') }
72
+ if (o.disabled) return { error: 'the option "' + w + '" is disabled' }
73
+ picked.push(o)
74
+ }
75
+ if (picked.length > 1 && !this.multiple) return { error: 'the list takes one option' }
76
+ this.focus()
77
+ for (const o of options) o.selected = picked.includes(o)
78
+ this.dispatchEvent(new Event('input', { bubbles: true }))
79
+ this.dispatchEvent(new Event('change', { bubbles: true }))
80
+ return { values: picked.map((o) => o.value) }
81
+ }`
82
+
83
+ /**
84
+ * The agent's expression, run in the page's own world: a value, a promise,
85
+ * or a function (called with no arguments). The answer comes back as JSON
86
+ * text, clipped to `cap` characters, with what JSON cannot hold described
87
+ * (elements, functions, Maps, Sets, cycles) rather than lost.
88
+ */
89
+ export function evaluateWrapper(expression: string, cap: number): string {
90
+ return `${answerJson(expression, cap, false)}()`
91
+ }
92
+
93
+ /** The agent's expression as an async function, of the element when `element` is set, answering JSON text clipped to `cap`. */
94
+ function answerJson(expression: string, cap: number, element: boolean): string {
95
+ return `(async (${element ? 'element' : ''}) => {
96
+ let v = (${expression}
97
+ )
98
+ if (typeof v === 'function') v = v(${element ? 'element' : ''})
99
+ v = await v
100
+ const seen = new WeakSet()
101
+ const json = JSON.stringify(v === undefined ? null : v, (k, x) => {
102
+ if (typeof x === 'bigint') return x.toString() + 'n'
103
+ if (typeof x === 'function') return '[function ' + (x.name || 'anonymous') + ']'
104
+ if (typeof x === 'symbol') return x.toString()
105
+ if (x && typeof x === 'object') {
106
+ if (typeof Element !== 'undefined' && x instanceof Element) return '<' + x.tagName.toLowerCase() + (x.id ? '#' + x.id : '') + (x.classList && x.classList.length ? '.' + [...x.classList].slice(0, 3).join('.') : '') + '>'
107
+ if (typeof Node !== 'undefined' && x instanceof Node) return '[' + x.nodeName + ']'
108
+ if (x instanceof Map) return { '[Map]': [...x.entries()].slice(0, 200) }
109
+ if (x instanceof Set) return { '[Set]': [...x.values()].slice(0, 200) }
110
+ if (x instanceof Error) return { name: x.name, message: x.message }
111
+ if (seen.has(x)) return '[circular]'
112
+ seen.add(x)
113
+ }
114
+ return x
115
+ })
116
+ const text = json === undefined ? 'null' : json
117
+ return { type: v === undefined ? 'undefined' : v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v, json: text.slice(0, ${cap}), length: text.length }
118
+ })`
119
+ }
120
+
121
+ /**
122
+ * The agent's expression run on an element (`browser_evaluate` with a ref),
123
+ * in the page's own world of the frame the element is in: a function is
124
+ * called with the element; anything else is answered as `evaluateWrapper`
125
+ * answers it. A function declaration for `Runtime.callFunctionOn` on the element.
126
+ */
127
+ export function evaluateOnElement(expression: string, cap: number): string {
128
+ return `function () { return ${answerJson(expression, cap, true)}(this) }`
129
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * What the agent can wait for (`browser_wait_for`, and `waitFor` on the
3
+ * reading tools), read the same way by Forge's Browser and the headless one
4
+ * `frontera mcp` runs without Forge.
5
+ */
6
+
7
+ export interface WaitCondition {
8
+ text?: string
9
+ textGone?: string
10
+ selector?: string
11
+ /** For a selector or a ref: wait until it shows (default) or until it is gone or hidden. */
12
+ state?: 'visible' | 'hidden'
13
+ /** The ref's DOM node (`backendNodeId`). */
14
+ ref?: number
15
+ networkIdle?: boolean
16
+ }
17
+
18
+ export interface Wait {
19
+ condition: WaitCondition
20
+ timeoutMs: number
21
+ /** In words, for the answer. */
22
+ what: string
23
+ }
24
+
25
+ /** How long a wait lasts when the agent does not say, and at most. */
26
+ export const WAIT_DEFAULT_MS = 10_000
27
+ export const WAIT_MAX_MS = 60_000
28
+
29
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
30
+
31
+ /**
32
+ * A wait from the agent's input, `null` when it asks for none, or why it is
33
+ * not one. `nodeForRef` names a ref's DOM node on the page (none before the
34
+ * tab has a page).
35
+ */
36
+ export function parseWaitInput(input: Record<string, unknown> | undefined, nodeForRef: ((ref: string) => number | undefined) | null): Wait | { error: string } | null {
37
+ if (!input || typeof input !== 'object') return null
38
+ const condition: WaitCondition = {}
39
+ const what: string[] = []
40
+ const t = typeof input.text === 'string' && input.text ? input.text : undefined
41
+ const gone = typeof input.textGone === 'string' && input.textGone ? input.textGone : undefined
42
+ const selector = str(input.selector)
43
+ const ref = str(input.ref)
44
+ const hidden = input.state === 'hidden' || input.state === 'gone'
45
+ if (t !== undefined) {
46
+ condition.text = t
47
+ what.push(`text ${JSON.stringify(t)} to show`)
48
+ }
49
+ if (gone !== undefined) {
50
+ condition.textGone = gone
51
+ what.push(`text ${JSON.stringify(gone)} to go`)
52
+ }
53
+ if (selector) {
54
+ condition.selector = selector
55
+ condition.state = hidden ? 'hidden' : 'visible'
56
+ what.push(`${selector} to ${hidden ? 'go' : 'show'}`)
57
+ }
58
+ if (ref) {
59
+ const node = nodeForRef?.(ref)
60
+ if (node === undefined) return { error: `No element [ref=${ref}] on this page: take a browser_snapshot, or wait for text or a selector instead.` }
61
+ condition.ref = node
62
+ condition.state = hidden ? 'hidden' : 'visible'
63
+ what.push(`[ref=${ref}] to ${hidden ? 'go' : 'show'}`)
64
+ }
65
+ if (input.networkIdle === true) {
66
+ condition.networkIdle = true
67
+ what.push('the network to go quiet')
68
+ }
69
+ if (!what.length) return null
70
+ const timeout = Number(input.timeout)
71
+ const timeoutMs = Number.isFinite(timeout) && timeout > 0 ? Math.min(timeout * 1000, WAIT_MAX_MS) : WAIT_DEFAULT_MS
72
+ return { condition, timeoutMs, what: what.join(' and ') }
73
+ }
@@ -0,0 +1,126 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { basename, dirname, join, relative, isAbsolute } from 'node:path'
3
+
4
+ import type { ToolResult } from './mcp-protocol'
5
+ import type { ObserveRequest, PageObservation } from './page-observation'
6
+
7
+ /**
8
+ * How `frontera mcp` reaches the Forge window's Browser. On start, and when a
9
+ * project is added, the Forge server writes `forge.json` into the project's
10
+ * `.frontera/local/` (mode 600) with its loopback port and a token made for
11
+ * that project on that start. The token reaches only the browser endpoint for
12
+ * this project, never the rest of Forge's API.
13
+ */
14
+
15
+ export interface ForgeLink {
16
+ version: 1
17
+ port: number
18
+ token: string
19
+ pid: number
20
+ /** Forge's id for the project, so a stale file from another folder is never used. */
21
+ projectId: string
22
+ root: string
23
+ startedAt: string
24
+ }
25
+
26
+ export function forgeLinkPath(stateDir: string): string {
27
+ return join(stateDir, 'forge.json')
28
+ }
29
+
30
+ function alive(pid: number): boolean {
31
+ try {
32
+ process.kill(pid, 0)
33
+ return true
34
+ } catch (err) {
35
+ // EPERM: it runs, as another user.
36
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
37
+ }
38
+ }
39
+
40
+ function readLinkFile(file: string, isAlive: (pid: number) => boolean): ForgeLink | null {
41
+ if (!existsSync(file)) return null
42
+ try {
43
+ const link = JSON.parse(readFileSync(file, 'utf8')) as Partial<ForgeLink>
44
+ if (link.version !== 1 || typeof link.port !== 'number' || typeof link.token !== 'string' || typeof link.pid !== 'number') return null
45
+ return isAlive(link.pid) ? (link as ForgeLink) : null
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ const inside = (folder: string, root: string) => {
52
+ const rel = relative(root, folder)
53
+ return rel === '' || (!!rel && !rel.startsWith('..') && !isAbsolute(rel))
54
+ }
55
+
56
+ /** How many folders up a Forge project can sit above the folder `frontera mcp` started in. */
57
+ const MAX_UP = 6
58
+
59
+ /**
60
+ * The link to a running Forge for this project, or null. When the folder
61
+ * `frontera mcp` took for the project has no link of its own (it started in
62
+ * an App's folder, whose `.frontera` holds the App's render evidence), the
63
+ * Forge project the folder sits in is used: Forge is open for it, so the
64
+ * person's Browser is preferred over the headless one.
65
+ */
66
+ export function readForgeLink(stateDir: string, isAlive: (pid: number) => boolean = alive): ForgeLink | null {
67
+ const own = readLinkFile(forgeLinkPath(stateDir), isAlive)
68
+ if (own) return own
69
+ // Only a project's own state folder (`<root>/.frontera/local`) says where the project is.
70
+ if (basename(stateDir) !== 'local' || basename(dirname(stateDir)) !== '.frontera') return null
71
+ const root = dirname(dirname(stateDir))
72
+ let dir = root
73
+ for (let i = 0; i < MAX_UP; i++) {
74
+ const parent = dirname(dir)
75
+ if (parent === dir) break
76
+ dir = parent
77
+ const link = readLinkFile(join(dir, '.frontera', 'local', 'forge.json'), isAlive)
78
+ if (link) return typeof link.root === 'string' && inside(root, link.root) ? link : null
79
+ }
80
+ return null
81
+ }
82
+
83
+ /** Who is asking: the Forge session the harness runs in, when it runs in one. */
84
+ export function forgeSession(env: NodeJS.ProcessEnv = process.env): string | null {
85
+ return env.FORGE_SESSION?.trim() || null
86
+ }
87
+
88
+ /** One request to Forge's browser endpoint; null when Forge is not reachable or has no window to show a page in. */
89
+ async function forgeRequest(link: ForgeLink, body: Record<string, unknown>, opts: { timeoutMs?: number; fetchImpl?: typeof fetch }): Promise<{ status: number; body: unknown } | null> {
90
+ const doFetch = opts.fetchImpl ?? fetch
91
+ try {
92
+ const res = await doFetch(`http://127.0.0.1:${link.port}/mcp/browser`, {
93
+ method: 'POST',
94
+ headers: { 'content-type': 'application/json', 'x-forge-mcp-token': link.token },
95
+ body: JSON.stringify({ projectId: link.projectId, ...body }),
96
+ // Forge bounds each action itself; a click outside the project waits for the person's answer.
97
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 150_000),
98
+ })
99
+ // 503: Forge runs without its window (the server alone), so nobody would see the page.
100
+ if (res.status === 503) return null
101
+ return { status: res.status, body: await res.json().catch(() => null) }
102
+ } catch (err) {
103
+ // Reached, but slower than the wait: say so rather than send the agent elsewhere.
104
+ if ((err as Error).name === 'TimeoutError') return { status: 504, body: { error: 'Forge did not answer in time. The page may still be loading; take a browser_snapshot to see where it is.' } }
105
+ return null
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Run one browser tool in the Forge window. Answers null when Forge is not
111
+ * reachable, so the caller can fall back or refuse in words.
112
+ */
113
+ export async function callForgeBrowser(link: ForgeLink, tool: string, input: Record<string, unknown>, opts: { session: string | null; timeoutMs?: number; fetchImpl?: typeof fetch } = { session: null }): Promise<ToolResult | null> {
114
+ const res = await forgeRequest(link, { session: opts.session, tool, input }, opts)
115
+ if (!res) return null
116
+ if (res.status >= 400) return { isError: true, content: [{ type: 'text', text: (res.body as { error?: string } | null)?.error ?? `Forge answered ${res.status}` }] }
117
+ return res.body as ToolResult
118
+ }
119
+
120
+ /** Open a page in a Forge Browser tab the person can see, let it settle, and observe it. */
121
+ export async function observeInForge(link: ForgeLink, req: ObserveRequest, opts: { session: string | null; label?: string; fetchImpl?: typeof fetch }): Promise<PageObservation | null> {
122
+ const res = await forgeRequest(link, { session: opts.session, tool: 'observe', input: { ...req, label: opts.label } }, { timeoutMs: 150_000, fetchImpl: opts.fetchImpl })
123
+ if (!res) return null
124
+ if (res.status >= 400) throw new Error((res.body as { error?: string } | null)?.error ?? `Forge answered ${res.status}`)
125
+ return res.body as PageObservation
126
+ }
@@ -0,0 +1,199 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ import { AXE_RUN, summarizeA11y } from './a11y-scan'
7
+ import { credentialsIn, redactDeep, redactWith } from './browser/credentials'
8
+ import type { PageObservation, ObserveRequest } from './page-observation'
9
+
10
+ /**
11
+ * The browser `frontera mcp` uses when Forge is not running (a plain
12
+ * terminal, an IDE, CI): headless Chromium through Playwright. One Chromium
13
+ * serves the whole MCP server process: `app_render`'s observations (each in
14
+ * a context of its own, closed after) and the `browser_*` tools' session
15
+ * (`browser/headless-session.ts`). It closes after a while unused.
16
+ *
17
+ * Chromium is Playwright's pinned build, downloaded on first use. A machine
18
+ * that already has one (`FRONTERA_CHROMIUM_PATH`, or a build under
19
+ * `PLAYWRIGHT_BROWSERS_PATH`) uses it instead.
20
+ */
21
+
22
+ type Playwright = typeof import('playwright-core')
23
+ export type HeadlessChromium = import('playwright-core').Browser
24
+
25
+ function loadPlaywright(): Playwright {
26
+ const require = createRequire(import.meta.url)
27
+ try {
28
+ return require('playwright-core') as Playwright
29
+ } catch {
30
+ throw new Error('The headless browser is not installed with this CLI (playwright-core). Open the project in Forge to render it, or reinstall the CLI.')
31
+ }
32
+ }
33
+
34
+ /** A Chromium already on this machine, newest build first. */
35
+ export function installedChromium(env: NodeJS.ProcessEnv = process.env): string | null {
36
+ if (env.FRONTERA_CHROMIUM_PATH && existsSync(env.FRONTERA_CHROMIUM_PATH)) return env.FRONTERA_CHROMIUM_PATH
37
+ const root = env.PLAYWRIGHT_BROWSERS_PATH
38
+ if (!root || !existsSync(root)) return null
39
+ const builds = readdirSync(root)
40
+ .filter((d) => /^chromium(_headless_shell)?-\d+$/.test(d))
41
+ .sort((a, b) => Number(b.split('-').pop()) - Number(a.split('-').pop()))
42
+ for (const build of builds) {
43
+ for (const exe of ['chrome-linux/chrome', 'chrome-linux/headless_shell', 'chrome-mac/Chromium.app/Contents/MacOS/Chromium', 'chrome-headless-shell-linux64/chrome-headless-shell']) {
44
+ const path = join(root, build, exe)
45
+ if (existsSync(path)) return path
46
+ }
47
+ }
48
+ return null
49
+ }
50
+
51
+ /** Download Playwright's pinned headless Chromium, once. */
52
+ function installChromium(): void {
53
+ const require = createRequire(import.meta.url)
54
+ const cli = join(dirname(require.resolve('playwright-core/package.json')), 'cli.js')
55
+ const result = spawnSync(process.execPath, [cli, 'install', 'chromium-headless-shell'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 10 * 60_000 })
56
+ if (result.status !== 0) throw new Error(`Chromium could not be downloaded for the headless browser: ${String(result.stderr ?? '').trim().slice(-400) || 'no output'}`)
57
+ }
58
+
59
+ async function launch(pw: Playwright): Promise<HeadlessChromium> {
60
+ // Frames from another site in a process of their own, as in Forge and a desktop Chrome: an App embedded in Studio is read and driven the same way in both.
61
+ const args = ['--no-sandbox', '--disable-dev-shm-usage', '--site-per-process']
62
+ try {
63
+ return await pw.chromium.launch({ args })
64
+ } catch (err) {
65
+ if (!/Executable doesn't exist|browserType\.launch/i.test((err as Error).message)) throw err
66
+ const local = installedChromium()
67
+ if (local) return pw.chromium.launch({ args, executablePath: local })
68
+ installChromium()
69
+ return pw.chromium.launch({ args })
70
+ }
71
+ }
72
+
73
+ /** How long the shared Chromium stays up once nothing holds it: renders in a row reuse it. */
74
+ export const LINGER_MS = 60_000
75
+
76
+ let shared: Promise<HeadlessChromium> | null = null
77
+ let users = 0
78
+ let idleTimer: ReturnType<typeof setTimeout> | null = null
79
+
80
+ /**
81
+ * The process's Chromium, launched on first use. Each caller holds it while
82
+ * it works (`release` when done); once nobody holds it for `LINGER_MS`
83
+ * it closes, and the next call launches it again.
84
+ */
85
+ export async function acquireChromium(): Promise<{ browser: HeadlessChromium; release: () => void }> {
86
+ users++
87
+ if (idleTimer) {
88
+ clearTimeout(idleTimer)
89
+ idleTimer = null
90
+ }
91
+ try {
92
+ if (!shared) {
93
+ const launching = launch(loadPlaywright())
94
+ shared = launching
95
+ launching.then(
96
+ (b) => b.on('disconnected', () => {
97
+ if (shared === launching) shared = null
98
+ }),
99
+ () => {
100
+ if (shared === launching) shared = null
101
+ },
102
+ )
103
+ }
104
+ const browser = await shared
105
+ let released = false
106
+ return {
107
+ browser,
108
+ release: () => {
109
+ if (released) return
110
+ released = true
111
+ users--
112
+ if (users > 0) return
113
+ idleTimer = setTimeout(() => {
114
+ idleTimer = null
115
+ if (users > 0 || !shared) return
116
+ const closing = shared
117
+ shared = null
118
+ void closing.then((b) => b.close()).catch(() => {})
119
+ }, LINGER_MS)
120
+ idleTimer.unref?.()
121
+ },
122
+ }
123
+ } catch (err) {
124
+ users--
125
+ throw err
126
+ }
127
+ }
128
+
129
+ /** Open a page headless, let it settle, and report what a person would have seen and the console would have said. */
130
+ export async function observeHeadless(req: ObserveRequest): Promise<PageObservation> {
131
+ const { browser, release } = await acquireChromium()
132
+ const context = await browser.newContext({ viewport: { width: req.width ?? 1440, height: req.height ?? 900 } }).catch((err) => {
133
+ release()
134
+ throw err
135
+ })
136
+ try {
137
+ const page = await context.newPage()
138
+ const console: PageObservation['console'] = []
139
+ const failed: PageObservation['failedRequests'] = []
140
+ const requests: PageObservation['requests'] = []
141
+ // Credentials the page's addresses carry (an App frame's launch token): never in the observation.
142
+ const secrets = new Map<string, string>()
143
+ const note = (url: string) => {
144
+ for (const c of credentialsIn(url)) if (!secrets.has(c.secret)) secrets.set(c.secret, c.label)
145
+ }
146
+ const shown = (url: string) => redactWith(url, secrets).slice(0, 300)
147
+ page.on('request', (r) => note(r.url()))
148
+ page.on('framenavigated', (f) => note(f.url()))
149
+ page.on('console', (m) => {
150
+ const level = m.type()
151
+ if (level === 'error' || level === 'warning') console.push({ level: level === 'warning' ? 'warning' : 'error', text: m.text().slice(0, 500) })
152
+ })
153
+ page.on('pageerror', (e) => console.push({ level: 'error', text: `Uncaught ${String(e?.message ?? e).slice(0, 500)}` }))
154
+ // Redacted before clipping, so a clipped address cannot keep half a token.
155
+ page.on('requestfailed', (r) => failed.push({ url: shown(r.url()), method: r.method(), reason: r.failure()?.errorText ?? 'failed' }))
156
+ page.on('response', (r) => {
157
+ const request = r.request()
158
+ requests.push({ url: shown(r.url()), method: request.method(), status: r.status(), type: request.resourceType() })
159
+ if (r.status() >= 400) failed.push({ url: shown(r.url()), method: request.method(), reason: `HTTP ${r.status()}` })
160
+ })
161
+ let navigationError: string | null = null
162
+ try {
163
+ await page.goto(req.url, { waitUntil: 'domcontentloaded', timeout: 45_000 })
164
+ // Never fatal: a dev server holding a websocket open (HMR) never goes idle.
165
+ await page.waitForLoadState('networkidle', { timeout: req.settleMs ?? 8_000 }).catch(() => {})
166
+ if (req.waitForText) await page.getByText(req.waitForText).first().waitFor({ timeout: 15_000 }).catch(() => {})
167
+ } catch (err) {
168
+ navigationError = String((err as Error)?.message ?? err).slice(0, 500)
169
+ }
170
+ let a11y: PageObservation['a11y'] = null
171
+ if (req.a11yScript && !navigationError) {
172
+ try {
173
+ await page.addScriptTag({ content: req.a11yScript })
174
+ a11y = summarizeA11y(await page.evaluate(AXE_RUN))
175
+ } catch {
176
+ // A page that refuses the script (a strict CSP) is reported without a scan.
177
+ }
178
+ }
179
+ const png = await page.screenshot({ fullPage: Boolean(req.fullPage) })
180
+ const title = await page.title().catch(() => '')
181
+ const text = await page.locator('body').innerText({ timeout: 2_000 }).catch(() => '')
182
+ const { screenshot, ...seen } = {
183
+ url: page.url(),
184
+ title,
185
+ text: String(text ?? '').trim().slice(0, 4_000),
186
+ console,
187
+ failedRequests: failed,
188
+ requests,
189
+ navigationError,
190
+ screenshot: Buffer.from(png).toString('base64'),
191
+ where: 'headless' as const,
192
+ a11y,
193
+ }
194
+ return { ...redactDeep(seen, secrets), screenshot }
195
+ } finally {
196
+ await context.close().catch(() => {})
197
+ release()
198
+ }
199
+ }