@deadragdoll/tellymcp 0.0.13 → 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.
Files changed (64) hide show
  1. package/.env.example.client +5 -1
  2. package/.env.example.gateway +16 -1
  3. package/CHANGELOG.md +30 -0
  4. package/README-ru.md +14 -0
  5. package/README.md +14 -0
  6. package/TOOLS.md +182 -10
  7. package/config/templates/env.both.template +15 -2
  8. package/config/templates/env.client.template +5 -2
  9. package/config/templates/env.gateway.template +15 -2
  10. package/dist/cli.js +28 -1
  11. package/dist/services/features/telegram-mcp/browser.service.js +18 -0
  12. package/dist/services/features/telegram-mcp/file-content.service.js +94 -0
  13. package/dist/services/features/telegram-mcp/gateway-delivery.service.js +5 -1
  14. package/dist/services/features/telegram-mcp/gateway-socket.service.js +43 -11
  15. package/dist/services/features/telegram-mcp/mcp-http.service.js +1 -0
  16. package/dist/services/features/telegram-mcp/mcp-server.service.js +18 -0
  17. package/dist/services/features/telegram-mcp/src/app/bootstrap/runtime.js +9 -1
  18. package/dist/services/features/telegram-mcp/src/app/config/env.js +119 -2
  19. package/dist/services/features/telegram-mcp/src/app/http.js +139 -99
  20. package/dist/services/features/telegram-mcp/src/app/oauthFacade.js +642 -0
  21. package/dist/services/features/telegram-mcp/src/app/webapp/assets.js +151 -170
  22. package/dist/services/features/telegram-mcp/src/app/webapp/auth.js +96 -99
  23. package/dist/services/features/telegram-mcp/src/entities/request/model/schema.js +62 -19
  24. package/dist/services/features/telegram-mcp/src/features/browser/model/browserAttachActiveTabTool.js +28 -0
  25. package/dist/services/features/telegram-mcp/src/features/browser/model/browserAttachTabTool.js +28 -0
  26. package/dist/services/features/telegram-mcp/src/features/browser/model/browserDetachTabTool.js +28 -0
  27. package/dist/services/features/telegram-mcp/src/features/browser/model/browserListAttachedInstancesTool.js +1 -1
  28. package/dist/services/features/telegram-mcp/src/features/browser/model/browserListTabsTool.js +1 -1
  29. package/dist/services/features/telegram-mcp/src/features/browser/model/browserService.js +410 -27
  30. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/browserRecordingBundle.js +37 -3
  31. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/firefoxAttachRegistry.js +7 -6
  32. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/firefoxAttachServer.js +214 -37
  33. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/types.js +186 -0
  34. package/dist/services/features/telegram-mcp/src/features/collaboration/model/collaborationService.js +2 -0
  35. package/dist/services/features/telegram-mcp/src/features/collaboration/model/sendPartnerFileService.js +6 -3
  36. package/dist/services/features/telegram-mcp/src/features/distributed-client/model/gatewayClientAccess.js +4 -1
  37. package/dist/services/features/telegram-mcp/src/features/distributed-gateway/model/gatewayHttpService.js +33 -35
  38. package/dist/services/features/telegram-mcp/src/features/file-content/model/getFileListTool.js +33 -0
  39. package/dist/services/features/telegram-mcp/src/features/file-content/model/getFileService.js +327 -0
  40. package/dist/services/features/telegram-mcp/src/features/file-content/model/getFileTool.js +81 -0
  41. package/dist/services/features/telegram-mcp/src/features/file-content/model/temporaryFileLinkStore.js +307 -0
  42. package/dist/services/features/telegram-mcp/src/features/file-content/model/workspaceFilePolicy.js +115 -0
  43. package/dist/services/features/telegram-mcp/src/features/notify/model/notifyService.js +5 -1
  44. package/dist/services/features/telegram-mcp/src/shared/integrations/telegram/transport.js +3 -0
  45. package/dist/services/features/telegram-mcp/src/shared/integrations/telegram/transportFileHandoffActions.js +6 -3
  46. package/dist/services/features/telegram-mcp/src/shared/integrations/terminal/client.js +29 -6
  47. package/dist/services/features/telegram-mcp/src/shared/integrations/terminal/ptyRegistry.js +100 -2
  48. package/dist/services/features/telegram-mcp/src/shared/lib/bodyLimits.js +63 -0
  49. package/dist/services/features/telegram-mcp/src/shared/lib/gatewayAuth.js +13 -0
  50. package/dist/services/features/telegram-mcp/src/shared/lib/time/localTimestamp.js +21 -0
  51. package/docs/CHAT_CONNECTOR.md +134 -0
  52. package/docs/STANDALONE-ru.md +13 -0
  53. package/docs/STANDALONE.md +13 -0
  54. package/package.json +5 -3
  55. package/packages/chrome-attach-extension/dist/background.js +572 -163
  56. package/packages/chrome-attach-extension/dist/manifest.json +2 -1
  57. package/packages/chrome-attach-extension/dist/options.js +15 -2
  58. package/packages/chrome-attach-extension/dist/recorder-content.js +14 -1
  59. package/packages/chrome-attach-extension/dist/recorder-page.js +34 -18
  60. package/packages/firefox-attach-extension/dist/background.js +413 -33
  61. package/packages/firefox-attach-extension/dist/manifest.json +0 -12
  62. package/packages/firefox-attach-extension/dist/options.js +14 -1
  63. package/packages/firefox-attach-extension/dist/recorder-content.js +14 -1
  64. package/packages/firefox-attach-extension/dist/recorder-page.js +34 -18
@@ -17,8 +17,25 @@ function sanitizeSegment(value) {
17
17
  .replace(/-+$/u, "");
18
18
  return normalized || "recording";
19
19
  }
20
+ function padNumber(value, length = 2) {
21
+ return String(value).padStart(length, "0");
22
+ }
23
+ function formatLocalTimestamp(date) {
24
+ return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())}T${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}.${padNumber(date.getMilliseconds(), 3)}`;
25
+ }
20
26
  function formatTimestampForDir(date) {
21
- return date.toISOString().replace(/[:.]/gu, "-");
27
+ return formatLocalTimestamp(date).replace(/[:.]/gu, "-");
28
+ }
29
+ function normalizeRecordedTimestamp(rawValue) {
30
+ const trimmed = rawValue?.trim();
31
+ if (!trimmed) {
32
+ return formatLocalTimestamp(new Date());
33
+ }
34
+ const parsed = new Date(trimmed);
35
+ if (Number.isNaN(parsed.getTime())) {
36
+ return formatLocalTimestamp(new Date());
37
+ }
38
+ return formatLocalTimestamp(parsed);
22
39
  }
23
40
  function formatJsonLine(value) {
24
41
  return Buffer.from(`${JSON.stringify(value)}\n`, "utf8");
@@ -85,7 +102,7 @@ class BrowserRecordingBundleWriter {
85
102
  ]) {
86
103
  await (0, client_1.writeXchangeRelativeFile)(this.config.terminal, input.session.cwd, this.config.exchange.dir, `${segment}/.keep`, Buffer.from("", "utf8"));
87
104
  }
88
- const startedAt = new Date().toISOString();
105
+ const startedAt = formatLocalTimestamp(new Date());
89
106
  const record = {
90
107
  sessionId: input.sessionId,
91
108
  backend: "firefox-attached",
@@ -150,7 +167,7 @@ class BrowserRecordingBundleWriter {
150
167
  return state;
151
168
  }
152
169
  async appendEvent(state, event) {
153
- const at = event.at?.trim() || new Date().toISOString();
170
+ const at = normalizeRecordedTimestamp(event.at);
154
171
  state.record.eventCount += 1;
155
172
  state.record.lastEventAt = at;
156
173
  let relPath;
@@ -255,6 +272,23 @@ class BrowserRecordingBundleWriter {
255
272
  await this.writeSessionJson(state);
256
273
  return state;
257
274
  }
275
+ async finalizeExisting(input) {
276
+ const state = {
277
+ record: { ...input.record },
278
+ session: input.session,
279
+ currentPageId: undefined,
280
+ currentPageUrl: input.record.tabUrl,
281
+ pageCounter: 0,
282
+ snapshotCounter: 0,
283
+ requestCounter: 0,
284
+ requestArtifacts: new Map(),
285
+ };
286
+ await this.appendEvent(state, {
287
+ kind: "session_stopped",
288
+ status: "stopped",
289
+ });
290
+ return state.record;
291
+ }
258
292
  async appendJsonLine(state, relativePath, value) {
259
293
  await (0, client_1.writeXchangeRelativeFile)(this.config.terminal, state.session.cwd || "", this.config.exchange.dir, relativePath, formatJsonLine(value), { append: true });
260
294
  }
@@ -1,14 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FirefoxAttachRegistry = void 0;
4
+ const localTimestamp_1 = require("../../../shared/lib/time/localTimestamp");
4
5
  class FirefoxAttachRegistry {
5
6
  instances = new Map();
6
7
  setConnected(input) {
7
- const now = new Date().toISOString();
8
+ const now = (0, localTimestamp_1.formatLocalTimestamp)(new Date());
8
9
  const existing = this.instances.get(input.instanceId);
9
10
  const next = {
10
11
  instanceId: input.instanceId,
11
- browser: "firefox",
12
+ browser: input.browser,
12
13
  extensionVersion: input.extensionVersion,
13
14
  ...(input.profileName ? { profileName: input.profileName } : {}),
14
15
  connectedAt: existing?.connectedAt ?? now,
@@ -25,7 +26,7 @@ class FirefoxAttachRegistry {
25
26
  if (!existing) {
26
27
  return;
27
28
  }
28
- existing.lastSeenAt = new Date().toISOString();
29
+ existing.lastSeenAt = (0, localTimestamp_1.formatLocalTimestamp)(new Date());
29
30
  }
30
31
  setTabs(instanceId, tabs) {
31
32
  const existing = this.instances.get(instanceId);
@@ -35,7 +36,7 @@ class FirefoxAttachRegistry {
35
36
  existing.tabs = tabs.map((tab) => ({ ...tab }));
36
37
  existing.activeTab =
37
38
  tabs.find((tab) => tab.active) ?? existing.activeTab ?? null;
38
- existing.lastSeenAt = new Date().toISOString();
39
+ existing.lastSeenAt = (0, localTimestamp_1.formatLocalTimestamp)(new Date());
39
40
  }
40
41
  setActiveTab(instanceId, tab) {
41
42
  const existing = this.instances.get(instanceId);
@@ -43,7 +44,7 @@ class FirefoxAttachRegistry {
43
44
  return;
44
45
  }
45
46
  existing.activeTab = tab ? { ...tab } : null;
46
- existing.lastSeenAt = new Date().toISOString();
47
+ existing.lastSeenAt = (0, localTimestamp_1.formatLocalTimestamp)(new Date());
47
48
  }
48
49
  updateTab(instanceId, tab) {
49
50
  const existing = this.instances.get(instanceId);
@@ -62,7 +63,7 @@ class FirefoxAttachRegistry {
62
63
  if (tab.active) {
63
64
  existing.activeTab = { ...tab };
64
65
  }
65
- existing.lastSeenAt = new Date().toISOString();
66
+ existing.lastSeenAt = (0, localTimestamp_1.formatLocalTimestamp)(new Date());
66
67
  }
67
68
  remove(instanceId) {
68
69
  this.instances.delete(instanceId);
@@ -1,16 +1,67 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
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
+ })();
5
35
  Object.defineProperty(exports, "__esModule", { value: true });
6
36
  exports.FirefoxAttachServer = void 0;
7
37
  const node_crypto_1 = require("node:crypto");
8
- const ws_1 = __importDefault(require("ws"));
38
+ const wsModule = __importStar(require("ws"));
39
+ const localTimestamp_1 = require("../../../shared/lib/time/localTimestamp");
40
+ const bodyLimits_1 = require("../../../shared/lib/bodyLimits");
9
41
  const browserRecordingBundle_1 = require("./browserRecordingBundle");
10
42
  const firefoxAttachRegistry_1 = require("./firefoxAttachRegistry");
11
- const wsLib = ws_1.default;
43
+ const types_1 = require("./types");
44
+ const wsLib = wsModule;
12
45
  const WebSocketServer = wsLib.WebSocketServer;
13
- const WS_OPEN = wsLib.OPEN;
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
+ };
14
65
  class FirefoxAttachServer {
15
66
  config;
16
67
  logger;
@@ -24,6 +75,7 @@ class FirefoxAttachServer {
24
75
  pendingTabActions = new Map();
25
76
  pendingRecordingControls = new Map();
26
77
  activeRecordingsById = new Map();
78
+ heartbeatReaper = null;
27
79
  constructor(config, logger, sessionStore, maintenanceStore) {
28
80
  this.config = config;
29
81
  this.logger = logger;
@@ -38,22 +90,48 @@ class FirefoxAttachServer {
38
90
  if (this.wsServer) {
39
91
  return;
40
92
  }
41
- this.wsServer = new WebSocketServer({
93
+ const wsServer = new WebSocketServer({
42
94
  host: this.config.browser.attach.host,
43
95
  port: this.config.browser.attach.port,
44
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
+ },
45
105
  });
46
- this.wsServer.on("connection", (socket) => {
47
- const state = { socket };
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
+ };
48
116
  this.sockets.add(state);
49
117
  socket.on("message", (payload) => {
50
- void this.handleMessage(state, payload);
118
+ void this.handleMessage(state, payload).catch((error) => {
119
+ this.handleProtocolError(state, error);
120
+ });
51
121
  });
52
122
  socket.on("close", () => {
53
123
  if (state.instanceId) {
54
- void this.handleInstanceDisconnect(state.instanceId);
55
- this.registry.remove(state.instanceId);
56
- this.socketsByInstanceId.delete(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
+ }
57
135
  this.logger.info("Firefox attach instance disconnected", {
58
136
  instanceId: state.instanceId,
59
137
  });
@@ -66,6 +144,10 @@ class FirefoxAttachServer {
66
144
  });
67
145
  });
68
146
  });
147
+ this.heartbeatReaper = setInterval(() => {
148
+ this.reapStaleSockets();
149
+ }, ATTACH_HEARTBEAT_REAP_INTERVAL_MS);
150
+ this.heartbeatReaper.unref();
69
151
  this.logger.info("Firefox attach WebSocket server started", {
70
152
  host: this.config.browser.attach.host,
71
153
  port: this.config.browser.attach.port,
@@ -76,6 +158,10 @@ class FirefoxAttachServer {
76
158
  if (!this.wsServer) {
77
159
  return;
78
160
  }
161
+ if (this.heartbeatReaper) {
162
+ clearInterval(this.heartbeatReaper);
163
+ this.heartbeatReaper = null;
164
+ }
79
165
  for (const state of this.sockets) {
80
166
  try {
81
167
  state.socket.close(1001, "server shutdown");
@@ -85,6 +171,7 @@ class FirefoxAttachServer {
85
171
  }
86
172
  }
87
173
  this.sockets.clear();
174
+ this.socketsByInstanceId.clear();
88
175
  const wsServer = this.wsServer;
89
176
  this.wsServer = null;
90
177
  await new Promise((resolve, reject) => {
@@ -110,6 +197,9 @@ class FirefoxAttachServer {
110
197
  if (existing.instanceId !== input.instanceId) {
111
198
  throw new Error("Recording is already active in another browser instance.");
112
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
+ }
113
203
  return existing;
114
204
  }
115
205
  const session = await this.sessionStore.getSession(input.sessionId);
@@ -118,7 +208,7 @@ class FirefoxAttachServer {
118
208
  sessionId: input.sessionId,
119
209
  label: this.config.project.sessionLabel?.trim() || input.sessionId,
120
210
  cwd: process.cwd(),
121
- updatedAt: new Date().toISOString(),
211
+ updatedAt: (0, localTimestamp_1.formatLocalTimestamp)(new Date()),
122
212
  };
123
213
  if (!resolvedSession.cwd?.trim()) {
124
214
  throw new Error("Workspace cwd is not registered for this console.");
@@ -170,19 +260,22 @@ class FirefoxAttachServer {
170
260
  if (!existing) {
171
261
  return null;
172
262
  }
263
+ const socketState = this.socketsByInstanceId.get(existing.instanceId);
173
264
  const activeState = this.activeRecordingsById.get(existing.recordingId);
174
- if (activeState) {
265
+ if (socketState) {
175
266
  try {
176
267
  await this.invokeRecordingControl({
177
- instanceId: activeState.record.instanceId,
178
- tabId: activeState.record.tabId,
179
- recordingId: activeState.record.recordingId,
268
+ instanceId: existing.instanceId,
269
+ tabId: existing.tabId,
270
+ recordingId: existing.recordingId,
180
271
  mode: "stop",
181
272
  });
182
273
  }
183
274
  catch {
184
- // Local writer still finalizes the bundle.
275
+ // Local writer or persisted state still finalizes the bundle.
185
276
  }
277
+ }
278
+ if (activeState) {
186
279
  await this.recordingWriter.appendEvent(activeState, {
187
280
  kind: "session_stopped",
188
281
  status: "stopped",
@@ -193,11 +286,17 @@ class FirefoxAttachServer {
193
286
  return activeState.record;
194
287
  }
195
288
  if (existing.status !== "stopped") {
196
- const stopped = {
197
- ...existing,
198
- status: "stopped",
199
- stoppedAt: new Date().toISOString(),
200
- };
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
+ };
201
300
  await this.maintenanceStore.setBrowserRecording(stopped);
202
301
  await this.broadcastRecordingState(stopped);
203
302
  return stopped;
@@ -216,6 +315,7 @@ class FirefoxAttachServer {
216
315
  reject(new Error(`Attached browser action '${input.action}' timed out for instance '${input.instanceId}'.`));
217
316
  }, this.config.browser.timeoutMs);
218
317
  this.pendingTabActions.set(requestId, {
318
+ instanceId: input.instanceId,
219
319
  resolve,
220
320
  reject,
221
321
  timer,
@@ -241,19 +341,39 @@ class FirefoxAttachServer {
241
341
  socket.send(JSON.stringify(payload));
242
342
  }
243
343
  async handleMessage(state, payload) {
344
+ if (state.protocolFailed) {
345
+ return;
346
+ }
347
+ this.enforceRateLimit(state);
348
+ state.lastSeenAt = Date.now();
244
349
  const raw = typeof payload === "string"
245
350
  ? payload
246
351
  : Buffer.isBuffer(payload)
247
352
  ? payload.toString("utf8")
248
- : String(payload ?? "");
249
- let message;
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;
250
359
  try {
251
- message = JSON.parse(raw);
360
+ decoded = JSON.parse(raw);
252
361
  }
253
362
  catch {
254
- this.logger.debug("Firefox attach message ignored because it is not valid JSON");
255
- return;
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");
256
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;
257
377
  switch (message.type) {
258
378
  case "hello":
259
379
  await this.handleHello(state, message);
@@ -293,10 +413,14 @@ class FirefoxAttachServer {
293
413
  }
294
414
  return;
295
415
  case "tab_action_result":
296
- this.resolvePendingTabAction(message);
416
+ if (connectedInstanceId) {
417
+ this.resolvePendingTabAction(connectedInstanceId, message);
418
+ }
297
419
  return;
298
420
  case "recording_control_result":
299
- this.resolvePendingRecordingControl(message);
421
+ if (connectedInstanceId) {
422
+ this.resolvePendingRecordingControl(connectedInstanceId, message);
423
+ }
300
424
  return;
301
425
  case "recording_event":
302
426
  if (state.instanceId) {
@@ -315,10 +439,18 @@ class FirefoxAttachServer {
315
439
  }
316
440
  }
317
441
  async handleHello(state, message) {
442
+ const previous = this.socketsByInstanceId.get(message.instance_id);
318
443
  state.instanceId = message.instance_id;
319
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
+ }
320
451
  const instance = this.registry.setConnected({
321
452
  instanceId: message.instance_id,
453
+ browser: message.browser,
322
454
  extensionVersion: message.extension_version,
323
455
  ...(message.profile_name ? { profileName: message.profile_name } : {}),
324
456
  capabilities: ["tabs", "active_tab", "recording"],
@@ -360,7 +492,7 @@ class FirefoxAttachServer {
360
492
  backend: "firefox-attached",
361
493
  instanceId,
362
494
  tabId: tab.tab_id,
363
- attachedAt: new Date().toISOString(),
495
+ attachedAt: (0, localTimestamp_1.formatLocalTimestamp)(new Date()),
364
496
  ...(tab.title ? { title: tab.title } : {}),
365
497
  ...(tab.url ? { url: tab.url } : {}),
366
498
  });
@@ -372,18 +504,18 @@ class FirefoxAttachServer {
372
504
  url: tab.url,
373
505
  });
374
506
  }
375
- resolvePendingTabAction(message) {
507
+ resolvePendingTabAction(instanceId, message) {
376
508
  const pending = this.pendingTabActions.get(message.request_id);
377
- if (!pending) {
509
+ if (!pending || pending.instanceId !== instanceId) {
378
510
  return;
379
511
  }
380
512
  clearTimeout(pending.timer);
381
513
  this.pendingTabActions.delete(message.request_id);
382
514
  pending.resolve(message);
383
515
  }
384
- resolvePendingRecordingControl(message) {
516
+ resolvePendingRecordingControl(instanceId, message) {
385
517
  const pending = this.pendingRecordingControls.get(message.request_id);
386
- if (!pending) {
518
+ if (!pending || pending.instanceId !== instanceId) {
387
519
  return;
388
520
  }
389
521
  clearTimeout(pending.timer);
@@ -402,6 +534,7 @@ class FirefoxAttachServer {
402
534
  reject(new Error(`Attached browser recording '${input.mode}' timed out for instance '${input.instanceId}'.`));
403
535
  }, this.config.browser.timeoutMs);
404
536
  this.pendingRecordingControls.set(requestId, {
537
+ instanceId: input.instanceId,
405
538
  resolve,
406
539
  reject,
407
540
  timer,
@@ -468,7 +601,51 @@ class FirefoxAttachServer {
468
601
  async broadcastRecordingState(record) {
469
602
  const payload = this.buildRecordingStateMessage(record);
470
603
  for (const state of this.sockets) {
471
- this.sendJson(state.socket, payload);
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");
472
649
  }
473
650
  }
474
651
  mapManualRecordingResult(record) {