@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.
- 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 +48 -4
- 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/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 +331 -31
- 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 +875 -88
- 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 +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- 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/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -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 +211 -21
- 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 +180 -7
- 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 +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- 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-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- 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 +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- 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 +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- 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-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- 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/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 +24 -21
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import type { BrowserContext } from 'playwright-core'
|
|
2
|
+
|
|
3
|
+
import { acquireChromium } from '../headless'
|
|
4
|
+
import { parseKey } from './keys'
|
|
5
|
+
import { DEVICE_SIZES, HeadlessTab, isProjectAddress, networkText, siteOf, type DeviceWidth } from './headless-tab'
|
|
6
|
+
import { parseWaitInput, WAIT_MAX_MS, type Wait } from './wait'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `browser_*` tools without Forge: a headless browser this MCP server
|
|
10
|
+
* process owns. One Chromium context for the process (so a Studio sign-in
|
|
11
|
+
* holds across tabs), tabs that belong to the session that opened them, and
|
|
12
|
+
* the answers Forge's Browser gives, word for word where it can: a snapshot
|
|
13
|
+
* with refs that keep naming their element until the page goes to another
|
|
14
|
+
* document, waits, every action, console, network and screenshots.
|
|
15
|
+
*
|
|
16
|
+
* What differs is that nobody watches. Reading and navigating are free on
|
|
17
|
+
* any site, as in Forge. What Forge asks the person about before doing it
|
|
18
|
+
* outside the project (a click that is not a link, submitting a form, Enter
|
|
19
|
+
* or Space, running script, OK on a dialog, on an address that is not this
|
|
20
|
+
* machine's) is refused here in words: a headless browser has nobody to ask.
|
|
21
|
+
* Never, anywhere: typing into a password field.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type Content = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
|
|
25
|
+
export interface HeadlessAnswer {
|
|
26
|
+
content: Content[]
|
|
27
|
+
isError?: true
|
|
28
|
+
/** Where a screenshot belongs, as Forge says it: the tab, its address and its project's folder. */
|
|
29
|
+
meta?: { tabId: string; url: string; root: string }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `browser_evaluate`'s answer, at most this many characters of JSON. */
|
|
33
|
+
export const EVALUATE_CAP = 20_000
|
|
34
|
+
/** The tabs close, and Chromium with them, after this long without a call. */
|
|
35
|
+
export const SESSION_IDLE_MS = 15 * 60_000
|
|
36
|
+
/** At most this many tabs: the oldest closes when one more opens. */
|
|
37
|
+
const MAX_TABS = 12
|
|
38
|
+
|
|
39
|
+
const text = (t: string): HeadlessAnswer => ({ content: [{ type: 'text', text: t }] })
|
|
40
|
+
const refuse = (t: string): HeadlessAnswer => ({ isError: true, content: [{ type: 'text', text: t }] })
|
|
41
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
|
|
42
|
+
const seconds = (ms: number) => `${(ms / 1000).toFixed(1)} s`
|
|
43
|
+
|
|
44
|
+
const NOBODY_WATCHES = 'needs the person\'s yes, and nobody watches a headless browser'
|
|
45
|
+
|
|
46
|
+
function dialogNote(tab: HeadlessTab): string {
|
|
47
|
+
const d = tab.dialog!
|
|
48
|
+
const kind = d.type === 'beforeunload' ? 'leave-page' : d.type
|
|
49
|
+
return `The page opened a ${kind} dialog${d.message ? `: ${JSON.stringify(d.message)}` : ''}${d.type === 'prompt' && d.defaultPrompt ? ` (default ${JSON.stringify(d.defaultPrompt)})` : ''}. The page waits until it is answered: call browser_handle_dialog with accept true or false.`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Tools that read, or answer the dialog: everything else waits for the page, which a dialog holds. */
|
|
53
|
+
const DIALOG_SAFE = new Set(['browser_handle_dialog', 'browser_console', 'browser_network', 'browser_screenshot', 'browser_tabs'])
|
|
54
|
+
|
|
55
|
+
export class HeadlessBrowserSession {
|
|
56
|
+
private tabs = new Map<string, HeadlessTab>()
|
|
57
|
+
/** Per project folder: the tab the session chose with browser_tabs select. */
|
|
58
|
+
private chosen = new Map<string, string>()
|
|
59
|
+
private context: Promise<{ context: BrowserContext; release: () => void }> | null = null
|
|
60
|
+
private idle: ReturnType<typeof setTimeout> | null = null
|
|
61
|
+
|
|
62
|
+
constructor(private readonly idleMs = SESSION_IDLE_MS) {}
|
|
63
|
+
|
|
64
|
+
private touch(): void {
|
|
65
|
+
if (this.idle) clearTimeout(this.idle)
|
|
66
|
+
this.idle = setTimeout(() => void this.close(), this.idleMs)
|
|
67
|
+
this.idle.unref?.()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async browserContext(): Promise<BrowserContext> {
|
|
71
|
+
if (!this.context) {
|
|
72
|
+
const opening = (async () => {
|
|
73
|
+
const { browser, release } = await acquireChromium()
|
|
74
|
+
try {
|
|
75
|
+
const context = await browser.newContext({ viewport: { width: DEVICE_SIZES.desktop.width, height: DEVICE_SIZES.desktop.height } })
|
|
76
|
+
context.on('close', () => {
|
|
77
|
+
if (this.context === opening) {
|
|
78
|
+
this.context = null
|
|
79
|
+
this.tabs.clear()
|
|
80
|
+
release()
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
return { context, release }
|
|
84
|
+
} catch (err) {
|
|
85
|
+
release()
|
|
86
|
+
throw err
|
|
87
|
+
}
|
|
88
|
+
})()
|
|
89
|
+
this.context = opening
|
|
90
|
+
opening.catch(() => {
|
|
91
|
+
if (this.context === opening) this.context = null
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
return (await this.context).context
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Close every tab and give Chromium back. */
|
|
98
|
+
async close(): Promise<void> {
|
|
99
|
+
if (this.idle) clearTimeout(this.idle)
|
|
100
|
+
this.idle = null
|
|
101
|
+
const opening = this.context
|
|
102
|
+
this.context = null
|
|
103
|
+
this.tabs.clear()
|
|
104
|
+
this.chosen.clear()
|
|
105
|
+
if (!opening) return
|
|
106
|
+
const held = await opening.catch(() => null)
|
|
107
|
+
if (!held) return
|
|
108
|
+
await held.context.close().catch(() => {})
|
|
109
|
+
held.release()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async open(root: string): Promise<HeadlessTab> {
|
|
113
|
+
const context = await this.browserContext()
|
|
114
|
+
const mine = [...this.tabs.values()].filter((t) => t.alive)
|
|
115
|
+
if (mine.length >= MAX_TABS) {
|
|
116
|
+
const oldest = mine.sort((a, b) => a.agentAt - b.agentAt)[0]!
|
|
117
|
+
this.tabs.delete(oldest.id)
|
|
118
|
+
await oldest.close()
|
|
119
|
+
}
|
|
120
|
+
const tab = await HeadlessTab.open(await context.newPage(), root)
|
|
121
|
+
this.tabs.set(tab.id, tab)
|
|
122
|
+
tab.page.on('close', () => this.tabs.delete(tab.id))
|
|
123
|
+
return tab
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The tab named, the one chosen, or the one acted in last, of this project. */
|
|
127
|
+
private tabFor(root: string, input: Record<string, unknown>): HeadlessTab | HeadlessAnswer {
|
|
128
|
+
const id = str(input.tabId)
|
|
129
|
+
if (id) {
|
|
130
|
+
const tab = this.tabs.get(id)
|
|
131
|
+
return tab && tab.alive && tab.root === root ? tab : refuse(`No tab "${id}" in this project; browser_tabs lists them.`)
|
|
132
|
+
}
|
|
133
|
+
const pick = this.chosen.get(root)
|
|
134
|
+
const picked = pick ? this.tabs.get(pick) : undefined
|
|
135
|
+
if (picked?.alive) return picked
|
|
136
|
+
const mine = [...this.tabs.values()].filter((t) => t.alive && t.root === root).sort((a, b) => b.agentAt - a.agentAt)
|
|
137
|
+
return mine[0] ?? refuse('You have no tab open: browser_open opens one.')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async snapshotText(tab: HeadlessTab, lead: () => string, wait: Wait | null = null): Promise<HeadlessAnswer> {
|
|
141
|
+
await tab.settle(4_000, 350)
|
|
142
|
+
let waited = ''
|
|
143
|
+
let met = true
|
|
144
|
+
if (wait) {
|
|
145
|
+
const r = await tab.waitFor(wait.condition, wait.timeoutMs)
|
|
146
|
+
met = r.met
|
|
147
|
+
waited = r.error ? `\n${r.error}` : r.met ? `\nWaited ${seconds(r.waitedMs)} for ${wait.what}.` : `\nWaited ${seconds(r.waitedMs)} for ${wait.what}; it did not happen. This is the page now.`
|
|
148
|
+
}
|
|
149
|
+
if (tab.dialog) return text(`${lead()}${waited}\n\n${dialogNote(tab)}`)
|
|
150
|
+
const snap = await tab.takeSnapshot()
|
|
151
|
+
const answer = `${lead()}${waited}\n\n${snap.text}`
|
|
152
|
+
return met ? text(answer) : refuse(answer)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async nodeFor(tab: HeadlessTab, ref: string | undefined): Promise<number | HeadlessAnswer> {
|
|
156
|
+
if (!ref) return refuse('ref is required: take a browser_snapshot and use an element\'s [ref=…].')
|
|
157
|
+
if (!tab.snapshot) return refuse('Take a browser_snapshot first: refs come from it.')
|
|
158
|
+
if (tab.navigatedAt > tab.snapshotAt) {
|
|
159
|
+
const snap = await tab.takeSnapshot()
|
|
160
|
+
return refuse(`The page went to another document after your snapshot, so look again before acting. Here is the page now; act on these refs.\n\n${snap.text}`)
|
|
161
|
+
}
|
|
162
|
+
return tab.nodeForRef(ref) ?? refuse(`No element [ref=${ref}] on this page: take a new browser_snapshot.`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Acting outside the project needs a yes nobody can give here: the
|
|
167
|
+
* refusal, or null to go ahead. An element in a frame (another site
|
|
168
|
+
* embedded in the page) is acted on in that frame's site.
|
|
169
|
+
*/
|
|
170
|
+
private permitted(tab: HeadlessTab, action: string, node?: number): HeadlessAnswer | null {
|
|
171
|
+
const frame = node !== undefined ? tab.frameAddressOf(node) : null
|
|
172
|
+
const url = [tab.url, frame].find((u): u is string => !!u && !isProjectAddress(u))
|
|
173
|
+
if (!url) return null
|
|
174
|
+
return refuse(`To ${action} on ${siteOf(url)} ${NOBODY_WATCHES}. Reading and navigating there are fine. Open this project in Forge to do it with the person watching, or ask them in the terminal.`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private elementName(tab: HeadlessTab, input: Record<string, unknown>, fallback: string): string {
|
|
178
|
+
const said = str(input.element)
|
|
179
|
+
if (said) return said
|
|
180
|
+
const ref = str(input.ref)
|
|
181
|
+
const label = ref ? tab.snapshot?.labels.get(ref) : undefined
|
|
182
|
+
return label ? `${label.role} "${label.name}"` : fallback
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Run one `browser_*` tool for the project at `root`. */
|
|
186
|
+
async run(tool: string, input: Record<string, unknown>, root: string): Promise<HeadlessAnswer> {
|
|
187
|
+
this.touch()
|
|
188
|
+
try {
|
|
189
|
+
return await this.dispatch(tool, input, root)
|
|
190
|
+
} finally {
|
|
191
|
+
this.touch()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async dispatch(tool: string, input: Record<string, unknown>, root: string): Promise<HeadlessAnswer> {
|
|
196
|
+
if (tool === 'browser_open') {
|
|
197
|
+
const url = str(input.url)
|
|
198
|
+
if (!url) return refuse('url is required.')
|
|
199
|
+
const wait = parseWaitInput(input.waitFor as Record<string, unknown> | undefined, null)
|
|
200
|
+
if (wait && 'error' in wait) return refuse(wait.error)
|
|
201
|
+
const tab = await this.open(root)
|
|
202
|
+
tab.addSecret(str(input.redact))
|
|
203
|
+
tab.noteSecretsIn(url)
|
|
204
|
+
const shown = tab.redact(url)
|
|
205
|
+
const error = await tab.load(url)
|
|
206
|
+
if (error) return refuse(`${shown} did not load: ${error}`)
|
|
207
|
+
return this.snapshotText(tab, () => `Opened ${tab.url && tab.url !== 'about:blank' ? tab.url : shown} in tab ${tab.id}.`, wait)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (tool === 'browser_tabs') {
|
|
211
|
+
const action = str(input.action) ?? 'list'
|
|
212
|
+
if (action === 'list') {
|
|
213
|
+
const current = this.tabFor(root, {})
|
|
214
|
+
const tabs = await Promise.all(
|
|
215
|
+
[...this.tabs.values()]
|
|
216
|
+
.filter((t) => t.alive && t.root === root)
|
|
217
|
+
.map(async (t) => ({ tabId: t.id, title: await t.title(), url: t.url, owner: 'you', active: current instanceof HeadlessTab && current.id === t.id, ...(t.dialog ? { dialog: true } : {}) })),
|
|
218
|
+
)
|
|
219
|
+
return text(JSON.stringify({ tabs }, null, 2))
|
|
220
|
+
}
|
|
221
|
+
const tab = this.tabFor(root, input)
|
|
222
|
+
if (!(tab instanceof HeadlessTab)) return tab
|
|
223
|
+
if (action === 'select') {
|
|
224
|
+
this.chosen.set(root, tab.id)
|
|
225
|
+
tab.agentAt = Date.now()
|
|
226
|
+
return this.snapshotText(tab, () => `Your current tab is ${tab.id}.`)
|
|
227
|
+
}
|
|
228
|
+
if (action === 'close') {
|
|
229
|
+
this.tabs.delete(tab.id)
|
|
230
|
+
if (this.chosen.get(root) === tab.id) this.chosen.delete(root)
|
|
231
|
+
await tab.close()
|
|
232
|
+
return text(`Closed ${tab.id}.`)
|
|
233
|
+
}
|
|
234
|
+
return refuse(`Unknown action "${action}": use list, select or close.`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const tab = this.tabFor(root, input)
|
|
238
|
+
if (!(tab instanceof HeadlessTab)) return tab
|
|
239
|
+
if (tab.dialog && !DIALOG_SAFE.has(tool)) return refuse(dialogNote(tab))
|
|
240
|
+
if (tool !== 'browser_console' && tool !== 'browser_network') tab.agentAt = Date.now()
|
|
241
|
+
|
|
242
|
+
switch (tool) {
|
|
243
|
+
case 'browser_navigate': {
|
|
244
|
+
const action = str(input.action) as 'back' | 'forward' | 'reload' | undefined
|
|
245
|
+
const url = str(input.url)
|
|
246
|
+
if (!action && !url) return refuse('Pass url, or action back, forward or reload.')
|
|
247
|
+
if (action && !['back', 'forward', 'reload'].includes(action)) return refuse(`Unknown action "${action}": use back, forward or reload.`)
|
|
248
|
+
const wait = parseWaitInput(input.waitFor as Record<string, unknown> | undefined, null)
|
|
249
|
+
if (wait && 'error' in wait) return refuse(wait.error)
|
|
250
|
+
if (url) {
|
|
251
|
+
tab.addSecret(str(input.redact))
|
|
252
|
+
tab.noteSecretsIn(url)
|
|
253
|
+
const error = await tab.load(url)
|
|
254
|
+
if (error) return refuse(`${tab.redact(url)} did not load: ${error}`)
|
|
255
|
+
} else {
|
|
256
|
+
const error = await tab.go(action!)
|
|
257
|
+
if (error) return refuse(error.startsWith('There is no page') ? error : `${action} did not load: ${error}`)
|
|
258
|
+
}
|
|
259
|
+
return this.snapshotText(tab, () => `Now at ${tab.url}.`, wait)
|
|
260
|
+
}
|
|
261
|
+
case 'browser_snapshot': {
|
|
262
|
+
const wait = parseWaitInput(input.waitFor as Record<string, unknown> | undefined, (ref) => tab.nodeForRef(ref))
|
|
263
|
+
if (wait && 'error' in wait) return refuse(wait.error)
|
|
264
|
+
return this.snapshotText(tab, () => `Tab ${tab.id}.`, wait)
|
|
265
|
+
}
|
|
266
|
+
case 'browser_wait_for': {
|
|
267
|
+
const time = Number(input.time)
|
|
268
|
+
if (Number.isFinite(time) && time > 0 && input.text === undefined && input.textGone === undefined && !input.selector && !input.ref && input.networkIdle !== true) {
|
|
269
|
+
const ms = Math.min(time * 1000, WAIT_MAX_MS)
|
|
270
|
+
await new Promise((r) => setTimeout(r, ms))
|
|
271
|
+
return this.snapshotText(tab, () => `Waited ${seconds(ms)}.`)
|
|
272
|
+
}
|
|
273
|
+
const wait = parseWaitInput(input, (ref) => tab.nodeForRef(ref))
|
|
274
|
+
if (!wait) return refuse('Say what to wait for: text, textGone, selector or ref (with state visible or hidden), networkIdle, or time in seconds.')
|
|
275
|
+
if ('error' in wait) return refuse(wait.error)
|
|
276
|
+
return this.snapshotText(tab, () => `Tab ${tab.id}.`, wait)
|
|
277
|
+
}
|
|
278
|
+
case 'browser_click': {
|
|
279
|
+
const node = await this.nodeFor(tab, str(input.ref))
|
|
280
|
+
if (typeof node !== 'number') return node
|
|
281
|
+
const label = tab.snapshot?.labels.get(str(input.ref)!)
|
|
282
|
+
const element = this.elementName(tab, input, 'the element')
|
|
283
|
+
// Following a link is navigating, which is free; any other click outside the project is acting.
|
|
284
|
+
if (label?.role !== 'link') {
|
|
285
|
+
const no = this.permitted(tab, `click ${element}`, node)
|
|
286
|
+
if (no) return no
|
|
287
|
+
}
|
|
288
|
+
await tab.click(node, { double: input.doubleClick === true, button: (str(input.button) as 'left' | 'right' | 'middle' | undefined) ?? 'left' })
|
|
289
|
+
return this.snapshotText(tab, () => `Clicked ${element}.`)
|
|
290
|
+
}
|
|
291
|
+
case 'browser_type': {
|
|
292
|
+
const node = await this.nodeFor(tab, str(input.ref))
|
|
293
|
+
if (typeof node !== 'number') return node
|
|
294
|
+
if (await tab.isPassword(node)) return refuse('That is a password field. Never type a password: ask the person to sign in, then take a new snapshot.')
|
|
295
|
+
const element = this.elementName(tab, input, 'the field')
|
|
296
|
+
if (input.submit === true) {
|
|
297
|
+
const no = this.permitted(tab, 'submit the form', node)
|
|
298
|
+
if (no) return no
|
|
299
|
+
}
|
|
300
|
+
await tab.type(node, typeof input.text === 'string' ? input.text : '', { clear: input.clear !== false, submit: input.submit === true })
|
|
301
|
+
return this.snapshotText(tab, () => `Typed into ${element}${input.submit === true ? ' and pressed Enter' : ''}.`)
|
|
302
|
+
}
|
|
303
|
+
case 'browser_press_key': {
|
|
304
|
+
const press = parseKey(typeof input.key === 'string' ? input.key : '')
|
|
305
|
+
if ('error' in press) return refuse(press.error)
|
|
306
|
+
let node: number | undefined
|
|
307
|
+
if (str(input.ref)) {
|
|
308
|
+
const found = await this.nodeFor(tab, str(input.ref))
|
|
309
|
+
if (typeof found !== 'number') return found
|
|
310
|
+
if (press.text && (await tab.isPassword(found))) return refuse('That is a password field. Never type a password: ask the person to sign in.')
|
|
311
|
+
node = found
|
|
312
|
+
}
|
|
313
|
+
const keyName = str(input.key)!
|
|
314
|
+
// Enter and Space submit forms and press buttons: outside the project, that is acting.
|
|
315
|
+
if (press.key === 'Enter' || press.key === ' ') {
|
|
316
|
+
const no = this.permitted(tab, `press ${keyName}`, node)
|
|
317
|
+
if (no) return no
|
|
318
|
+
}
|
|
319
|
+
const where = node !== undefined ? ` in ${this.elementName(tab, input, 'the element')}` : ''
|
|
320
|
+
await tab.pressKey(press, node)
|
|
321
|
+
return this.snapshotText(tab, () => `Pressed ${keyName}${where}.`)
|
|
322
|
+
}
|
|
323
|
+
case 'browser_select_option': {
|
|
324
|
+
const node = await this.nodeFor(tab, str(input.ref))
|
|
325
|
+
if (typeof node !== 'number') return node
|
|
326
|
+
const values = Array.isArray(input.values) ? input.values.filter((v): v is string => typeof v === 'string') : typeof input.value === 'string' ? [input.value] : []
|
|
327
|
+
if (!values.length) return refuse('values is required: the options to choose, by value or by label.')
|
|
328
|
+
const element = this.elementName(tab, input, 'the list')
|
|
329
|
+
const chosen = await tab.selectOptions(node, values)
|
|
330
|
+
if ('error' in chosen) return refuse(`Could not choose in ${element}: ${chosen.error}.`)
|
|
331
|
+
return this.snapshotText(tab, () => `Chose ${chosen.values.map((v) => JSON.stringify(v)).join(', ')} in ${element}.`)
|
|
332
|
+
}
|
|
333
|
+
case 'browser_hover': {
|
|
334
|
+
const node = await this.nodeFor(tab, str(input.ref))
|
|
335
|
+
if (typeof node !== 'number') return node
|
|
336
|
+
const element = this.elementName(tab, input, 'the element')
|
|
337
|
+
await tab.hover(node)
|
|
338
|
+
return this.snapshotText(tab, () => `Hovering over ${element}.`)
|
|
339
|
+
}
|
|
340
|
+
case 'browser_evaluate': {
|
|
341
|
+
const expression = str(input.expression) ?? str(input.function)
|
|
342
|
+
if (!expression) return refuse('expression is required: a JavaScript expression, or a function such as () => document.title.')
|
|
343
|
+
// With a ref, the script runs in the frame that element is in (an App embedded in Studio), given the element.
|
|
344
|
+
let node: number | undefined
|
|
345
|
+
if (str(input.ref)) {
|
|
346
|
+
const found = await this.nodeFor(tab, str(input.ref))
|
|
347
|
+
if (typeof found !== 'number') return found
|
|
348
|
+
node = found
|
|
349
|
+
}
|
|
350
|
+
const no = this.permitted(tab, 'run script in the page', node)
|
|
351
|
+
if (no) return no
|
|
352
|
+
const result = await tab.evaluateInPage(expression, EVALUATE_CAP, 10_000, node)
|
|
353
|
+
if (tab.dialog) return refuse(`The script opened a dialog. ${dialogNote(tab)}`)
|
|
354
|
+
if ('error' in result) return refuse(`The script failed: ${result.error}`)
|
|
355
|
+
const clipped = result.length > result.json.length ? ` (the first ${EVALUATE_CAP.toLocaleString('en')} of ${result.length.toLocaleString('en')} characters)` : ''
|
|
356
|
+
return text(`Result, ${result.type}${clipped}:\n${tab.redact(result.json)}`)
|
|
357
|
+
}
|
|
358
|
+
case 'browser_set_viewport': {
|
|
359
|
+
const device = str(input.device)
|
|
360
|
+
const width = Number(input.width)
|
|
361
|
+
const height = Number(input.height)
|
|
362
|
+
if (Number.isFinite(width) && width > 0) {
|
|
363
|
+
if (width < 240 || width > 3840) return refuse('width must be between 240 and 3840.')
|
|
364
|
+
if (Number.isFinite(height) && height > 0 && (height < 240 || height > 4320)) return refuse('height must be between 240 and 4320.')
|
|
365
|
+
await tab.setSize({ width: Math.round(width), height: Number.isFinite(height) && height > 0 ? Math.round(height) : null })
|
|
366
|
+
} else if (device && device in DEVICE_SIZES) {
|
|
367
|
+
await tab.setSize({ device: device as DeviceWidth })
|
|
368
|
+
} else return refuse('Pass device (desktop, tablet or phone), or width (and height) in pixels.')
|
|
369
|
+
await tab.settle(2_000, 200)
|
|
370
|
+
const now = await tab.viewportWidth()
|
|
371
|
+
const label = tab.viewport ? `${tab.viewport.width}${tab.viewport.height ? `×${tab.viewport.height}` : ''} px` : DEVICE_SIZES[tab.device].label
|
|
372
|
+
return text(`Tab ${tab.id} lays out at ${label}; the page is ${now} px wide now.`)
|
|
373
|
+
}
|
|
374
|
+
case 'browser_handle_dialog': {
|
|
375
|
+
const dialog = tab.dialog
|
|
376
|
+
if (!dialog) return refuse('No dialog is open in this tab.')
|
|
377
|
+
const accept = input.accept !== false
|
|
378
|
+
if (accept && dialog.type !== 'alert') {
|
|
379
|
+
const no = this.permitted(tab, `answer OK to "${dialog.message.slice(0, 120)}"`)
|
|
380
|
+
if (no) return no
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
await tab.answerDialog(accept, typeof input.promptText === 'string' ? input.promptText : undefined)
|
|
384
|
+
} catch (err) {
|
|
385
|
+
return refuse(`The dialog could not be answered (${(err as Error).message}).`)
|
|
386
|
+
}
|
|
387
|
+
return this.snapshotText(tab, () => `${accept ? 'Accepted' : 'Dismissed'} the ${dialog.type} ${JSON.stringify(dialog.message.slice(0, 200))}.`)
|
|
388
|
+
}
|
|
389
|
+
case 'browser_screenshot': {
|
|
390
|
+
if (!tab.dialog) await tab.settle(3_000, 300)
|
|
391
|
+
const data = await tab.screenshot({ fullPage: input.fullPage === true })
|
|
392
|
+
return { content: [{ type: 'text', text: `Tab ${tab.id}: ${tab.url}` }, { type: 'image', data, mimeType: 'image/png' }], meta: { tabId: tab.id, url: tab.url, root: tab.root } }
|
|
393
|
+
}
|
|
394
|
+
case 'browser_console': {
|
|
395
|
+
const entries = tab.console.filter((c) => input.onlyErrors !== true || c.level === 'error' || c.level === 'warning').slice(-200)
|
|
396
|
+
return text(entries.length ? entries.map((c) => `[${c.level}] ${c.text}${c.source ? ` (${c.source})` : ''}`).join('\n') : 'No console messages.')
|
|
397
|
+
}
|
|
398
|
+
case 'browser_network': {
|
|
399
|
+
return text(networkText([...tab.network.values()], input.onlyFailed === true))
|
|
400
|
+
}
|
|
401
|
+
default:
|
|
402
|
+
return refuse(`Unknown browser tool "${tool}".`)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let processSession: HeadlessBrowserSession | null = null
|
|
408
|
+
|
|
409
|
+
/** The MCP server process's one headless browser session. */
|
|
410
|
+
export function headlessSession(): HeadlessBrowserSession {
|
|
411
|
+
processSession ??= new HeadlessBrowserSession()
|
|
412
|
+
return processSession
|
|
413
|
+
}
|