webflow-mcp 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.webflow/flows/_template/index.ts +14 -0
  2. package/.webflow/flows/_template/schema.json +11 -0
  3. package/.webflow/skills/README.md +3 -0
  4. package/README.md +215 -0
  5. package/dist/src/anti-block/provider-gate.d.ts +19 -0
  6. package/dist/src/anti-block/provider-gate.js +116 -0
  7. package/dist/src/cli/commands/approvals.d.ts +5 -0
  8. package/dist/src/cli/commands/approvals.js +69 -0
  9. package/dist/src/cli/commands/config.d.ts +4 -0
  10. package/dist/src/cli/commands/config.js +56 -0
  11. package/dist/src/cli/commands/create-flow.d.ts +4 -0
  12. package/dist/src/cli/commands/create-flow.js +27 -0
  13. package/dist/src/cli/commands/doctor.d.ts +19 -0
  14. package/dist/src/cli/commands/doctor.js +241 -0
  15. package/dist/src/cli/commands/fork.d.ts +4 -0
  16. package/dist/src/cli/commands/fork.js +19 -0
  17. package/dist/src/cli/commands/init.d.ts +4 -0
  18. package/dist/src/cli/commands/init.js +22 -0
  19. package/dist/src/cli/commands/login.d.ts +26 -0
  20. package/dist/src/cli/commands/login.js +145 -0
  21. package/dist/src/cli/commands/profiles.d.ts +40 -0
  22. package/dist/src/cli/commands/profiles.js +142 -0
  23. package/dist/src/cli/commands/run.d.ts +10 -0
  24. package/dist/src/cli/commands/run.js +13 -0
  25. package/dist/src/cli/commands/setup.d.ts +11 -0
  26. package/dist/src/cli/commands/setup.js +46 -0
  27. package/dist/src/cli/commands/worker.d.ts +29 -0
  28. package/dist/src/cli/commands/worker.js +30 -0
  29. package/dist/src/cli/index.d.ts +2 -0
  30. package/dist/src/cli/index.js +336 -0
  31. package/dist/src/config/webflow-config.d.ts +35 -0
  32. package/dist/src/config/webflow-config.js +119 -0
  33. package/dist/src/errors/self-healing.d.ts +62 -0
  34. package/dist/src/errors/self-healing.js +77 -0
  35. package/dist/src/mcp/cli.d.ts +2 -0
  36. package/dist/src/mcp/cli.js +36 -0
  37. package/dist/src/mcp/relay-gateway.d.ts +24 -0
  38. package/dist/src/mcp/relay-gateway.js +208 -0
  39. package/dist/src/mcp/server.d.ts +8 -0
  40. package/dist/src/mcp/server.js +62 -0
  41. package/dist/src/mcp/tool-registry.d.ts +10 -0
  42. package/dist/src/mcp/tool-registry.js +366 -0
  43. package/dist/src/profiles/profile-manager.d.ts +33 -0
  44. package/dist/src/profiles/profile-manager.js +37 -0
  45. package/dist/src/profiles/profile-state.d.ts +31 -0
  46. package/dist/src/profiles/profile-state.js +122 -0
  47. package/dist/src/repertoire/local-repertoire.d.ts +3 -0
  48. package/dist/src/repertoire/local-repertoire.js +59 -0
  49. package/dist/src/runner/browser-session.d.ts +23 -0
  50. package/dist/src/runner/browser-session.js +58 -0
  51. package/dist/src/runner/cdp-session.d.ts +25 -0
  52. package/dist/src/runner/cdp-session.js +156 -0
  53. package/dist/src/runner/chrome.d.ts +18 -0
  54. package/dist/src/runner/chrome.js +56 -0
  55. package/dist/src/runner/flow-loader.d.ts +11 -0
  56. package/dist/src/runner/flow-loader.js +115 -0
  57. package/dist/src/runner/flow-runner.d.ts +70 -0
  58. package/dist/src/runner/flow-runner.js +378 -0
  59. package/dist/src/runner/types.d.ts +112 -0
  60. package/dist/src/runner/types.js +1 -0
  61. package/dist/src/schema/translator.d.ts +5 -0
  62. package/dist/src/schema/translator.js +104 -0
  63. package/dist/src/schema/types.d.ts +32 -0
  64. package/dist/src/schema/types.js +1 -0
  65. package/dist/src/security/flow-approvals.d.ts +34 -0
  66. package/dist/src/security/flow-approvals.js +324 -0
  67. package/dist/src/security/flow-release.d.ts +82 -0
  68. package/dist/src/security/flow-release.js +392 -0
  69. package/dist/src/security/official-flow-trust.d.ts +2 -0
  70. package/dist/src/security/official-flow-trust.js +16 -0
  71. package/dist/src/security/trusted-flow-keys.d.ts +8 -0
  72. package/dist/src/security/trusted-flow-keys.js +132 -0
  73. package/dist/src/shared/credentials.d.ts +21 -0
  74. package/dist/src/shared/credentials.js +99 -0
  75. package/dist/src/shared/errors.d.ts +6 -0
  76. package/dist/src/shared/errors.js +16 -0
  77. package/dist/src/shared/fs.d.ts +5 -0
  78. package/dist/src/shared/fs.js +23 -0
  79. package/dist/src/shared/paths.d.ts +112 -0
  80. package/dist/src/shared/paths.js +257 -0
  81. package/dist/src/shared/semaphore.d.ts +8 -0
  82. package/dist/src/shared/semaphore.js +23 -0
  83. package/dist/src/shared/taxonomy.d.ts +21 -0
  84. package/dist/src/shared/taxonomy.js +20 -0
  85. package/dist/src/worker/connection.d.ts +72 -0
  86. package/dist/src/worker/connection.js +427 -0
  87. package/dist/src/worker/lifecycle.d.ts +50 -0
  88. package/dist/src/worker/lifecycle.js +154 -0
  89. package/dist/src/worker/protocol.d.ts +188 -0
  90. package/dist/src/worker/protocol.js +156 -0
  91. package/dist/src/worker/worker-service.d.ts +112 -0
  92. package/dist/src/worker/worker-service.js +258 -0
  93. package/package.json +50 -0
  94. package/taxonomy.json +99 -0
@@ -0,0 +1,378 @@
1
+ import { ZodError } from 'zod';
2
+ import { buildSelfHealingPayload } from '../errors/self-healing.js';
3
+ import { ProfileManager } from '../profiles/profile-manager.js';
4
+ import { buildProfileLoginCommand, getProfileStatus, looksLikeLoginWall, markLoginRequired, markSuccessfulRun } from '../profiles/profile-state.js';
5
+ import { readJsonFile } from '../shared/fs.js';
6
+ import { Semaphore } from '../shared/semaphore.js';
7
+ import { WebFlowError } from '../shared/errors.js';
8
+ import { translateSchema } from '../schema/translator.js';
9
+ import { loadConfig, resolveBrowserModePreference, resolveBrowserVisibility } from '../config/webflow-config.js';
10
+ import { classifyProviderSignal, deriveProviderKey, ProviderGate } from '../anti-block/provider-gate.js';
11
+ import { loadFlowModule, loadFlowModuleFromSource } from './flow-loader.js';
12
+ function defaultBrowserMode(capability) {
13
+ if (capability === 'cdp')
14
+ return 'cdp';
15
+ if (capability === 'managed' || capability === true)
16
+ return 'managed';
17
+ return 'none';
18
+ }
19
+ function supportedBrowserModes(capability, declared) {
20
+ if (declared && declared.length > 0) {
21
+ return [...new Set(declared)];
22
+ }
23
+ const fallback = defaultBrowserMode(capability);
24
+ return fallback === 'none' ? [] : [fallback];
25
+ }
26
+ function unsupportedBrowserModeError(flowName, requested, supported, source) {
27
+ return new WebFlowError('BROWSER_MODE_UNSUPPORTED', `Flow "${flowName}" cannot run in browser mode "${requested}" requested by ${source}. Supported mode(s): ${supported.length ? supported.join(', ') : 'none'}.`, { flow: flowName, requestedBrowserMode: requested, supportedBrowserModes: supported, source });
28
+ }
29
+ export function resolveBrowserMode(params) {
30
+ const defaultMode = defaultBrowserMode(params.capability);
31
+ const supported = supportedBrowserModes(params.capability, params.supportedBrowserModes);
32
+ if (params.requestedMode) {
33
+ if (!supported.includes(params.requestedMode)) {
34
+ throw unsupportedBrowserModeError(params.flowName, params.requestedMode, supported, 'one-off run options');
35
+ }
36
+ return params.requestedMode;
37
+ }
38
+ const flowOverride = params.config.browserMode.flows[params.flowName];
39
+ if (flowOverride) {
40
+ if (!supported.includes(flowOverride)) {
41
+ throw unsupportedBrowserModeError(params.flowName, flowOverride, supported, 'per-flow config');
42
+ }
43
+ return flowOverride;
44
+ }
45
+ const preference = resolveBrowserModePreference(params.config, params.flowName);
46
+ if (preference !== 'inherit' && supported.includes(preference)) {
47
+ return preference;
48
+ }
49
+ // Verification hook kept for compatibility: force legacy CDP-only flows through
50
+ // the managed path without editing flow code. Normal users should prefer config.
51
+ if (defaultMode === 'cdp' && process.env.WEBFLOW_CDP_AS_MANAGED === '1') {
52
+ return 'managed';
53
+ }
54
+ if (defaultMode !== 'none' && !supported.includes(defaultMode)) {
55
+ throw unsupportedBrowserModeError(params.flowName, defaultMode, supported, 'flow default');
56
+ }
57
+ return defaultMode;
58
+ }
59
+ /**
60
+ * Visibility precedence (see Project-Context/modules/browser-runner.md):
61
+ * 1. requiresVisibleBrowser (mandatory, e.g. strict providers) -> visible
62
+ * 2. --headed / worker --headed (options.headed) -> visible
63
+ * 3. one-off browserVisibility option -> as set
64
+ * 4. config.browserVisibility.flows[flowName] (explicit override) -> as set
65
+ * 5. config.browserVisibility.default, if not 'inherit' -> as set
66
+ * 6. capabilities.headless (legacy, per-flow code) -> as set
67
+ * 7. fallback -> hidden
68
+ * `browser: 'cdp'` never reaches this function — it has no headless concept, it
69
+ * connects to an already-visible real Chrome by construction.
70
+ */
71
+ export function resolveHeadless(params) {
72
+ if (params.requiresVisibleBrowser)
73
+ return false;
74
+ if (params.optionsHeaded)
75
+ return false;
76
+ if (params.optionsVisibility === 'visible')
77
+ return false;
78
+ if (params.optionsVisibility === 'hidden')
79
+ return true;
80
+ const visibility = resolveBrowserVisibility(params.config, params.flowName);
81
+ if (visibility === 'visible')
82
+ return false;
83
+ if (visibility === 'hidden')
84
+ return true;
85
+ return params.legacyHeadless ?? true;
86
+ }
87
+ /**
88
+ * Whether to nudge the calling agent to ask the human for a standing visibility
89
+ * preference for this specific flow. Never fires for flows with a mandatory-visible
90
+ * exception (nothing to configure), and never fires again once an explicit per-flow
91
+ * override already exists.
92
+ */
93
+ export function shouldNudgeVisibility(params) {
94
+ if (params.requiresVisibleBrowser)
95
+ return false;
96
+ if (params.optionsVisibility)
97
+ return false;
98
+ return params.config.browserVisibility.flows[params.flowName] === undefined;
99
+ }
100
+ /**
101
+ * Defense-in-depth against a single worker process launching too many Chrome instances
102
+ * at once (audit item 5) — a modest default appropriate for an average machine, since
103
+ * each persistent-profile Chrome instance can realistically use several hundred MB.
104
+ * Complementary to ProviderGate above (which only serializes calls to the SAME
105
+ * provider/anti-block key): different providers still run fully in parallel without
106
+ * this. Jobs beyond this wait their turn rather than being rejected: nothing here is a
107
+ * synchronous HTTP caller, and the relay's own job timeout (20 min) comfortably
108
+ * tolerates a short wait for a slot.
109
+ */
110
+ const DEFAULT_MAX_CONCURRENT_BROWSERS = 3;
111
+ function resolveMaxConcurrentBrowsers() {
112
+ const raw = process.env.WEBFLOW_MAX_CONCURRENT_BROWSERS?.trim();
113
+ if (!raw)
114
+ return DEFAULT_MAX_CONCURRENT_BROWSERS;
115
+ const parsed = Number(raw);
116
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CONCURRENT_BROWSERS;
117
+ }
118
+ export class FlowRunner {
119
+ projectRoot;
120
+ profileManager;
121
+ providerGate = new ProviderGate();
122
+ browserSlots;
123
+ constructor(projectRoot, profileManager) {
124
+ this.projectRoot = projectRoot;
125
+ this.profileManager = profileManager ?? new ProfileManager(projectRoot);
126
+ this.browserSlots = new Semaphore(resolveMaxConcurrentBrowsers());
127
+ }
128
+ async compile(definition) {
129
+ const schemaJson = await readJsonFile(definition.schemaFile);
130
+ const schema = translateSchema(schemaJson);
131
+ return { definition, schema };
132
+ }
133
+ async run(definition, input, options) {
134
+ const schema = options?.schema ?? (await this.compile(definition)).schema;
135
+ return this.executeModule(definition.name, definition.dir, input, schema, () => loadFlowModule(definition.entryFile), options);
136
+ }
137
+ /**
138
+ * Executes a flow whose code was NOT loaded from this machine's disk (RFC 0001,
139
+ * Fase 3): the relay bundles and pushes the flow's source per job so that no
140
+ * automation code — free or paid — ever persists on the worker's machine. Reuses
141
+ * every bit of session/profile/error-handling logic that `run()` uses; the only
142
+ * difference is where the `FlowModule` comes from.
143
+ */
144
+ async runRemote(flowName, code, schemaJson, input, options, expectedCapabilities) {
145
+ const schema = options?.schema ?? translateSchema(schemaJson);
146
+ return this.executeModule(flowName, this.projectRoot, input, schema, () => loadFlowModuleFromSource(code, flowName), options, expectedCapabilities);
147
+ }
148
+ async executeModule(flowName, flowDir, input, schema, loadModule, options, expectedCapabilities) {
149
+ let session;
150
+ // Hoisted out of the try block so the catch's login-wall check (below) can see them
151
+ // even when the failure happens after they were assigned but before the flow returns.
152
+ let profile;
153
+ let requiresLogin = false;
154
+ let loginUrl;
155
+ let providerKey;
156
+ let antiBlockPolicy;
157
+ let releaseProvider;
158
+ let acquiredBrowserSlot = false;
159
+ try {
160
+ const parsedInput = await schema.zodSchema.parseAsync(input);
161
+ const module = await loadModule();
162
+ if (expectedCapabilities && stableJson(module.capabilities ?? {}) !== stableJson(expectedCapabilities)) {
163
+ throw new WebFlowError('FLOW_CAPABILITIES_MISMATCH', `Flow "${flowName}" exports capabilities that differ from its signed release metadata.`);
164
+ }
165
+ const inputProfile = parsedInput?.profile;
166
+ const profileName = options?.profileName
167
+ ?? (typeof inputProfile === 'string' && inputProfile.length > 0 ? inputProfile : undefined)
168
+ ?? module.capabilities?.profile;
169
+ const config = await loadConfig();
170
+ const browserMode = resolveBrowserMode({
171
+ capability: module.capabilities?.browser,
172
+ supportedBrowserModes: module.capabilities?.supportedBrowserModes,
173
+ requestedMode: options?.browserMode,
174
+ flowName,
175
+ config
176
+ });
177
+ if (browserMode !== 'none') {
178
+ antiBlockPolicy = module.capabilities?.antiBlock;
179
+ providerKey = deriveProviderKey(flowName, module.capabilities);
180
+ releaseProvider = await this.providerGate.enter(providerKey, antiBlockPolicy);
181
+ }
182
+ requiresLogin = module.capabilities?.requiresLogin ?? false;
183
+ loginUrl = module.capabilities?.startUrl && module.capabilities.startUrl !== 'about:blank'
184
+ ? module.capabilities.startUrl
185
+ : undefined;
186
+ // Multi-agent (Fase 5): WEBFLOW_PROFILE_INSTANCE makes this run use a per-instance
187
+ // clone of the logged-in profile, since one Chrome user-data-dir = one process.
188
+ // Off by default, so a single agent reuses the shared base profile.
189
+ const instanceEnv = process.env.WEBFLOW_PROFILE_INSTANCE?.trim();
190
+ const profileOptions = instanceEnv ? { instance: instanceEnv } : {};
191
+ // Only browser flows need a real on-disk profile directory; non-browser flows
192
+ // just resolve their identity so we don't create empty dirs in the global store.
193
+ const resolvedProfile = await this.profileManager.resolve(profileName, flowName, profileOptions);
194
+ const status = browserMode !== 'none' && requiresLogin
195
+ ? await getProfileStatus(resolvedProfile, { loginUrl })
196
+ : undefined;
197
+ if (status && status.probablyValid === false) {
198
+ if (status.status === 'missing') {
199
+ await markLoginRequired(resolvedProfile, flowName);
200
+ }
201
+ throw new WebFlowError('LOGIN_REQUIRED', `Flow "${flowName}" requires a logged-in session for profile "${resolvedProfile.id}", but none was found. Run "${status.setupCommand}" on this machine first.`, {
202
+ flow: flowName,
203
+ profile: resolvedProfile.id,
204
+ profileStatus: status.status,
205
+ loginUrl,
206
+ setupCommand: status.setupCommand
207
+ });
208
+ }
209
+ profile = browserMode !== 'none'
210
+ ? await this.profileManager.ensure(profileName, flowName, profileOptions)
211
+ : resolvedProfile;
212
+ if (browserMode === 'managed') {
213
+ await this.browserSlots.acquire();
214
+ acquiredBrowserSlot = true;
215
+ const { launchBrowserSession } = await import('./browser-session.js');
216
+ session = await launchBrowserSession({
217
+ userDataDir: profile.dir,
218
+ headless: resolveHeadless({
219
+ requiresVisibleBrowser: module.capabilities?.requiresVisibleBrowser,
220
+ optionsHeaded: options?.headed,
221
+ optionsVisibility: options?.browserVisibility,
222
+ legacyHeadless: module.capabilities?.headless,
223
+ flowName,
224
+ config
225
+ }),
226
+ startUrl: module.capabilities?.startUrl,
227
+ channel: module.capabilities?.channel,
228
+ stealth: module.capabilities?.stealth
229
+ });
230
+ }
231
+ else if (browserMode === 'cdp') {
232
+ await this.browserSlots.acquire();
233
+ acquiredBrowserSlot = true;
234
+ const { connectCdpSession } = await import('./cdp-session.js');
235
+ const cdpInput = parsedInput;
236
+ session = await connectCdpSession({
237
+ userDataDir: cdpInput?.userDataDir ?? profile.dir,
238
+ startUrl: module.capabilities?.startUrl,
239
+ cdpUrl: cdpInput?.cdpUrl,
240
+ chromePath: cdpInput?.chromePath
241
+ });
242
+ }
243
+ const result = await module.run({
244
+ projectRoot: this.projectRoot,
245
+ flowName,
246
+ flowDir,
247
+ profile,
248
+ browser: session?.context,
249
+ page: session?.page
250
+ }, parsedInput);
251
+ if (providerKey)
252
+ await this.providerGate.record(providerKey, 'ok', antiBlockPolicy);
253
+ if (requiresLogin) {
254
+ await markSuccessfulRun(profile, flowName);
255
+ }
256
+ const visibilityNudge = browserMode === 'managed'
257
+ && shouldNudgeVisibility({ requiresVisibleBrowser: module.capabilities?.requiresVisibleBrowser, optionsVisibility: options?.browserVisibility, flowName, config })
258
+ ? 'This flow just ran using the current browser-visibility setting. If the user wants this specific flow to always run visible (see Chrome working) or always hidden (background), ask them, then call set_browser_visibility with scope:"flow".'
259
+ : undefined;
260
+ return { ok: true, result, profile, visibilityNudge };
261
+ }
262
+ catch (error) {
263
+ if (error instanceof WebFlowError && error.code === 'PROVIDER_COOLDOWN') {
264
+ const details = error.details;
265
+ return {
266
+ ok: false,
267
+ error: buildSelfHealingPayload(error, {
268
+ code: 'PROVIDER_COOLDOWN',
269
+ flow: flowName,
270
+ retryable: false,
271
+ suggestedAction: `Do not retry before ${details?.retryAfter ?? 'the provider cooldown expires'}.`,
272
+ })
273
+ };
274
+ }
275
+ const providerSignal = classifyProviderSignal(error, antiBlockPolicy?.tripOnPageNotFound);
276
+ if (providerKey)
277
+ await this.providerGate.record(providerKey, providerSignal, antiBlockPolicy);
278
+ if (error instanceof ZodError) {
279
+ return {
280
+ ok: false,
281
+ error: buildSelfHealingPayload(error, {
282
+ code: 'FLOW_VALIDATION_FAILED',
283
+ flow: flowName,
284
+ retryable: false,
285
+ suggestedAction: 'Provide arguments matching schema.json.',
286
+ validationErrors: error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message }))
287
+ })
288
+ };
289
+ }
290
+ if (error instanceof WebFlowError && error.code === 'LOGIN_REQUIRED') {
291
+ const details = error.details;
292
+ return {
293
+ ok: false,
294
+ error: buildSelfHealingPayload(error, {
295
+ code: 'LOGIN_REQUIRED',
296
+ flow: flowName,
297
+ profile: details?.profile,
298
+ profileStatus: details?.profileStatus,
299
+ loginUrl: details?.loginUrl,
300
+ setupCommand: details?.setupCommand,
301
+ retryable: false,
302
+ suggestedAction: 'Ask the user to run the setup command, log in in the opened Chrome window, close it, then retry the flow.'
303
+ })
304
+ };
305
+ }
306
+ if (error instanceof WebFlowError && error.code === 'BROWSER_MODE_UNSUPPORTED') {
307
+ return {
308
+ ok: false,
309
+ error: buildSelfHealingPayload(error, {
310
+ code: 'BROWSER_MODE_UNSUPPORTED',
311
+ flow: flowName,
312
+ retryable: false,
313
+ suggestedAction: 'Choose a browser mode supported by this flow, or update the flow capabilities only after verifying it works in that mode.'
314
+ })
315
+ };
316
+ }
317
+ const context = error;
318
+ let autoSnapshot;
319
+ if (!context.domSnapshot && session?.page) {
320
+ const { captureDomSnapshot } = await import('./browser-session.js');
321
+ autoSnapshot = await captureDomSnapshot(session.page);
322
+ }
323
+ const failureUrl = context.url ?? autoSnapshot?.url;
324
+ // A profile that was `verified`/`setup_completed` (dir exists, prior good run) can
325
+ // still go stale — the site expires the session and redirects to its login wall
326
+ // mid-flow. Flows already detect this by hand (gemini-prompt, chatgpt-prompt, twitter,
327
+ // ...) and throw a plain Error; without this check that error just fell into the
328
+ // generic FLOW_STEP_FAILED path below and the profile kept reporting probablyValid:
329
+ // true forever, so the agent had no structured signal to ask for a fresh login.
330
+ if (requiresLogin && profile && looksLikeLoginWall(failureUrl)) {
331
+ await markLoginRequired(profile, flowName);
332
+ const setupCommand = buildProfileLoginCommand(profile.id, loginUrl);
333
+ return {
334
+ ok: false,
335
+ error: buildSelfHealingPayload(error, {
336
+ code: 'LOGIN_REQUIRED',
337
+ flow: flowName,
338
+ profile: profile.id,
339
+ profileStatus: 'login_required',
340
+ loginUrl,
341
+ setupCommand,
342
+ retryable: false,
343
+ suggestedAction: 'Ask the user to run the setup command, log in in the opened Chrome window, close it, then retry the flow.'
344
+ })
345
+ };
346
+ }
347
+ return {
348
+ ok: false,
349
+ error: buildSelfHealingPayload(error, {
350
+ code: 'FLOW_STEP_FAILED',
351
+ flow: flowName,
352
+ retryable: providerSignal === 'unknown' ? context.retryable : false,
353
+ suggestedAction: context.suggestedAction ?? 'Inspect the step context and retry after fixing the flow or inputs.',
354
+ step: context.step,
355
+ url: failureUrl,
356
+ domSnapshot: context.domSnapshot ?? (autoSnapshot ? { html: autoSnapshot.html, text: autoSnapshot.text } : undefined)
357
+ })
358
+ };
359
+ }
360
+ finally {
361
+ if (session) {
362
+ await session.close();
363
+ }
364
+ releaseProvider?.();
365
+ if (acquiredBrowserSlot) {
366
+ this.browserSlots.release();
367
+ }
368
+ }
369
+ }
370
+ }
371
+ function stableJson(value) {
372
+ if (Array.isArray(value))
373
+ return `[${value.map(stableJson).join(',')}]`;
374
+ if (value && typeof value === 'object') {
375
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
376
+ }
377
+ return JSON.stringify(value) ?? 'null';
378
+ }
@@ -0,0 +1,112 @@
1
+ import type { BrowserContext, Page } from 'playwright-core';
2
+ import type { CompiledSchema } from '../schema/types.js';
3
+ import type { ResolvedProfile } from '../profiles/profile-manager.js';
4
+ import type { SelfHealingPayload } from '../errors/self-healing.js';
5
+ import type { AntiBlockPolicy } from '../anti-block/provider-gate.js';
6
+ /**
7
+ * How a flow drives the browser:
8
+ * - `true`/`'managed'`: Playwright-managed persistent profile (default, clean, isolated).
9
+ * - `'cdp'`: attach to a real Chrome over CDP (for strict providers like Google AI Studio / Adobe).
10
+ * - falsy: no browser.
11
+ */
12
+ export type BrowserMode = 'managed' | 'cdp';
13
+ export type BrowserCapability = boolean | BrowserMode;
14
+ export interface FlowCapabilities {
15
+ browser?: BrowserCapability;
16
+ headless?: boolean;
17
+ startUrl?: string;
18
+ channel?: string;
19
+ stealth?: boolean;
20
+ profile?: string;
21
+ /**
22
+ * Browser modes this flow is known to support. If omitted, the runner assumes the
23
+ * flow supports only its default mode from `browser` (managed for true/'managed',
24
+ * cdp for 'cdp'). Declare ['managed', 'cdp'] only after confirming the flow uses
25
+ * the central ctx.page/ctx.browser contract and works in both launch modes.
26
+ */
27
+ supportedBrowserModes?: BrowserMode[];
28
+ /**
29
+ * Declares whether this flow needs a previously-logged-in session to work (vs. a
30
+ * plain/anonymous browser or no browser at all). Must be explicit, not inferred
31
+ * from `profile`/`startUrl`, so `webflow doctor` and the worker catalog can rely on
32
+ * it instead of every caller guessing. Flows with `requiresLogin: true` must not use
33
+ * `startUrl: 'about:blank'` (contradiction) and should declare `profile` (falls back
34
+ * to `<flow>-default` otherwise, which is almost always wrong for a flow that needs
35
+ * a real account). Defaults to `false` when omitted.
36
+ */
37
+ requiresLogin?: boolean;
38
+ /**
39
+ * Hard requirement that this flow's browser be visible (not headless) — for providers
40
+ * whose automation detection breaks under a headless Chrome. Overrides the user's
41
+ * config.browserVisibility entirely (both the global default and any per-flow override),
42
+ * the same way `browser: 'cdp'` is always visible by construction. This is an escape
43
+ * hatch for technical correctness, not a user preference — leave it unset and let config
44
+ * decide whenever headless genuinely works, so the person running the worker keeps
45
+ * control over what they see.
46
+ */
47
+ requiresVisibleBrowser?: boolean;
48
+ /**
49
+ * Sector this flow belongs to (top of the catalog-browsing hierarchy: sector -> service ->
50
+ * flow, see `browse_catalog` in the relay). Must be explicit, not inferred from the flow's
51
+ * name or description, and must be a key present in the canonical `taxonomy.json` registry
52
+ * shipped with this package — `webflow doctor` and `relay/scripts/sync-catalog.ts` both
53
+ * reject an unknown value instead of silently minting a near-duplicate sector once the
54
+ * catalog has hundreds/thousands of flows. Omit only while a flow is still unclassified.
55
+ */
56
+ category?: string;
57
+ /**
58
+ * Which website/service within `category` this flow automates (e.g. "chatgpt" inside sector
59
+ * "productividad-e-ia"). Must be a service key registered under that sector in `taxonomy.json`.
60
+ * For a flow with no single canonical site (a multi-source scraper), use whichever source it
61
+ * defaults to if it has one, or a descriptive generic bucket if it genuinely aggregates
62
+ * several sources with none dominant. Requires `category` to also be set.
63
+ */
64
+ service?: string;
65
+ /** Provider-level pacing and circuit-breaker overrides. Defaults remain conservative. */
66
+ antiBlock?: AntiBlockPolicy;
67
+ }
68
+ export interface FlowContext {
69
+ projectRoot: string;
70
+ flowName: string;
71
+ flowDir: string;
72
+ profile: ResolvedProfile;
73
+ browser?: BrowserContext;
74
+ page?: Page;
75
+ }
76
+ export interface FlowModule {
77
+ run(ctx: FlowContext, input: unknown): Promise<unknown>;
78
+ capabilities?: FlowCapabilities;
79
+ }
80
+ export interface FlowDefinition {
81
+ name: string;
82
+ dir: string;
83
+ entryFile: string;
84
+ schemaFile: string;
85
+ }
86
+ export interface FlowDiscoveryResult {
87
+ flows: FlowDefinition[];
88
+ diagnostics: string[];
89
+ }
90
+ export interface FlowExecutionSuccess {
91
+ ok: true;
92
+ result: unknown;
93
+ profile: ResolvedProfile;
94
+ /**
95
+ * Present only the first time a `managed`-browser flow runs with no explicit
96
+ * per-flow visibility override in config.browserVisibility.flows yet, and only
97
+ * when the flow has no mandatory-visible exception (`requiresVisibleBrowser` /
98
+ * `browser: 'cdp'`) — those must never suggest a visibility change. Callers
99
+ * (relay's mcp-endpoint.ts) surface this as an `askUser` hint so the agent asks
100
+ * the human once, then calls set_browser_visibility instead of assuming.
101
+ */
102
+ visibilityNudge?: string;
103
+ }
104
+ export interface FlowExecutionFailure {
105
+ ok: false;
106
+ error: SelfHealingPayload;
107
+ }
108
+ export type FlowExecutionResult = FlowExecutionSuccess | FlowExecutionFailure;
109
+ export interface RunnableFlow {
110
+ definition: FlowDefinition;
111
+ schema: CompiledSchema;
112
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import { z } from 'zod';
2
+ import type { CompiledSchema, SupportedSchema } from './types.js';
3
+ export declare function compileZodSchema(schema: SupportedSchema): z.ZodTypeAny;
4
+ export declare function normalizeSchema(schema: unknown): SupportedSchema;
5
+ export declare function translateSchema(schema: unknown): CompiledSchema;
@@ -0,0 +1,104 @@
1
+ import { z } from 'zod';
2
+ import { WebFlowError } from '../shared/errors.js';
3
+ /**
4
+ * Applied when a flow's schema.json doesn't declare its own maxLength/maxItems, so
5
+ * every flow gets a safety net against unbounded input without hand-editing every
6
+ * JSON file (audit item 5). A flow only needs an explicit value if it legitimately
7
+ * needs more than this.
8
+ */
9
+ const DEFAULT_MAX_STRING_LENGTH = 5000;
10
+ const DEFAULT_MAX_ARRAY_ITEMS = 50;
11
+ function isSupportedSchema(input) {
12
+ return typeof input === 'object' && input !== null && 'type' in input;
13
+ }
14
+ function withMetadata(schema, descriptor) {
15
+ let next = schema;
16
+ if (descriptor.description) {
17
+ next = next.describe(descriptor.description);
18
+ }
19
+ if (descriptor.default !== undefined) {
20
+ next = next.default(descriptor.default);
21
+ }
22
+ return next;
23
+ }
24
+ function compileArray(schema) {
25
+ return withMetadata(z.array(compileZodSchema(schema.items)).max(schema.maxItems ?? DEFAULT_MAX_ARRAY_ITEMS), schema);
26
+ }
27
+ function compileObject(schema) {
28
+ const shape = {};
29
+ const required = new Set(schema.required ?? []);
30
+ for (const [key, value] of Object.entries(schema.properties ?? {})) {
31
+ let compiled = compileZodSchema(value);
32
+ if (!required.has(key) && value.default === undefined) {
33
+ compiled = compiled.optional();
34
+ }
35
+ shape[key] = compiled;
36
+ }
37
+ return withMetadata(z.object(shape).strict(), schema);
38
+ }
39
+ export function compileZodSchema(schema) {
40
+ switch (schema.type) {
41
+ case 'object':
42
+ return compileObject(schema);
43
+ case 'array':
44
+ return compileArray(schema);
45
+ case 'string': {
46
+ // An enum is already bounded to its fixed value set, so maxLength doesn't apply there.
47
+ const base = schema.enum ? z.enum(schema.enum) : z.string().max(schema.maxLength ?? DEFAULT_MAX_STRING_LENGTH);
48
+ return withMetadata(base, schema);
49
+ }
50
+ case 'number':
51
+ return withMetadata(z.number(), schema);
52
+ case 'boolean':
53
+ return withMetadata(z.boolean(), schema);
54
+ default:
55
+ throw new WebFlowError('UNSUPPORTED_SCHEMA', `Unsupported schema type: ${schema.type ?? 'unknown'}`);
56
+ }
57
+ }
58
+ export function normalizeSchema(schema) {
59
+ if (!isSupportedSchema(schema)) {
60
+ throw new WebFlowError('INVALID_SCHEMA', 'schema.json must be a supported JSON object schema.');
61
+ }
62
+ switch (schema.type) {
63
+ case 'object':
64
+ return {
65
+ type: 'object',
66
+ description: schema.description,
67
+ default: schema.default,
68
+ required: schema.required,
69
+ properties: Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, value]) => [key, normalizeSchema(value)]))
70
+ };
71
+ case 'array':
72
+ if (!('items' in schema) || !schema.items) {
73
+ throw new WebFlowError('INVALID_SCHEMA', 'Array schema requires an items field.');
74
+ }
75
+ return {
76
+ type: 'array',
77
+ description: schema.description,
78
+ default: schema.default,
79
+ items: normalizeSchema(schema.items),
80
+ maxItems: typeof schema.maxItems === 'number' ? schema.maxItems : undefined
81
+ };
82
+ case 'string':
83
+ return {
84
+ type: 'string',
85
+ description: schema.description,
86
+ default: schema.default,
87
+ enum: Array.isArray(schema.enum) ? schema.enum.map(String) : undefined,
88
+ maxLength: typeof schema.maxLength === 'number' ? schema.maxLength : undefined
89
+ };
90
+ case 'number':
91
+ return { type: 'number', description: schema.description, default: schema.default };
92
+ case 'boolean':
93
+ return { type: 'boolean', description: schema.description, default: schema.default };
94
+ default:
95
+ throw new WebFlowError('UNSUPPORTED_SCHEMA', `Unsupported schema type: ${schema.type ?? 'unknown'}`);
96
+ }
97
+ }
98
+ export function translateSchema(schema) {
99
+ const normalized = normalizeSchema(schema);
100
+ return {
101
+ jsonSchema: normalized,
102
+ zodSchema: compileZodSchema(normalized)
103
+ };
104
+ }
@@ -0,0 +1,32 @@
1
+ import type { ZodTypeAny } from 'zod';
2
+ export type SupportedSchema = ObjectSchema | StringSchema | NumberSchema | BooleanSchema | ArraySchema;
3
+ interface BaseSchema {
4
+ description?: string;
5
+ default?: unknown;
6
+ }
7
+ export interface ObjectSchema extends BaseSchema {
8
+ type: 'object';
9
+ properties?: Record<string, SupportedSchema>;
10
+ required?: string[];
11
+ }
12
+ export interface StringSchema extends BaseSchema {
13
+ type: 'string';
14
+ enum?: string[];
15
+ maxLength?: number;
16
+ }
17
+ export interface NumberSchema extends BaseSchema {
18
+ type: 'number';
19
+ }
20
+ export interface BooleanSchema extends BaseSchema {
21
+ type: 'boolean';
22
+ }
23
+ export interface ArraySchema extends BaseSchema {
24
+ type: 'array';
25
+ items: SupportedSchema;
26
+ maxItems?: number;
27
+ }
28
+ export interface CompiledSchema {
29
+ zodSchema: ZodTypeAny;
30
+ jsonSchema: SupportedSchema;
31
+ }
32
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { type VerifiedRemoteFlow } from './flow-release.js';
2
+ export interface FlowApproval {
3
+ trustDomain: string;
4
+ flowName: string;
5
+ flowDigest: string;
6
+ sequence: number;
7
+ version: string;
8
+ releaseId: string;
9
+ keyId: string;
10
+ approvedAt: string;
11
+ updatedAt: string;
12
+ }
13
+ export interface PendingFlowApproval {
14
+ trustDomain: string;
15
+ flowName: string;
16
+ flowDigest: string;
17
+ sequence: number;
18
+ version: string;
19
+ releaseId: string;
20
+ keyId: string;
21
+ receivedAt: string;
22
+ }
23
+ export type FlowApprovalAssessment = {
24
+ status: 'approved';
25
+ approval: FlowApproval;
26
+ } | {
27
+ status: 'pending';
28
+ pending: PendingFlowApproval;
29
+ };
30
+ export declare function assessAndRecordPending(verified: VerifiedRemoteFlow): Promise<FlowApprovalAssessment>;
31
+ export declare function listPending(): Promise<PendingFlowApproval[]>;
32
+ export declare function approvePending(flowName: string, trustDomain?: string, expected?: Pick<PendingFlowApproval, 'flowDigest' | 'releaseId' | 'sequence'>): Promise<FlowApproval>;
33
+ export declare function revokeApproval(flowName: string, trustDomain?: string, expected?: Pick<FlowApproval, 'flowDigest' | 'releaseId' | 'sequence'>): Promise<boolean>;
34
+ export declare function listApprovals(): Promise<FlowApproval[]>;