@deadragdoll/tellymcp 0.0.12 → 0.0.14
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/.env.example.client +5 -1
- package/.env.example.gateway +16 -1
- package/CHANGELOG.md +30 -0
- package/README-ru.md +33 -0
- package/README.md +33 -0
- package/TOOLS.md +384 -9
- package/config/templates/env.both.template +15 -2
- package/config/templates/env.client.template +5 -2
- package/config/templates/env.gateway.template +15 -2
- package/dist/cli.js +137 -2
- package/dist/services/features/telegram-mcp/browser.service.js +56 -1
- package/dist/services/features/telegram-mcp/file-content.service.js +94 -0
- package/dist/services/features/telegram-mcp/gateway-delivery.service.js +5 -1
- package/dist/services/features/telegram-mcp/gateway-socket.service.js +43 -11
- package/dist/services/features/telegram-mcp/mcp-http.service.js +1 -0
- package/dist/services/features/telegram-mcp/mcp-server.service.js +30 -0
- package/dist/services/features/telegram-mcp/src/app/bootstrap/runtime.js +23 -1
- package/dist/services/features/telegram-mcp/src/app/config/env.js +132 -2
- package/dist/services/features/telegram-mcp/src/app/http.js +139 -99
- package/dist/services/features/telegram-mcp/src/app/oauthFacade.js +642 -0
- package/dist/services/features/telegram-mcp/src/app/webapp/assets.js +151 -170
- package/dist/services/features/telegram-mcp/src/app/webapp/auth.js +96 -99
- package/dist/services/features/telegram-mcp/src/entities/request/model/schema.js +205 -17
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserAttachActiveTabTool.js +28 -0
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserAttachTabTool.js +28 -0
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserClickTool.js +1 -1
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserDetachTabTool.js +28 -0
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserDomTool.js +1 -1
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserFillTool.js +1 -1
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserInjectScriptTool.js +28 -0
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserListAttachedInstancesTool.js +33 -0
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserListTabsTool.js +33 -0
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserPressTool.js +1 -1
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserRecordingStartTool.js +28 -0
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserRecordingStatusTool.js +28 -0
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserRecordingStopTool.js +28 -0
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserScreenshotTool.js +1 -1
- package/dist/services/features/telegram-mcp/src/features/browser/model/browserService.js +890 -23
- package/dist/services/features/telegram-mcp/src/features/browser-attach/model/browserRecordingBundle.js +536 -0
- package/dist/services/features/telegram-mcp/src/features/browser-attach/model/firefoxAttachRegistry.js +80 -0
- package/dist/services/features/telegram-mcp/src/features/browser-attach/model/firefoxAttachServer.js +736 -0
- package/dist/services/features/telegram-mcp/src/features/browser-attach/model/types.js +188 -0
- package/dist/services/features/telegram-mcp/src/features/collaboration/model/collaborationService.js +2 -0
- package/dist/services/features/telegram-mcp/src/features/collaboration/model/sendPartnerFileService.js +6 -3
- package/dist/services/features/telegram-mcp/src/features/distributed-client/model/gatewayClientAccess.js +4 -1
- package/dist/services/features/telegram-mcp/src/features/distributed-gateway/model/gatewayHttpService.js +33 -35
- package/dist/services/features/telegram-mcp/src/features/file-content/model/getFileListTool.js +33 -0
- package/dist/services/features/telegram-mcp/src/features/file-content/model/getFileService.js +327 -0
- package/dist/services/features/telegram-mcp/src/features/file-content/model/getFileTool.js +81 -0
- package/dist/services/features/telegram-mcp/src/features/file-content/model/temporaryFileLinkStore.js +307 -0
- package/dist/services/features/telegram-mcp/src/features/file-content/model/workspaceFilePolicy.js +115 -0
- package/dist/services/features/telegram-mcp/src/features/notify/model/notifyService.js +5 -1
- package/dist/services/features/telegram-mcp/src/shared/integrations/redis/stateStore.js +28 -0
- package/dist/services/features/telegram-mcp/src/shared/integrations/telegram/transport.js +3 -0
- package/dist/services/features/telegram-mcp/src/shared/integrations/telegram/transportFileHandoffActions.js +6 -3
- package/dist/services/features/telegram-mcp/src/shared/integrations/terminal/client.js +29 -6
- package/dist/services/features/telegram-mcp/src/shared/integrations/terminal/ptyRegistry.js +100 -2
- package/dist/services/features/telegram-mcp/src/shared/lib/bodyLimits.js +63 -0
- package/dist/services/features/telegram-mcp/src/shared/lib/gatewayAuth.js +13 -0
- package/dist/services/features/telegram-mcp/src/shared/lib/time/localTimestamp.js +21 -0
- package/docs/CHAT_CONNECTOR.md +134 -0
- package/docs/STANDALONE-ru.md +55 -6
- package/docs/STANDALONE.md +55 -6
- package/package.json +10 -5
- package/packages/chrome-attach-extension/dist/background.js +1735 -0
- package/packages/chrome-attach-extension/dist/icon.svg +6 -0
- package/packages/chrome-attach-extension/dist/manifest.json +37 -0
- package/packages/chrome-attach-extension/dist/options.html +312 -0
- package/packages/chrome-attach-extension/dist/options.js +606 -0
- package/packages/chrome-attach-extension/dist/popup.html +93 -0
- package/packages/chrome-attach-extension/dist/popup.js +79 -0
- package/packages/chrome-attach-extension/dist/recorder-content.js +96 -0
- package/packages/chrome-attach-extension/dist/recorder-page.js +282 -0
- package/packages/firefox-attach-extension/README.md +13 -0
- package/packages/firefox-attach-extension/dist/background.js +1622 -0
- package/packages/firefox-attach-extension/dist/icon.svg +6 -0
- package/packages/firefox-attach-extension/dist/manifest.json +44 -0
- package/packages/firefox-attach-extension/dist/options.html +312 -0
- package/packages/firefox-attach-extension/dist/options.js +540 -0
- package/packages/firefox-attach-extension/dist/popup.html +93 -0
- package/packages/firefox-attach-extension/dist/popup.js +64 -0
- package/packages/firefox-attach-extension/dist/recorder-content.js +90 -0
- package/packages/firefox-attach-extension/dist/recorder-page.js +318 -0
package/dist/services/features/telegram-mcp/src/features/browser-attach/model/firefoxAttachServer.js
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.FirefoxAttachServer = void 0;
|
|
37
|
+
const node_crypto_1 = require("node:crypto");
|
|
38
|
+
const wsModule = __importStar(require("ws"));
|
|
39
|
+
const localTimestamp_1 = require("../../../shared/lib/time/localTimestamp");
|
|
40
|
+
const bodyLimits_1 = require("../../../shared/lib/bodyLimits");
|
|
41
|
+
const browserRecordingBundle_1 = require("./browserRecordingBundle");
|
|
42
|
+
const firefoxAttachRegistry_1 = require("./firefoxAttachRegistry");
|
|
43
|
+
const types_1 = require("./types");
|
|
44
|
+
const wsLib = wsModule;
|
|
45
|
+
const WebSocketServer = wsLib.WebSocketServer;
|
|
46
|
+
const WS_OPEN = wsLib.WebSocket.OPEN;
|
|
47
|
+
const ATTACH_HEARTBEAT_TIMEOUT_MS = 45_000;
|
|
48
|
+
const ATTACH_HEARTBEAT_REAP_INTERVAL_MS = 15_000;
|
|
49
|
+
const ATTACH_RATE_WINDOW_MS = 1_000;
|
|
50
|
+
const ATTACH_RATE_MAX_MESSAGES = 1_000;
|
|
51
|
+
class FirefoxAttachProtocolError extends Error {
|
|
52
|
+
reason;
|
|
53
|
+
constructor(reason) {
|
|
54
|
+
super(reason);
|
|
55
|
+
this.reason = reason;
|
|
56
|
+
this.name = "FirefoxAttachProtocolError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const isAllowedAttachOrigin = (origin) => {
|
|
60
|
+
if (!origin) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return /^(?:moz|chrome)-extension:\/\/[a-z0-9-]+\/?$/iu.test(origin);
|
|
64
|
+
};
|
|
65
|
+
class FirefoxAttachServer {
|
|
66
|
+
config;
|
|
67
|
+
logger;
|
|
68
|
+
sessionStore;
|
|
69
|
+
maintenanceStore;
|
|
70
|
+
wsServer = null;
|
|
71
|
+
registry = new firefoxAttachRegistry_1.FirefoxAttachRegistry();
|
|
72
|
+
recordingWriter;
|
|
73
|
+
sockets = new Set();
|
|
74
|
+
socketsByInstanceId = new Map();
|
|
75
|
+
pendingTabActions = new Map();
|
|
76
|
+
pendingRecordingControls = new Map();
|
|
77
|
+
activeRecordingsById = new Map();
|
|
78
|
+
heartbeatReaper = null;
|
|
79
|
+
constructor(config, logger, sessionStore, maintenanceStore) {
|
|
80
|
+
this.config = config;
|
|
81
|
+
this.logger = logger;
|
|
82
|
+
this.sessionStore = sessionStore;
|
|
83
|
+
this.maintenanceStore = maintenanceStore;
|
|
84
|
+
this.recordingWriter = new browserRecordingBundle_1.BrowserRecordingBundleWriter(config, logger);
|
|
85
|
+
}
|
|
86
|
+
async start() {
|
|
87
|
+
if (!this.config.browser.attach.enabled) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (this.wsServer) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const wsServer = new WebSocketServer({
|
|
94
|
+
host: this.config.browser.attach.host,
|
|
95
|
+
port: this.config.browser.attach.port,
|
|
96
|
+
path: this.config.browser.attach.path,
|
|
97
|
+
maxPayload: bodyLimits_1.MAX_BODY_SIZE_BYTES,
|
|
98
|
+
verifyClient: (info, done) => {
|
|
99
|
+
if (!isAllowedAttachOrigin(info.origin)) {
|
|
100
|
+
done(false, 403, "Forbidden");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
done(true);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
this.wsServer = wsServer;
|
|
107
|
+
wsServer.on("connection", (socket) => {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const state = {
|
|
110
|
+
socket,
|
|
111
|
+
lastSeenAt: now,
|
|
112
|
+
rateWindowStartedAt: now,
|
|
113
|
+
rateWindowMessageCount: 0,
|
|
114
|
+
protocolFailed: false,
|
|
115
|
+
};
|
|
116
|
+
this.sockets.add(state);
|
|
117
|
+
socket.on("message", (payload) => {
|
|
118
|
+
void this.handleMessage(state, payload).catch((error) => {
|
|
119
|
+
this.handleProtocolError(state, error);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
socket.on("close", () => {
|
|
123
|
+
if (state.instanceId) {
|
|
124
|
+
const current = this.socketsByInstanceId.get(state.instanceId);
|
|
125
|
+
if (current === state) {
|
|
126
|
+
void this.handleInstanceDisconnect(state.instanceId).catch((error) => {
|
|
127
|
+
this.logger.error("Firefox attach disconnect cleanup failed", {
|
|
128
|
+
instanceId: state.instanceId,
|
|
129
|
+
error: error instanceof Error ? error.message : String(error),
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
this.registry.remove(state.instanceId);
|
|
133
|
+
this.socketsByInstanceId.delete(state.instanceId);
|
|
134
|
+
}
|
|
135
|
+
this.logger.info("Firefox attach instance disconnected", {
|
|
136
|
+
instanceId: state.instanceId,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
this.sockets.delete(state);
|
|
140
|
+
});
|
|
141
|
+
socket.on("error", (error) => {
|
|
142
|
+
this.logger.debug("Firefox attach socket error", {
|
|
143
|
+
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
this.heartbeatReaper = setInterval(() => {
|
|
148
|
+
this.reapStaleSockets();
|
|
149
|
+
}, ATTACH_HEARTBEAT_REAP_INTERVAL_MS);
|
|
150
|
+
this.heartbeatReaper.unref();
|
|
151
|
+
this.logger.info("Firefox attach WebSocket server started", {
|
|
152
|
+
host: this.config.browser.attach.host,
|
|
153
|
+
port: this.config.browser.attach.port,
|
|
154
|
+
path: this.config.browser.attach.path,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
async stop() {
|
|
158
|
+
if (!this.wsServer) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (this.heartbeatReaper) {
|
|
162
|
+
clearInterval(this.heartbeatReaper);
|
|
163
|
+
this.heartbeatReaper = null;
|
|
164
|
+
}
|
|
165
|
+
for (const state of this.sockets) {
|
|
166
|
+
try {
|
|
167
|
+
state.socket.close(1001, "server shutdown");
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// ignore
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
this.sockets.clear();
|
|
174
|
+
this.socketsByInstanceId.clear();
|
|
175
|
+
const wsServer = this.wsServer;
|
|
176
|
+
this.wsServer = null;
|
|
177
|
+
await new Promise((resolve, reject) => {
|
|
178
|
+
wsServer.close((error) => {
|
|
179
|
+
if (error) {
|
|
180
|
+
reject(error);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
resolve();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
this.logger.info("Firefox attach WebSocket server stopped");
|
|
187
|
+
}
|
|
188
|
+
listInstances() {
|
|
189
|
+
return this.registry.listInstances();
|
|
190
|
+
}
|
|
191
|
+
async getRecordingStatus(sessionId) {
|
|
192
|
+
return this.maintenanceStore.getBrowserRecording(sessionId);
|
|
193
|
+
}
|
|
194
|
+
async startRecording(input) {
|
|
195
|
+
const existing = await this.maintenanceStore.getBrowserRecording(input.sessionId);
|
|
196
|
+
if (existing?.status === "recording") {
|
|
197
|
+
if (existing.instanceId !== input.instanceId) {
|
|
198
|
+
throw new Error("Recording is already active in another browser instance.");
|
|
199
|
+
}
|
|
200
|
+
if (existing.tabId !== input.tabId) {
|
|
201
|
+
throw new Error(`Browser instance '${input.instanceId}' is already recording tab '${existing.tabId}'. Stop it before starting recording for tab '${input.tabId}'.`);
|
|
202
|
+
}
|
|
203
|
+
return existing;
|
|
204
|
+
}
|
|
205
|
+
const session = await this.sessionStore.getSession(input.sessionId);
|
|
206
|
+
const resolvedSession = session ||
|
|
207
|
+
{
|
|
208
|
+
sessionId: input.sessionId,
|
|
209
|
+
label: this.config.project.sessionLabel?.trim() || input.sessionId,
|
|
210
|
+
cwd: process.cwd(),
|
|
211
|
+
updatedAt: (0, localTimestamp_1.formatLocalTimestamp)(new Date()),
|
|
212
|
+
};
|
|
213
|
+
if (!resolvedSession.cwd?.trim()) {
|
|
214
|
+
throw new Error("Workspace cwd is not registered for this console.");
|
|
215
|
+
}
|
|
216
|
+
if (!session) {
|
|
217
|
+
await this.sessionStore.setSession({
|
|
218
|
+
sessionId: resolvedSession.sessionId,
|
|
219
|
+
...(resolvedSession.label ? { label: resolvedSession.label } : {}),
|
|
220
|
+
cwd: resolvedSession.cwd,
|
|
221
|
+
updatedAt: resolvedSession.updatedAt,
|
|
222
|
+
});
|
|
223
|
+
this.logger.info("Firefox attach recording synthesized missing session context", {
|
|
224
|
+
sessionId: input.sessionId,
|
|
225
|
+
cwd: resolvedSession.cwd,
|
|
226
|
+
label: resolvedSession.label,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const recordingId = (0, node_crypto_1.randomUUID)();
|
|
230
|
+
const state = await this.recordingWriter.create({
|
|
231
|
+
session: resolvedSession,
|
|
232
|
+
sessionId: input.sessionId,
|
|
233
|
+
instanceId: input.instanceId,
|
|
234
|
+
tabId: input.tabId,
|
|
235
|
+
tabTitle: input.tabTitle,
|
|
236
|
+
...(input.tabUrl ? { tabUrl: input.tabUrl } : {}),
|
|
237
|
+
recordingId,
|
|
238
|
+
});
|
|
239
|
+
this.activeRecordingsById.set(recordingId, state);
|
|
240
|
+
await this.maintenanceStore.setBrowserRecording(state.record);
|
|
241
|
+
await this.broadcastRecordingState(state.record);
|
|
242
|
+
try {
|
|
243
|
+
await this.invokeRecordingControl({
|
|
244
|
+
instanceId: input.instanceId,
|
|
245
|
+
tabId: input.tabId,
|
|
246
|
+
recordingId,
|
|
247
|
+
mode: "start",
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
this.activeRecordingsById.delete(recordingId);
|
|
252
|
+
await this.maintenanceStore.clearBrowserRecording(input.sessionId);
|
|
253
|
+
await this.broadcastRecordingState(null);
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
return state.record;
|
|
257
|
+
}
|
|
258
|
+
async stopRecording(input) {
|
|
259
|
+
const existing = await this.maintenanceStore.getBrowserRecording(input.sessionId);
|
|
260
|
+
if (!existing) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const socketState = this.socketsByInstanceId.get(existing.instanceId);
|
|
264
|
+
const activeState = this.activeRecordingsById.get(existing.recordingId);
|
|
265
|
+
if (socketState) {
|
|
266
|
+
try {
|
|
267
|
+
await this.invokeRecordingControl({
|
|
268
|
+
instanceId: existing.instanceId,
|
|
269
|
+
tabId: existing.tabId,
|
|
270
|
+
recordingId: existing.recordingId,
|
|
271
|
+
mode: "stop",
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Local writer or persisted state still finalizes the bundle.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (activeState) {
|
|
279
|
+
await this.recordingWriter.appendEvent(activeState, {
|
|
280
|
+
kind: "session_stopped",
|
|
281
|
+
status: "stopped",
|
|
282
|
+
});
|
|
283
|
+
this.activeRecordingsById.delete(existing.recordingId);
|
|
284
|
+
await this.maintenanceStore.setBrowserRecording(activeState.record);
|
|
285
|
+
await this.broadcastRecordingState(activeState.record);
|
|
286
|
+
return activeState.record;
|
|
287
|
+
}
|
|
288
|
+
if (existing.status !== "stopped") {
|
|
289
|
+
const session = await this.sessionStore.getSession(existing.sessionId);
|
|
290
|
+
const stopped = session?.cwd?.trim()
|
|
291
|
+
? await this.recordingWriter.finalizeExisting({
|
|
292
|
+
session,
|
|
293
|
+
record: existing,
|
|
294
|
+
})
|
|
295
|
+
: {
|
|
296
|
+
...existing,
|
|
297
|
+
status: "stopped",
|
|
298
|
+
stoppedAt: (0, localTimestamp_1.formatLocalTimestamp)(new Date()),
|
|
299
|
+
};
|
|
300
|
+
await this.maintenanceStore.setBrowserRecording(stopped);
|
|
301
|
+
await this.broadcastRecordingState(stopped);
|
|
302
|
+
return stopped;
|
|
303
|
+
}
|
|
304
|
+
return existing;
|
|
305
|
+
}
|
|
306
|
+
async invokeTabAction(input) {
|
|
307
|
+
const socketState = this.socketsByInstanceId.get(input.instanceId);
|
|
308
|
+
if (!socketState) {
|
|
309
|
+
throw new Error(`Attached browser instance '${input.instanceId}' is not connected.`);
|
|
310
|
+
}
|
|
311
|
+
const requestId = `tab-action-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
312
|
+
const result = await new Promise((resolve, reject) => {
|
|
313
|
+
const timer = setTimeout(() => {
|
|
314
|
+
this.pendingTabActions.delete(requestId);
|
|
315
|
+
reject(new Error(`Attached browser action '${input.action}' timed out for instance '${input.instanceId}'.`));
|
|
316
|
+
}, this.config.browser.timeoutMs);
|
|
317
|
+
this.pendingTabActions.set(requestId, {
|
|
318
|
+
instanceId: input.instanceId,
|
|
319
|
+
resolve,
|
|
320
|
+
reject,
|
|
321
|
+
timer,
|
|
322
|
+
});
|
|
323
|
+
this.sendJson(socketState.socket, {
|
|
324
|
+
type: "tab_action",
|
|
325
|
+
request_id: requestId,
|
|
326
|
+
tab_id: input.tabId,
|
|
327
|
+
action: input.action,
|
|
328
|
+
...(input.payload ? { payload: input.payload } : {}),
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
if (!result.ok) {
|
|
332
|
+
throw new Error(result.error ||
|
|
333
|
+
`Attached browser action '${input.action}' failed for instance '${input.instanceId}'.`);
|
|
334
|
+
}
|
|
335
|
+
return result.result;
|
|
336
|
+
}
|
|
337
|
+
sendJson(socket, payload) {
|
|
338
|
+
if (socket.readyState !== WS_OPEN) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
socket.send(JSON.stringify(payload));
|
|
342
|
+
}
|
|
343
|
+
async handleMessage(state, payload) {
|
|
344
|
+
if (state.protocolFailed) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
this.enforceRateLimit(state);
|
|
348
|
+
state.lastSeenAt = Date.now();
|
|
349
|
+
const raw = typeof payload === "string"
|
|
350
|
+
? payload
|
|
351
|
+
: Buffer.isBuffer(payload)
|
|
352
|
+
? payload.toString("utf8")
|
|
353
|
+
: payload instanceof ArrayBuffer
|
|
354
|
+
? Buffer.from(payload).toString("utf8")
|
|
355
|
+
: Array.isArray(payload) && payload.every(Buffer.isBuffer)
|
|
356
|
+
? Buffer.concat(payload).toString("utf8")
|
|
357
|
+
: String(payload ?? "");
|
|
358
|
+
let decoded;
|
|
359
|
+
try {
|
|
360
|
+
decoded = JSON.parse(raw);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
throw new FirefoxAttachProtocolError("invalid_json");
|
|
364
|
+
}
|
|
365
|
+
const parsed = types_1.firefoxAttachInboundMessageSchema.safeParse(decoded);
|
|
366
|
+
if (!parsed.success) {
|
|
367
|
+
throw new FirefoxAttachProtocolError("invalid_message");
|
|
368
|
+
}
|
|
369
|
+
const message = parsed.data;
|
|
370
|
+
if (!state.instanceId && message.type !== "hello") {
|
|
371
|
+
throw new FirefoxAttachProtocolError("hello_required");
|
|
372
|
+
}
|
|
373
|
+
if (state.instanceId && message.type === "hello") {
|
|
374
|
+
throw new FirefoxAttachProtocolError("duplicate_hello");
|
|
375
|
+
}
|
|
376
|
+
const connectedInstanceId = state.instanceId;
|
|
377
|
+
switch (message.type) {
|
|
378
|
+
case "hello":
|
|
379
|
+
await this.handleHello(state, message);
|
|
380
|
+
return;
|
|
381
|
+
case "heartbeat":
|
|
382
|
+
if (state.instanceId) {
|
|
383
|
+
this.registry.touch(state.instanceId);
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
case "list_tabs_result":
|
|
387
|
+
if (state.instanceId) {
|
|
388
|
+
this.registry.setTabs(state.instanceId, message.tabs);
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
case "get_active_tab_result":
|
|
392
|
+
if (state.instanceId) {
|
|
393
|
+
this.registry.setActiveTab(state.instanceId, message.tab);
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
case "active_tab_changed":
|
|
397
|
+
if (state.instanceId) {
|
|
398
|
+
this.registry.setActiveTab(state.instanceId, message.tab);
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
case "tab_updated":
|
|
402
|
+
if (state.instanceId) {
|
|
403
|
+
this.registry.updateTab(state.instanceId, message.tab);
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
case "attach_tab_selected":
|
|
407
|
+
if (state.instanceId) {
|
|
408
|
+
this.registry.updateTab(state.instanceId, {
|
|
409
|
+
...message.tab,
|
|
410
|
+
active: true,
|
|
411
|
+
});
|
|
412
|
+
await this.persistSelectedTab(state.instanceId, message.tab);
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
case "tab_action_result":
|
|
416
|
+
if (connectedInstanceId) {
|
|
417
|
+
this.resolvePendingTabAction(connectedInstanceId, message);
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
case "recording_control_result":
|
|
421
|
+
if (connectedInstanceId) {
|
|
422
|
+
this.resolvePendingRecordingControl(connectedInstanceId, message);
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
case "recording_event":
|
|
426
|
+
if (state.instanceId) {
|
|
427
|
+
await this.handleRecordingEvent(state.instanceId, message.recording_id, message.tab_id, message.event);
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
case "recording_manual_start":
|
|
431
|
+
case "recording_manual_stop":
|
|
432
|
+
case "recording_manual_status":
|
|
433
|
+
if (state.instanceId) {
|
|
434
|
+
await this.handleManualRecordingRequest(state.socket, state.instanceId, message);
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
default:
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async handleHello(state, message) {
|
|
442
|
+
const previous = this.socketsByInstanceId.get(message.instance_id);
|
|
443
|
+
state.instanceId = message.instance_id;
|
|
444
|
+
this.socketsByInstanceId.set(message.instance_id, state);
|
|
445
|
+
if (previous && previous !== state) {
|
|
446
|
+
this.logger.warn("Firefox attach instance connection replaced", {
|
|
447
|
+
instanceId: message.instance_id,
|
|
448
|
+
});
|
|
449
|
+
previous.socket.close(4001, "replaced by newer connection");
|
|
450
|
+
}
|
|
451
|
+
const instance = this.registry.setConnected({
|
|
452
|
+
instanceId: message.instance_id,
|
|
453
|
+
browser: message.browser,
|
|
454
|
+
extensionVersion: message.extension_version,
|
|
455
|
+
...(message.profile_name ? { profileName: message.profile_name } : {}),
|
|
456
|
+
capabilities: ["tabs", "active_tab", "recording"],
|
|
457
|
+
});
|
|
458
|
+
this.sendJson(state.socket, {
|
|
459
|
+
type: "hello_ack",
|
|
460
|
+
ok: true,
|
|
461
|
+
instance_id: message.instance_id,
|
|
462
|
+
capabilities: instance.capabilities,
|
|
463
|
+
...(this.config.project.sessionId
|
|
464
|
+
? { session_id: this.config.project.sessionId }
|
|
465
|
+
: {}),
|
|
466
|
+
...(this.config.project.sessionLabel
|
|
467
|
+
? { session_label: this.config.project.sessionLabel }
|
|
468
|
+
: {}),
|
|
469
|
+
});
|
|
470
|
+
this.logger.info("Firefox attach instance connected", {
|
|
471
|
+
instanceId: message.instance_id,
|
|
472
|
+
extensionVersion: message.extension_version,
|
|
473
|
+
profileName: message.profile_name,
|
|
474
|
+
});
|
|
475
|
+
this.sendJson(state.socket, {
|
|
476
|
+
type: "list_tabs",
|
|
477
|
+
request_id: "bootstrap-list-tabs",
|
|
478
|
+
});
|
|
479
|
+
this.sendJson(state.socket, {
|
|
480
|
+
type: "get_active_tab",
|
|
481
|
+
request_id: "bootstrap-active-tab",
|
|
482
|
+
});
|
|
483
|
+
await this.sendCurrentRecordingState(state.socket);
|
|
484
|
+
}
|
|
485
|
+
async persistSelectedTab(instanceId, tab) {
|
|
486
|
+
const sessionId = this.config.project.sessionId?.trim();
|
|
487
|
+
if (!sessionId) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
await this.maintenanceStore.setBrowserAttachment({
|
|
491
|
+
sessionId,
|
|
492
|
+
backend: "firefox-attached",
|
|
493
|
+
instanceId,
|
|
494
|
+
tabId: tab.tab_id,
|
|
495
|
+
attachedAt: (0, localTimestamp_1.formatLocalTimestamp)(new Date()),
|
|
496
|
+
...(tab.title ? { title: tab.title } : {}),
|
|
497
|
+
...(tab.url ? { url: tab.url } : {}),
|
|
498
|
+
});
|
|
499
|
+
this.logger.info("Firefox attached tab selected", {
|
|
500
|
+
sessionId,
|
|
501
|
+
instanceId,
|
|
502
|
+
tabId: tab.tab_id,
|
|
503
|
+
title: tab.title,
|
|
504
|
+
url: tab.url,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
resolvePendingTabAction(instanceId, message) {
|
|
508
|
+
const pending = this.pendingTabActions.get(message.request_id);
|
|
509
|
+
if (!pending || pending.instanceId !== instanceId) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
clearTimeout(pending.timer);
|
|
513
|
+
this.pendingTabActions.delete(message.request_id);
|
|
514
|
+
pending.resolve(message);
|
|
515
|
+
}
|
|
516
|
+
resolvePendingRecordingControl(instanceId, message) {
|
|
517
|
+
const pending = this.pendingRecordingControls.get(message.request_id);
|
|
518
|
+
if (!pending || pending.instanceId !== instanceId) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
clearTimeout(pending.timer);
|
|
522
|
+
this.pendingRecordingControls.delete(message.request_id);
|
|
523
|
+
pending.resolve(message);
|
|
524
|
+
}
|
|
525
|
+
async invokeRecordingControl(input) {
|
|
526
|
+
const socketState = this.socketsByInstanceId.get(input.instanceId);
|
|
527
|
+
if (!socketState) {
|
|
528
|
+
throw new Error(`Attached browser instance '${input.instanceId}' is not connected.`);
|
|
529
|
+
}
|
|
530
|
+
const requestId = `recording-${input.mode}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
531
|
+
const result = await new Promise((resolve, reject) => {
|
|
532
|
+
const timer = setTimeout(() => {
|
|
533
|
+
this.pendingRecordingControls.delete(requestId);
|
|
534
|
+
reject(new Error(`Attached browser recording '${input.mode}' timed out for instance '${input.instanceId}'.`));
|
|
535
|
+
}, this.config.browser.timeoutMs);
|
|
536
|
+
this.pendingRecordingControls.set(requestId, {
|
|
537
|
+
instanceId: input.instanceId,
|
|
538
|
+
resolve,
|
|
539
|
+
reject,
|
|
540
|
+
timer,
|
|
541
|
+
});
|
|
542
|
+
this.sendJson(socketState.socket, input.mode === "start"
|
|
543
|
+
? {
|
|
544
|
+
type: "recording_start",
|
|
545
|
+
request_id: requestId,
|
|
546
|
+
recording_id: input.recordingId,
|
|
547
|
+
tab_id: input.tabId,
|
|
548
|
+
}
|
|
549
|
+
: {
|
|
550
|
+
type: "recording_stop",
|
|
551
|
+
request_id: requestId,
|
|
552
|
+
recording_id: input.recordingId,
|
|
553
|
+
tab_id: input.tabId,
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
if (!result.ok) {
|
|
557
|
+
throw new Error(result.error ||
|
|
558
|
+
`Attached browser recording '${input.mode}' failed for instance '${input.instanceId}'.`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async handleRecordingEvent(instanceId, recordingId, tabId, event) {
|
|
562
|
+
const state = this.activeRecordingsById.get(recordingId);
|
|
563
|
+
if (!state) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (state.record.instanceId !== instanceId || state.record.tabId !== tabId) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
await this.recordingWriter.appendEvent(state, event);
|
|
570
|
+
await this.maintenanceStore.setBrowserRecording(state.record);
|
|
571
|
+
}
|
|
572
|
+
async handleInstanceDisconnect(instanceId) {
|
|
573
|
+
const activeStates = Array.from(this.activeRecordingsById.values()).filter((state) => state.record.instanceId === instanceId);
|
|
574
|
+
if (activeStates.length === 0) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
for (const state of activeStates) {
|
|
578
|
+
await this.recordingWriter.appendEvent(state, {
|
|
579
|
+
kind: "session_stopped",
|
|
580
|
+
status: "instance_disconnected",
|
|
581
|
+
});
|
|
582
|
+
this.activeRecordingsById.delete(state.record.recordingId);
|
|
583
|
+
await this.maintenanceStore.setBrowserRecording(state.record);
|
|
584
|
+
await this.broadcastRecordingState(state.record);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
buildRecordingStateMessage(record) {
|
|
588
|
+
return {
|
|
589
|
+
type: "recording_state",
|
|
590
|
+
active: record?.status === "recording",
|
|
591
|
+
...(record ? { recording: this.mapManualRecordingResult(record) } : {}),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
async sendCurrentRecordingState(socket) {
|
|
595
|
+
const sessionId = this.config.project.sessionId?.trim();
|
|
596
|
+
const record = sessionId
|
|
597
|
+
? await this.maintenanceStore.getBrowserRecording(sessionId)
|
|
598
|
+
: null;
|
|
599
|
+
this.sendJson(socket, this.buildRecordingStateMessage(record));
|
|
600
|
+
}
|
|
601
|
+
async broadcastRecordingState(record) {
|
|
602
|
+
const payload = this.buildRecordingStateMessage(record);
|
|
603
|
+
for (const state of this.sockets) {
|
|
604
|
+
if (state.instanceId) {
|
|
605
|
+
this.sendJson(state.socket, payload);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
enforceRateLimit(state) {
|
|
610
|
+
const now = Date.now();
|
|
611
|
+
if (now - state.rateWindowStartedAt >= ATTACH_RATE_WINDOW_MS) {
|
|
612
|
+
state.rateWindowStartedAt = now;
|
|
613
|
+
state.rateWindowMessageCount = 0;
|
|
614
|
+
}
|
|
615
|
+
state.rateWindowMessageCount += 1;
|
|
616
|
+
if (state.rateWindowMessageCount > ATTACH_RATE_MAX_MESSAGES) {
|
|
617
|
+
throw new FirefoxAttachProtocolError("rate_limit_exceeded");
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
handleProtocolError(state, error) {
|
|
621
|
+
if (state.protocolFailed) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
state.protocolFailed = true;
|
|
625
|
+
const reason = error instanceof FirefoxAttachProtocolError
|
|
626
|
+
? error.reason
|
|
627
|
+
: "message_handler_failed";
|
|
628
|
+
this.logger.warn("Firefox attach protocol error", {
|
|
629
|
+
...(state.instanceId ? { instanceId: state.instanceId } : {}),
|
|
630
|
+
reason,
|
|
631
|
+
});
|
|
632
|
+
try {
|
|
633
|
+
state.socket.close(1008, reason);
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
// The socket may already have closed while the handler was awaiting I/O.
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
reapStaleSockets() {
|
|
640
|
+
const staleBefore = Date.now() - ATTACH_HEARTBEAT_TIMEOUT_MS;
|
|
641
|
+
for (const state of this.sockets) {
|
|
642
|
+
if (state.lastSeenAt >= staleBefore) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
this.logger.warn("Firefox attach socket heartbeat expired", {
|
|
646
|
+
...(state.instanceId ? { instanceId: state.instanceId } : {}),
|
|
647
|
+
});
|
|
648
|
+
state.socket.close(4002, "heartbeat timeout");
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
mapManualRecordingResult(record) {
|
|
652
|
+
if (!record) {
|
|
653
|
+
return undefined;
|
|
654
|
+
}
|
|
655
|
+
return {
|
|
656
|
+
recording_id: record.recordingId,
|
|
657
|
+
instance_id: record.instanceId,
|
|
658
|
+
tab_id: record.tabId,
|
|
659
|
+
...(record.tabTitle ? { tab_title: record.tabTitle } : {}),
|
|
660
|
+
...(record.tabUrl ? { tab_url: record.tabUrl } : {}),
|
|
661
|
+
bundle_dir_name: record.bundleDirName,
|
|
662
|
+
bundle_relative_path: record.bundleRelativePath,
|
|
663
|
+
bundle_path: record.bundlePath,
|
|
664
|
+
started_at: record.startedAt,
|
|
665
|
+
...(record.stoppedAt ? { stopped_at: record.stoppedAt } : {}),
|
|
666
|
+
status: record.status,
|
|
667
|
+
event_count: record.eventCount,
|
|
668
|
+
...(record.lastEventAt ? { last_event_at: record.lastEventAt } : {}),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
async handleManualRecordingRequest(socket, instanceId, message) {
|
|
672
|
+
const sessionId = this.config.project.sessionId?.trim();
|
|
673
|
+
if (!sessionId) {
|
|
674
|
+
this.sendJson(socket, {
|
|
675
|
+
type: "recording_manual_result",
|
|
676
|
+
request_id: message.request_id,
|
|
677
|
+
ok: false,
|
|
678
|
+
active: false,
|
|
679
|
+
error: "Session id is not configured for this attach server.",
|
|
680
|
+
});
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
try {
|
|
684
|
+
if (message.type === "recording_manual_status") {
|
|
685
|
+
const record = await this.getRecordingStatus(sessionId);
|
|
686
|
+
this.sendJson(socket, {
|
|
687
|
+
type: "recording_manual_result",
|
|
688
|
+
request_id: message.request_id,
|
|
689
|
+
ok: true,
|
|
690
|
+
active: record?.status === "recording",
|
|
691
|
+
...(record ? { recording: this.mapManualRecordingResult(record) } : {}),
|
|
692
|
+
});
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (message.type === "recording_manual_stop") {
|
|
696
|
+
const record = await this.stopRecording({ sessionId });
|
|
697
|
+
this.sendJson(socket, {
|
|
698
|
+
type: "recording_manual_result",
|
|
699
|
+
request_id: message.request_id,
|
|
700
|
+
ok: true,
|
|
701
|
+
active: record?.status === "recording",
|
|
702
|
+
...(record ? { recording: this.mapManualRecordingResult(record) } : {}),
|
|
703
|
+
});
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const tab = message.tab;
|
|
707
|
+
if (!tab) {
|
|
708
|
+
throw new Error("Selected tab metadata is required to start recording.");
|
|
709
|
+
}
|
|
710
|
+
const record = await this.startRecording({
|
|
711
|
+
sessionId,
|
|
712
|
+
instanceId,
|
|
713
|
+
tabId: tab.tab_id,
|
|
714
|
+
tabTitle: tab.title || "attached-tab",
|
|
715
|
+
...(tab.url ? { tabUrl: tab.url } : {}),
|
|
716
|
+
});
|
|
717
|
+
this.sendJson(socket, {
|
|
718
|
+
type: "recording_manual_result",
|
|
719
|
+
request_id: message.request_id,
|
|
720
|
+
ok: true,
|
|
721
|
+
active: true,
|
|
722
|
+
recording: this.mapManualRecordingResult(record),
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
this.sendJson(socket, {
|
|
727
|
+
type: "recording_manual_result",
|
|
728
|
+
request_id: message.request_id,
|
|
729
|
+
ok: false,
|
|
730
|
+
active: false,
|
|
731
|
+
error: error instanceof Error ? error.message : String(error),
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
exports.FirefoxAttachServer = FirefoxAttachServer;
|