@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.
- package/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- 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
|
+
}
|