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.
- package/.webflow/flows/_template/index.ts +14 -0
- package/.webflow/flows/_template/schema.json +11 -0
- package/.webflow/skills/README.md +3 -0
- package/README.md +215 -0
- package/dist/src/anti-block/provider-gate.d.ts +19 -0
- package/dist/src/anti-block/provider-gate.js +116 -0
- package/dist/src/cli/commands/approvals.d.ts +5 -0
- package/dist/src/cli/commands/approvals.js +69 -0
- package/dist/src/cli/commands/config.d.ts +4 -0
- package/dist/src/cli/commands/config.js +56 -0
- package/dist/src/cli/commands/create-flow.d.ts +4 -0
- package/dist/src/cli/commands/create-flow.js +27 -0
- package/dist/src/cli/commands/doctor.d.ts +19 -0
- package/dist/src/cli/commands/doctor.js +241 -0
- package/dist/src/cli/commands/fork.d.ts +4 -0
- package/dist/src/cli/commands/fork.js +19 -0
- package/dist/src/cli/commands/init.d.ts +4 -0
- package/dist/src/cli/commands/init.js +22 -0
- package/dist/src/cli/commands/login.d.ts +26 -0
- package/dist/src/cli/commands/login.js +145 -0
- package/dist/src/cli/commands/profiles.d.ts +40 -0
- package/dist/src/cli/commands/profiles.js +142 -0
- package/dist/src/cli/commands/run.d.ts +10 -0
- package/dist/src/cli/commands/run.js +13 -0
- package/dist/src/cli/commands/setup.d.ts +11 -0
- package/dist/src/cli/commands/setup.js +46 -0
- package/dist/src/cli/commands/worker.d.ts +29 -0
- package/dist/src/cli/commands/worker.js +30 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +336 -0
- package/dist/src/config/webflow-config.d.ts +35 -0
- package/dist/src/config/webflow-config.js +119 -0
- package/dist/src/errors/self-healing.d.ts +62 -0
- package/dist/src/errors/self-healing.js +77 -0
- package/dist/src/mcp/cli.d.ts +2 -0
- package/dist/src/mcp/cli.js +36 -0
- package/dist/src/mcp/relay-gateway.d.ts +24 -0
- package/dist/src/mcp/relay-gateway.js +208 -0
- package/dist/src/mcp/server.d.ts +8 -0
- package/dist/src/mcp/server.js +62 -0
- package/dist/src/mcp/tool-registry.d.ts +10 -0
- package/dist/src/mcp/tool-registry.js +366 -0
- package/dist/src/profiles/profile-manager.d.ts +33 -0
- package/dist/src/profiles/profile-manager.js +37 -0
- package/dist/src/profiles/profile-state.d.ts +31 -0
- package/dist/src/profiles/profile-state.js +122 -0
- package/dist/src/repertoire/local-repertoire.d.ts +3 -0
- package/dist/src/repertoire/local-repertoire.js +59 -0
- package/dist/src/runner/browser-session.d.ts +23 -0
- package/dist/src/runner/browser-session.js +58 -0
- package/dist/src/runner/cdp-session.d.ts +25 -0
- package/dist/src/runner/cdp-session.js +156 -0
- package/dist/src/runner/chrome.d.ts +18 -0
- package/dist/src/runner/chrome.js +56 -0
- package/dist/src/runner/flow-loader.d.ts +11 -0
- package/dist/src/runner/flow-loader.js +115 -0
- package/dist/src/runner/flow-runner.d.ts +70 -0
- package/dist/src/runner/flow-runner.js +378 -0
- package/dist/src/runner/types.d.ts +112 -0
- package/dist/src/runner/types.js +1 -0
- package/dist/src/schema/translator.d.ts +5 -0
- package/dist/src/schema/translator.js +104 -0
- package/dist/src/schema/types.d.ts +32 -0
- package/dist/src/schema/types.js +1 -0
- package/dist/src/security/flow-approvals.d.ts +34 -0
- package/dist/src/security/flow-approvals.js +324 -0
- package/dist/src/security/flow-release.d.ts +82 -0
- package/dist/src/security/flow-release.js +392 -0
- package/dist/src/security/official-flow-trust.d.ts +2 -0
- package/dist/src/security/official-flow-trust.js +16 -0
- package/dist/src/security/trusted-flow-keys.d.ts +8 -0
- package/dist/src/security/trusted-flow-keys.js +132 -0
- package/dist/src/shared/credentials.d.ts +21 -0
- package/dist/src/shared/credentials.js +99 -0
- package/dist/src/shared/errors.d.ts +6 -0
- package/dist/src/shared/errors.js +16 -0
- package/dist/src/shared/fs.d.ts +5 -0
- package/dist/src/shared/fs.js +23 -0
- package/dist/src/shared/paths.d.ts +112 -0
- package/dist/src/shared/paths.js +257 -0
- package/dist/src/shared/semaphore.d.ts +8 -0
- package/dist/src/shared/semaphore.js +23 -0
- package/dist/src/shared/taxonomy.d.ts +21 -0
- package/dist/src/shared/taxonomy.js +20 -0
- package/dist/src/worker/connection.d.ts +72 -0
- package/dist/src/worker/connection.js +427 -0
- package/dist/src/worker/lifecycle.d.ts +50 -0
- package/dist/src/worker/lifecycle.js +154 -0
- package/dist/src/worker/protocol.d.ts +188 -0
- package/dist/src/worker/protocol.js +156 -0
- package/dist/src/worker/worker-service.d.ts +112 -0
- package/dist/src/worker/worker-service.js +258 -0
- package/package.json +50 -0
- package/taxonomy.json +99 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { buildSelfHealingPayload } from '../errors/self-healing.js';
|
|
3
|
+
import { assertSecureRelayUrl } from '../shared/credentials.js';
|
|
4
|
+
import { WebFlowError } from '../shared/errors.js';
|
|
5
|
+
import { verifyFlowRelease } from '../security/flow-release.js';
|
|
6
|
+
import { parseRelayToWorkerMessage, serializeWorkerMessage, SUPPORTED_FLOW_RELEASE_FORMATS, SUPPORTED_FLOW_RUNTIME_APIS, WORKER_PROTOCOL_VERSION, WORKER_VERSION } from './protocol.js';
|
|
7
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 20_000;
|
|
8
|
+
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1_000;
|
|
9
|
+
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30_000;
|
|
10
|
+
const MAX_RELAY_FRAME_BYTES = 6 * 1024 * 1024;
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Outbound WebSocket client that lets a local worker be driven by a remote relay
|
|
16
|
+
* without ever opening an inbound port on the user's machine (RFC 0001, section 2.1).
|
|
17
|
+
* Owns reconnection/backoff and heartbeating; all flow execution is delegated to
|
|
18
|
+
* WorkerService, which is the only piece that knows about FlowRunner/Chrome.
|
|
19
|
+
*/
|
|
20
|
+
export class WorkerConnection {
|
|
21
|
+
options;
|
|
22
|
+
socket;
|
|
23
|
+
heartbeatTimer;
|
|
24
|
+
reconnectTimer;
|
|
25
|
+
reconnectAttempt = 0;
|
|
26
|
+
stopped = true;
|
|
27
|
+
helloSentSocket;
|
|
28
|
+
authenticatedSocket;
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
}
|
|
32
|
+
start() {
|
|
33
|
+
assertSecureRelayUrl(this.options.relayUrl);
|
|
34
|
+
this.stopped = false;
|
|
35
|
+
this.reconnectAttempt = 0;
|
|
36
|
+
this.connect();
|
|
37
|
+
}
|
|
38
|
+
stop() {
|
|
39
|
+
this.stopped = true;
|
|
40
|
+
this.helloSentSocket = undefined;
|
|
41
|
+
this.authenticatedSocket = undefined;
|
|
42
|
+
this.clearHeartbeat();
|
|
43
|
+
if (this.reconnectTimer) {
|
|
44
|
+
clearTimeout(this.reconnectTimer);
|
|
45
|
+
this.reconnectTimer = undefined;
|
|
46
|
+
}
|
|
47
|
+
this.socket?.close();
|
|
48
|
+
}
|
|
49
|
+
log(message) {
|
|
50
|
+
this.options.onLog?.(message);
|
|
51
|
+
}
|
|
52
|
+
connect() {
|
|
53
|
+
const createSocket = this.options.createSocket ?? ((url) => new WebSocket(url, { maxPayload: MAX_RELAY_FRAME_BYTES }));
|
|
54
|
+
const socket = createSocket(this.options.relayUrl);
|
|
55
|
+
this.socket = socket;
|
|
56
|
+
this.helloSentSocket = undefined;
|
|
57
|
+
this.authenticatedSocket = undefined;
|
|
58
|
+
socket.on('open', () => {
|
|
59
|
+
if (socket !== this.socket || this.stopped) {
|
|
60
|
+
socket.close();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.reconnectAttempt = 0;
|
|
64
|
+
this.log(`connected to relay at ${this.options.relayUrl}`);
|
|
65
|
+
void this.sendHello(socket).catch((error) => {
|
|
66
|
+
this.log(`could not prepare worker hello: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
socket.close(1011, 'could not prepare hello');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
socket.on('message', (data, isBinary) => {
|
|
71
|
+
if (isBinary) {
|
|
72
|
+
socket.close(1003, 'binary relay messages are not supported');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
void this.handleMessage(socket, data.toString());
|
|
76
|
+
});
|
|
77
|
+
socket.on('close', () => {
|
|
78
|
+
if (socket !== this.socket)
|
|
79
|
+
return;
|
|
80
|
+
this.helloSentSocket = undefined;
|
|
81
|
+
this.authenticatedSocket = undefined;
|
|
82
|
+
this.clearHeartbeat();
|
|
83
|
+
if (!this.stopped) {
|
|
84
|
+
this.log('relay connection closed; scheduling reconnect');
|
|
85
|
+
this.scheduleReconnect();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
socket.on('error', (error) => {
|
|
89
|
+
this.log(`relay connection error: ${error instanceof Error ? error.message : String(error)}`);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Sent on every connect AND every reconnect with the full catalog (never a diff),
|
|
94
|
+
* so a worker that changed its flows on disk never leaves the relay serving a
|
|
95
|
+
* stale tool list (RFC 0001, section 2.3).
|
|
96
|
+
*/
|
|
97
|
+
async sendHello(socket) {
|
|
98
|
+
const catalog = await this.options.service.buildCatalog();
|
|
99
|
+
if (socket !== this.socket || this.stopped)
|
|
100
|
+
return;
|
|
101
|
+
this.send({
|
|
102
|
+
type: 'hello',
|
|
103
|
+
workerId: this.options.workerId,
|
|
104
|
+
credential: this.options.credential,
|
|
105
|
+
protocolVersion: WORKER_PROTOCOL_VERSION,
|
|
106
|
+
workerVersion: this.options.workerVersion ?? WORKER_VERSION,
|
|
107
|
+
flowReleaseFormats: [...SUPPORTED_FLOW_RELEASE_FORMATS],
|
|
108
|
+
runtimeApiVersions: [...SUPPORTED_FLOW_RUNTIME_APIS],
|
|
109
|
+
catalog
|
|
110
|
+
}, socket);
|
|
111
|
+
this.helloSentSocket = socket;
|
|
112
|
+
}
|
|
113
|
+
send(message, socket = this.socket) {
|
|
114
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
115
|
+
socket.send(serializeWorkerMessage(message));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
startHeartbeat(socket) {
|
|
119
|
+
this.clearHeartbeat();
|
|
120
|
+
const intervalMs = this.options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
121
|
+
// Heartbeat is independent of job traffic: a multi-minute flow run must never
|
|
122
|
+
// be mistaken for a dead connection (RFC 0001, section 3.1).
|
|
123
|
+
this.heartbeatTimer = setInterval(() => {
|
|
124
|
+
if (socket === this.socket && socket === this.authenticatedSocket) {
|
|
125
|
+
this.send({ type: 'ping' }, socket);
|
|
126
|
+
}
|
|
127
|
+
}, intervalMs);
|
|
128
|
+
}
|
|
129
|
+
clearHeartbeat() {
|
|
130
|
+
if (this.heartbeatTimer) {
|
|
131
|
+
clearInterval(this.heartbeatTimer);
|
|
132
|
+
this.heartbeatTimer = undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
scheduleReconnect() {
|
|
136
|
+
const base = this.options.reconnectBaseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
|
|
137
|
+
const max = this.options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
|
|
138
|
+
const delay = Math.min(max, base * 2 ** this.reconnectAttempt);
|
|
139
|
+
this.reconnectAttempt += 1;
|
|
140
|
+
this.reconnectTimer = setTimeout(() => {
|
|
141
|
+
if (!this.stopped) {
|
|
142
|
+
this.connect();
|
|
143
|
+
}
|
|
144
|
+
}, delay);
|
|
145
|
+
}
|
|
146
|
+
async handleMessage(socket, raw) {
|
|
147
|
+
if (socket !== this.socket)
|
|
148
|
+
return;
|
|
149
|
+
let message;
|
|
150
|
+
try {
|
|
151
|
+
message = parseRelayToWorkerMessage(raw);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
this.log(`ignoring malformed message from relay: ${error instanceof Error ? error.message : String(error)}`);
|
|
155
|
+
this.rejectMalformedRun(socket, raw, error);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (message.type === 'hello_ack') {
|
|
159
|
+
if (this.helloSentSocket !== socket) {
|
|
160
|
+
this.log('closing relay connection: received hello_ack before hello was sent');
|
|
161
|
+
socket.close(4002, 'unexpected hello acknowledgement');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (this.authenticatedSocket === socket) {
|
|
165
|
+
this.log('closing relay connection: duplicate hello_ack');
|
|
166
|
+
socket.close(4002, 'hello already acknowledged');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!message.ok) {
|
|
170
|
+
this.log(`relay rejected hello: ${message.reason ?? 'unknown reason'}`);
|
|
171
|
+
if (/invalid|revoked/i.test(message.reason ?? '')) {
|
|
172
|
+
this.stopped = true;
|
|
173
|
+
this.clearHeartbeat();
|
|
174
|
+
this.log('worker stopped because the saved credential is invalid or revoked; run "webflow login" to pair again');
|
|
175
|
+
}
|
|
176
|
+
socket.close(4003, 'hello rejected');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
this.authenticatedSocket = socket;
|
|
180
|
+
this.startHeartbeat(socket);
|
|
181
|
+
this.log('relay authenticated this worker connection');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Heartbeat frames are harmless while the relay is still validating hello. Every
|
|
185
|
+
// operation with local side effects remains blocked until a positive ack arrives.
|
|
186
|
+
if (message.type === 'ping') {
|
|
187
|
+
this.send({ type: 'pong' }, socket);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (message.type === 'pong')
|
|
191
|
+
return;
|
|
192
|
+
if (this.authenticatedSocket !== socket) {
|
|
193
|
+
this.log(`closing relay connection: received ${message.type} before authentication`);
|
|
194
|
+
socket.close(4003, 'relay not authenticated');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
switch (message.type) {
|
|
198
|
+
case 'run':
|
|
199
|
+
await this.handleRun(socket, message);
|
|
200
|
+
return;
|
|
201
|
+
case 'config_request':
|
|
202
|
+
await this.handleConfigRequest(socket, message);
|
|
203
|
+
return;
|
|
204
|
+
case 'repertoire_request':
|
|
205
|
+
await this.handleRepertoireRequest(socket, message);
|
|
206
|
+
return;
|
|
207
|
+
case 'profile_request':
|
|
208
|
+
await this.handleProfileRequest(socket, message);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* A dropped socket mid-job is an accepted v1 gap (RFC 0001, section 6, edge case 2):
|
|
214
|
+
* the flow keeps running locally against the user's real Chrome, but its result is
|
|
215
|
+
* bound to the socket that received the job. If that socket disconnects, the result
|
|
216
|
+
* is dropped instead of leaking into a new unauthenticated connection. Revisit before
|
|
217
|
+
* relying on this for anything
|
|
218
|
+
* business-critical.
|
|
219
|
+
*/
|
|
220
|
+
async handleRun(socket, message) {
|
|
221
|
+
try {
|
|
222
|
+
const result = await this.runSignedRemoteFlow(message);
|
|
223
|
+
if (result.ok) {
|
|
224
|
+
this.send({ type: 'run_result', jobId: message.jobId, ok: true, result: result.result, visibilityNudge: result.visibilityNudge }, socket);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
this.send({ type: 'run_result', jobId: message.jobId, ok: false, error: result.error }, socket);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
// WebFlowError already carries a meaningful code (FLOW_NOT_FOUND, LOGIN_REQUIRED,
|
|
232
|
+
// ...) from WorkerService — surface it as-is instead of relabeling everything.
|
|
233
|
+
const code = error instanceof WebFlowError ? error.code : 'WORKER_RUN_FAILED';
|
|
234
|
+
const details = error instanceof WebFlowError
|
|
235
|
+
? error.details
|
|
236
|
+
: undefined;
|
|
237
|
+
const approvalCommand = code === 'FLOW_UPDATE_APPROVAL_REQUIRED'
|
|
238
|
+
? this.approvalCommand(message.flow, details?.trustDomain)
|
|
239
|
+
: undefined;
|
|
240
|
+
this.send({
|
|
241
|
+
type: 'run_result',
|
|
242
|
+
jobId: message.jobId,
|
|
243
|
+
ok: false,
|
|
244
|
+
error: buildSelfHealingPayload(error, {
|
|
245
|
+
code,
|
|
246
|
+
flow: message.flow,
|
|
247
|
+
profile: details?.profile,
|
|
248
|
+
profileStatus: details?.profileStatus,
|
|
249
|
+
loginUrl: details?.loginUrl,
|
|
250
|
+
setupCommand: approvalCommand ?? details?.setupCommand,
|
|
251
|
+
approvalCommand,
|
|
252
|
+
flowDigest: details?.flowDigest,
|
|
253
|
+
releaseId: details?.releaseId,
|
|
254
|
+
releaseVersion: details?.releaseVersion,
|
|
255
|
+
releaseSequence: details?.releaseSequence,
|
|
256
|
+
signerKeyId: details?.signerKeyId,
|
|
257
|
+
trustDomain: details?.trustDomain,
|
|
258
|
+
retryable: false,
|
|
259
|
+
suggestedAction: approvalCommand
|
|
260
|
+
? `On the worker machine, use the local MCP tools list_flow_approvals and approve_flow_update after explicit user confirmation, or run "${approvalCommand}" in an interactive terminal, then retry.`
|
|
261
|
+
: 'Verify the flow name/login state against this worker\'s catalog and its saved sessions.'
|
|
262
|
+
})
|
|
263
|
+
}, socket);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
rejectMalformedRun(socket, raw, error) {
|
|
267
|
+
let candidate;
|
|
268
|
+
try {
|
|
269
|
+
candidate = JSON.parse(raw);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (!candidate || typeof candidate !== 'object' || candidate.type !== 'run')
|
|
275
|
+
return;
|
|
276
|
+
if (this.authenticatedSocket !== socket) {
|
|
277
|
+
socket.close(4003, 'relay not authenticated');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const record = candidate;
|
|
281
|
+
if (typeof record.jobId !== 'string' || typeof record.flow !== 'string')
|
|
282
|
+
return;
|
|
283
|
+
const code = error instanceof WebFlowError ? error.code : 'INVALID_RELAY_MESSAGE';
|
|
284
|
+
this.send({
|
|
285
|
+
type: 'run_result',
|
|
286
|
+
jobId: record.jobId,
|
|
287
|
+
ok: false,
|
|
288
|
+
error: buildSelfHealingPayload(error, {
|
|
289
|
+
code,
|
|
290
|
+
flow: record.flow,
|
|
291
|
+
retryable: false,
|
|
292
|
+
suggestedAction: 'Update the relay/worker and retry with a valid signed flow release.'
|
|
293
|
+
})
|
|
294
|
+
}, socket);
|
|
295
|
+
}
|
|
296
|
+
async runSignedRemoteFlow(message) {
|
|
297
|
+
const policies = (this.options.trustPolicy ?? []).filter((entry) => entry.relayUrl === this.options.relayUrl);
|
|
298
|
+
const domains = [...new Set(policies.map((entry) => entry.trustDomain))];
|
|
299
|
+
if (domains.length !== 1) {
|
|
300
|
+
throw new WebFlowError('UNTRUSTED_FLOW_RELEASE', `No unique flow trust policy is configured for ${this.options.relayUrl}.`);
|
|
301
|
+
}
|
|
302
|
+
const verified = verifyFlowRelease(message.release, {
|
|
303
|
+
expectedRelayUrl: this.options.relayUrl,
|
|
304
|
+
expectedTrustDomain: domains[0],
|
|
305
|
+
trustPolicy: policies
|
|
306
|
+
});
|
|
307
|
+
if (verified.flowName !== message.flow) {
|
|
308
|
+
throw new WebFlowError('FLOW_RELEASE_NAME_MISMATCH', 'The signed flow name does not match the requested flow.');
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
return await this.options.service.runVerifiedRemoteFlow(verified, message.input, { browserMode: message.browserMode, browserVisibility: message.browserVisibility });
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
if (error instanceof WebFlowError && error.code === 'FLOW_UPDATE_APPROVAL_REQUIRED') {
|
|
315
|
+
const details = isRecord(error.details) ? error.details : {};
|
|
316
|
+
throw new WebFlowError(error.code, error.message, {
|
|
317
|
+
...details,
|
|
318
|
+
flowDigest: verified.flowDigest,
|
|
319
|
+
releaseId: verified.releaseId,
|
|
320
|
+
releaseVersion: verified.version,
|
|
321
|
+
releaseSequence: verified.sequence,
|
|
322
|
+
signerKeyId: verified.keyId,
|
|
323
|
+
trustDomain: verified.trustDomain
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
approvalCommand(flowName, trustDomain) {
|
|
330
|
+
const quote = (value) => /^[a-zA-Z0-9._-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
331
|
+
return `webflow approvals approve ${quote(flowName)}${trustDomain ? ` --trust-domain ${quote(trustDomain)}` : ''}`;
|
|
332
|
+
}
|
|
333
|
+
/** Pure local disk I/O (~/.webflow/config.json) — should never take long enough for
|
|
334
|
+
* the relay's short request timeout to matter in practice. */
|
|
335
|
+
async handleConfigRequest(socket, message) {
|
|
336
|
+
try {
|
|
337
|
+
const config = await (async () => {
|
|
338
|
+
switch (message.request.action) {
|
|
339
|
+
case 'get':
|
|
340
|
+
return this.options.service.getConfig();
|
|
341
|
+
case 'set_default_visibility':
|
|
342
|
+
return this.options.service.setDefaultBrowserVisibility(message.request.value);
|
|
343
|
+
case 'set_flow_visibility':
|
|
344
|
+
return this.options.service.setFlowBrowserVisibility(message.request.flowName, message.request.value);
|
|
345
|
+
case 'set_default_browser_mode':
|
|
346
|
+
return this.options.service.setDefaultBrowserMode(message.request.value);
|
|
347
|
+
case 'set_flow_browser_mode':
|
|
348
|
+
return this.options.service.setFlowBrowserMode(message.request.flowName, message.request.value);
|
|
349
|
+
}
|
|
350
|
+
})();
|
|
351
|
+
this.send({ type: 'config_response', requestId: message.requestId, ok: true, config }, socket);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
this.send({
|
|
355
|
+
type: 'config_response',
|
|
356
|
+
requestId: message.requestId,
|
|
357
|
+
ok: false,
|
|
358
|
+
error: error instanceof Error ? error.message : String(error)
|
|
359
|
+
}, socket);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** Pure local disk I/O (~/.webflow/repertoire.json) — same near-instant expectation as config. */
|
|
363
|
+
async handleRepertoireRequest(socket, message) {
|
|
364
|
+
try {
|
|
365
|
+
const flows = await (async () => {
|
|
366
|
+
switch (message.request.action) {
|
|
367
|
+
case 'list':
|
|
368
|
+
return this.options.service.listRepertoire();
|
|
369
|
+
case 'add':
|
|
370
|
+
return this.options.service.addToRepertoire(message.request.flowName);
|
|
371
|
+
case 'remove':
|
|
372
|
+
return this.options.service.removeFromRepertoire(message.request.flowName);
|
|
373
|
+
}
|
|
374
|
+
})();
|
|
375
|
+
this.send({ type: 'repertoire_response', requestId: message.requestId, ok: true, flows }, socket);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
this.send({
|
|
379
|
+
type: 'repertoire_response',
|
|
380
|
+
requestId: message.requestId,
|
|
381
|
+
ok: false,
|
|
382
|
+
error: error instanceof Error ? error.message : String(error)
|
|
383
|
+
}, socket);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* `login` opens a real, visible Chrome window and returns as soon as it's up
|
|
388
|
+
* (see WorkerService.startProfileLogin) — still well within the relay's short
|
|
389
|
+
* localRequestTimeoutMs, unlike waiting for the human to actually finish logging in.
|
|
390
|
+
*/
|
|
391
|
+
async handleProfileRequest(socket, message) {
|
|
392
|
+
try {
|
|
393
|
+
const status = await (async () => {
|
|
394
|
+
switch (message.request.action) {
|
|
395
|
+
case 'status':
|
|
396
|
+
return this.options.service.checkProfileStatus(message.request.flowName, message.request.loginProvider, message.request.loginUrl);
|
|
397
|
+
case 'login':
|
|
398
|
+
return this.options.service.startProfileLogin(message.request.flowName, message.request.loginProvider, message.request.loginUrl);
|
|
399
|
+
}
|
|
400
|
+
})();
|
|
401
|
+
this.send({
|
|
402
|
+
type: 'profile_response',
|
|
403
|
+
requestId: message.requestId,
|
|
404
|
+
ok: true,
|
|
405
|
+
launched: message.request.action === 'login' ? true : undefined,
|
|
406
|
+
status: {
|
|
407
|
+
profileId: status.profileId,
|
|
408
|
+
status: status.status,
|
|
409
|
+
probablyValid: status.probablyValid,
|
|
410
|
+
loginUrl: status.loginUrl,
|
|
411
|
+
setupCommand: status.setupCommand,
|
|
412
|
+
lastInteractiveSetupAt: status.lastInteractiveSetupAt,
|
|
413
|
+
lastSuccessfulRunAt: status.lastSuccessfulRunAt,
|
|
414
|
+
lastLoginRequiredAt: status.lastLoginRequiredAt
|
|
415
|
+
}
|
|
416
|
+
}, socket);
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
this.send({
|
|
420
|
+
type: 'profile_response',
|
|
421
|
+
requestId: message.requestId,
|
|
422
|
+
ok: false,
|
|
423
|
+
error: error instanceof Error ? error.message : String(error)
|
|
424
|
+
}, socket);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { BrowserMode } from '../runner/types.js';
|
|
2
|
+
import type { BrowserVisibility } from '../config/webflow-config.js';
|
|
3
|
+
export interface WorkerProcessInfo {
|
|
4
|
+
pid: number;
|
|
5
|
+
startedAt: string;
|
|
6
|
+
relayUrl: string;
|
|
7
|
+
}
|
|
8
|
+
export interface StartWorkerOptions {
|
|
9
|
+
headed?: boolean;
|
|
10
|
+
browserMode?: BrowserMode;
|
|
11
|
+
browserVisibility?: BrowserVisibility;
|
|
12
|
+
/** Test hook: spawn this instead of the real dist/src/cli/index.js. */
|
|
13
|
+
cliEntryPath?: string;
|
|
14
|
+
}
|
|
15
|
+
export type StartWorkerResult = {
|
|
16
|
+
started: true;
|
|
17
|
+
pid: number;
|
|
18
|
+
relayUrl: string;
|
|
19
|
+
} | {
|
|
20
|
+
started: false;
|
|
21
|
+
alreadyRunning: true;
|
|
22
|
+
pid: number;
|
|
23
|
+
} | {
|
|
24
|
+
started: false;
|
|
25
|
+
alreadyRunning?: false;
|
|
26
|
+
reason: string;
|
|
27
|
+
};
|
|
28
|
+
export type StopWorkerResult = {
|
|
29
|
+
stopped: true;
|
|
30
|
+
pid: number;
|
|
31
|
+
} | {
|
|
32
|
+
stopped: false;
|
|
33
|
+
reason: string;
|
|
34
|
+
};
|
|
35
|
+
/** Also self-heals a stale PID file (process no longer alive) by clearing it, so
|
|
36
|
+
* callers never have to special-case "file exists but process is dead". */
|
|
37
|
+
export declare function isWorkerRunning(): Promise<WorkerProcessInfo | undefined>;
|
|
38
|
+
/**
|
|
39
|
+
* Launches `webflow worker start` as a detached background process and returns as
|
|
40
|
+
* soon as it's confirmed alive, without waiting for it to actually finish connecting
|
|
41
|
+
* (that stays connected indefinitely by design). Deliberately never accepts a relay
|
|
42
|
+
* URL or secret argument — it only ever uses what `webflow login` already persisted,
|
|
43
|
+
* so a chat-connected MCP client can start/stop the worker without ever handling the
|
|
44
|
+
* worker secret itself.
|
|
45
|
+
*/
|
|
46
|
+
export declare function startBackgroundWorker(options?: StartWorkerOptions): Promise<StartWorkerResult>;
|
|
47
|
+
/** Best-effort graceful stop: SIGTERM, then wait briefly for the process to actually
|
|
48
|
+
* exit before clearing the PID file, matching the CLI's own SIGTERM/SIGINT handler
|
|
49
|
+
* (src/cli/index.ts) which closes the relay connection cleanly. */
|
|
50
|
+
export declare function stopBackgroundWorker(timeoutMs?: number): Promise<StopWorkerResult>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { closeSync, openSync } from 'node:fs';
|
|
2
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { loadCredentials } from '../shared/credentials.js';
|
|
6
|
+
import { ensureDir, writeJsonFile } from '../shared/fs.js';
|
|
7
|
+
import { getPackageRoot, getWorkerLogPath, getWorkerPidPath, RELAY_MCP_TOKEN_ENV } from '../shared/paths.js';
|
|
8
|
+
/**
|
|
9
|
+
* Login is a delicate, one-time step for many users — spell out each step in order
|
|
10
|
+
* instead of a terse pointer, since whoever is reading this on the other end may
|
|
11
|
+
* never have opened a terminal for this before. Never invite the agent to ask for or
|
|
12
|
+
* handle a worker secret itself; pairing happens in the user's browser.
|
|
13
|
+
*/
|
|
14
|
+
const NOT_LOGGED_IN_MESSAGE = 'This machine has not logged in to a relay yet, so the worker cannot start. Walk the user through these ' +
|
|
15
|
+
'exact steps, one at a time, and wait for them to confirm each before moving to the next — do not skip ahead ' +
|
|
16
|
+
'and do not ask them to paste any credential here or anywhere in this chat:\n' +
|
|
17
|
+
'1. Ask them to open a terminal ON THEIR OWN MACHINE (not this chat) and run exactly:\n' +
|
|
18
|
+
' webflow login\n' +
|
|
19
|
+
'2. Their browser will open the WebFlowMCP app. Ask them to approve the displayed device pairing.\n' +
|
|
20
|
+
'3. Once the command confirms the pairing, tell them to let you know, then call start_worker again.';
|
|
21
|
+
function isPidAlive(pid) {
|
|
22
|
+
try {
|
|
23
|
+
// Signal 0 sends nothing — it only probes whether the process exists and is
|
|
24
|
+
// ours to signal (throws ESRCH/EPERM otherwise). Never actually kills anything.
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function readPidFile() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await readFile(getWorkerPidPath(), 'utf8');
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
if (typeof parsed.pid !== 'number' || typeof parsed.startedAt !== 'string' || typeof parsed.relayUrl !== 'string') {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
return { pid: parsed.pid, startedAt: parsed.startedAt, relayUrl: parsed.relayUrl };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function clearPidFile() {
|
|
46
|
+
await rm(getWorkerPidPath(), { force: true });
|
|
47
|
+
}
|
|
48
|
+
/** Also self-heals a stale PID file (process no longer alive) by clearing it, so
|
|
49
|
+
* callers never have to special-case "file exists but process is dead". */
|
|
50
|
+
export async function isWorkerRunning() {
|
|
51
|
+
const info = await readPidFile();
|
|
52
|
+
if (!info)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (!isPidAlive(info.pid)) {
|
|
55
|
+
await clearPidFile();
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return info;
|
|
59
|
+
}
|
|
60
|
+
async function readLogTail(logPath, maxChars = 2000) {
|
|
61
|
+
try {
|
|
62
|
+
const raw = await readFile(logPath, 'utf8');
|
|
63
|
+
return raw.length > maxChars ? raw.slice(-maxChars) : raw;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Launches `webflow worker start` as a detached background process and returns as
|
|
71
|
+
* soon as it's confirmed alive, without waiting for it to actually finish connecting
|
|
72
|
+
* (that stays connected indefinitely by design). Deliberately never accepts a relay
|
|
73
|
+
* URL or secret argument — it only ever uses what `webflow login` already persisted,
|
|
74
|
+
* so a chat-connected MCP client can start/stop the worker without ever handling the
|
|
75
|
+
* worker secret itself.
|
|
76
|
+
*/
|
|
77
|
+
export async function startBackgroundWorker(options = {}) {
|
|
78
|
+
const existing = await isWorkerRunning();
|
|
79
|
+
if (existing) {
|
|
80
|
+
return { started: false, alreadyRunning: true, pid: existing.pid };
|
|
81
|
+
}
|
|
82
|
+
const credentials = await loadCredentials();
|
|
83
|
+
if (!credentials) {
|
|
84
|
+
return {
|
|
85
|
+
started: false,
|
|
86
|
+
reason: NOT_LOGGED_IN_MESSAGE
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const cliEntryPath = options.cliEntryPath ?? resolve(getPackageRoot(import.meta.url), 'dist/src/cli/index.js');
|
|
90
|
+
const logPath = getWorkerLogPath();
|
|
91
|
+
await ensureDir(dirname(logPath));
|
|
92
|
+
const logFd = openSync(logPath, 'a');
|
|
93
|
+
const args = ['worker', 'start'];
|
|
94
|
+
if (options.headed)
|
|
95
|
+
args.push('--headed');
|
|
96
|
+
if (options.browserMode)
|
|
97
|
+
args.push('--browser-mode', options.browserMode);
|
|
98
|
+
if (options.browserVisibility)
|
|
99
|
+
args.push('--browser-visibility', options.browserVisibility);
|
|
100
|
+
const childEnvironment = { ...process.env };
|
|
101
|
+
delete childEnvironment[RELAY_MCP_TOKEN_ENV];
|
|
102
|
+
const child = spawn(process.execPath, [cliEntryPath, ...args], {
|
|
103
|
+
detached: true,
|
|
104
|
+
stdio: ['ignore', logFd, logFd],
|
|
105
|
+
env: childEnvironment
|
|
106
|
+
});
|
|
107
|
+
closeSync(logFd); // The child inherited its own copy; this process no longer needs it.
|
|
108
|
+
child.unref(); // Survives after this MCP tool call — and this whole session — ends.
|
|
109
|
+
// Give it a moment to fail fast (bad relay URL, corrupt credentials, port issues)
|
|
110
|
+
// before declaring success, instead of blindly trusting a fire-and-forget spawn.
|
|
111
|
+
const crashed = await new Promise((resolveCrash) => {
|
|
112
|
+
let settled = false;
|
|
113
|
+
child.once('exit', () => {
|
|
114
|
+
if (!settled) {
|
|
115
|
+
settled = true;
|
|
116
|
+
resolveCrash(true);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
setTimeout(() => {
|
|
120
|
+
if (!settled) {
|
|
121
|
+
settled = true;
|
|
122
|
+
resolveCrash(false);
|
|
123
|
+
}
|
|
124
|
+
}, 1500);
|
|
125
|
+
});
|
|
126
|
+
if (crashed || typeof child.pid !== 'number') {
|
|
127
|
+
const tail = await readLogTail(logPath);
|
|
128
|
+
return { started: false, reason: `Worker process exited immediately.${tail ? ` Last log output:\n${tail}` : ''}` };
|
|
129
|
+
}
|
|
130
|
+
await writeJsonFile(getWorkerPidPath(), { pid: child.pid, startedAt: new Date().toISOString(), relayUrl: credentials.relayUrl });
|
|
131
|
+
return { started: true, pid: child.pid, relayUrl: credentials.relayUrl };
|
|
132
|
+
}
|
|
133
|
+
/** Best-effort graceful stop: SIGTERM, then wait briefly for the process to actually
|
|
134
|
+
* exit before clearing the PID file, matching the CLI's own SIGTERM/SIGINT handler
|
|
135
|
+
* (src/cli/index.ts) which closes the relay connection cleanly. */
|
|
136
|
+
export async function stopBackgroundWorker(timeoutMs = 5_000) {
|
|
137
|
+
const info = await isWorkerRunning();
|
|
138
|
+
if (!info) {
|
|
139
|
+
return { stopped: false, reason: 'No worker is currently running.' };
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
process.kill(info.pid, 'SIGTERM');
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
await clearPidFile();
|
|
146
|
+
return { stopped: false, reason: `Could not signal process ${info.pid}: ${error instanceof Error ? error.message : String(error)}` };
|
|
147
|
+
}
|
|
148
|
+
const deadline = Date.now() + timeoutMs;
|
|
149
|
+
while (Date.now() < deadline && isPidAlive(info.pid)) {
|
|
150
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
151
|
+
}
|
|
152
|
+
await clearPidFile();
|
|
153
|
+
return { stopped: true, pid: info.pid };
|
|
154
|
+
}
|