@juspay/neurolink 13.8.17 → 13.8.18

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 (192) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/README.md +1 -1
  3. package/dist/action/actionExecutor.d.ts +33 -3
  4. package/dist/action/actionExecutor.js +71 -15
  5. package/dist/action/actionInputs.js +4 -1
  6. package/dist/action/cliVersion.d.ts +25 -0
  7. package/dist/action/cliVersion.js +26 -0
  8. package/dist/action/index.d.ts +2 -1
  9. package/dist/action/index.js +2 -1
  10. package/dist/adapters/video/directorPipeline.js +46 -36
  11. package/dist/adapters/video/ffmpegAdapter.d.ts +17 -0
  12. package/dist/adapters/video/ffmpegAdapter.js +32 -5
  13. package/dist/adapters/video/frameExtractor.js +1 -1
  14. package/dist/adapters/video/videoMerger.js +5 -2
  15. package/dist/agent/arithmeticEvaluator.d.ts +32 -0
  16. package/dist/agent/arithmeticEvaluator.js +349 -0
  17. package/dist/agent/directTools.d.ts +6 -18
  18. package/dist/agent/directTools.js +17 -56
  19. package/dist/artifacts/artifactStore.d.ts +22 -1
  20. package/dist/artifacts/artifactStore.js +66 -6
  21. package/dist/auth/anthropicOAuth.d.ts +26 -1
  22. package/dist/auth/anthropicOAuth.js +81 -5
  23. package/dist/auth/secureFiles.d.ts +37 -0
  24. package/dist/auth/secureFiles.js +95 -0
  25. package/dist/auth/tokenStore.d.ts +43 -6
  26. package/dist/auth/tokenStore.js +260 -79
  27. package/dist/avatar/providers/DIDAvatar.js +49 -45
  28. package/dist/avatar/providers/ReplicateAvatar.js +33 -40
  29. package/dist/browser/neurolink.min.js +485 -558
  30. package/dist/cli/commands/auth.js +112 -70
  31. package/dist/cli/commands/proxy.d.ts +4 -0
  32. package/dist/cli/commands/proxy.js +128 -13
  33. package/dist/cli/commands/serve.d.ts +10 -1
  34. package/dist/cli/commands/serve.js +98 -17
  35. package/dist/cli/commands/server.js +17 -5
  36. package/dist/cli/index.js +146 -5
  37. package/dist/cli/loop/optionsSchema.d.ts +1 -1
  38. package/dist/cli/utils/envManager.js +17 -3
  39. package/dist/cli/utils/serverUtils.d.ts +39 -3
  40. package/dist/cli/utils/serverUtils.js +63 -6
  41. package/dist/core/baseProvider.js +7 -1
  42. package/dist/core/modules/MessageBuilder.js +5 -0
  43. package/dist/core/modules/ToolsManager.d.ts +46 -1
  44. package/dist/core/modules/ToolsManager.js +190 -43
  45. package/dist/files/fileReferenceRegistry.d.ts +2 -23
  46. package/dist/files/fileReferenceRegistry.js +55 -5
  47. package/dist/hitl/hitlManager.js +1 -0
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +6 -1
  50. package/dist/mcp/auth/tokenStorage.d.ts +7 -0
  51. package/dist/mcp/auth/tokenStorage.js +23 -4
  52. package/dist/mcp/externalServerManager.d.ts +13 -3
  53. package/dist/mcp/externalServerManager.js +154 -136
  54. package/dist/mcp/index.d.ts +2 -0
  55. package/dist/mcp/index.js +3 -0
  56. package/dist/mcp/mcpClientFactory.d.ts +14 -0
  57. package/dist/mcp/mcpClientFactory.js +70 -7
  58. package/dist/mcp/projectConfigTrust.d.ts +72 -0
  59. package/dist/mcp/projectConfigTrust.js +237 -0
  60. package/dist/mcp/transportUrlPolicy.d.ts +39 -0
  61. package/dist/mcp/transportUrlPolicy.js +172 -0
  62. package/dist/music/providers/BeatovenMusic.js +35 -46
  63. package/dist/music/providers/ReplicateMusic.js +33 -40
  64. package/dist/neurolink.d.ts +16 -1
  65. package/dist/neurolink.js +64 -11
  66. package/dist/processors/archive/ArchiveProcessor.js +15 -1
  67. package/dist/processors/archive/zipEntryIndex.d.ts +22 -0
  68. package/dist/processors/archive/zipEntryIndex.js +33 -0
  69. package/dist/processors/base/BaseFileProcessor.d.ts +9 -21
  70. package/dist/processors/base/BaseFileProcessor.js +40 -111
  71. package/dist/processors/data/YamlProcessor.d.ts +13 -1
  72. package/dist/processors/data/YamlProcessor.js +93 -2
  73. package/dist/processors/document/OpenDocumentProcessor.js +7 -2
  74. package/dist/processors/document/PptxProcessor.js +12 -7
  75. package/dist/processors/media/VideoProcessor.js +93 -15
  76. package/dist/proxy/inboundAuth.d.ts +89 -0
  77. package/dist/proxy/inboundAuth.js +219 -0
  78. package/dist/proxy/tokenRefresh.d.ts +6 -0
  79. package/dist/proxy/tokenRefresh.js +13 -1
  80. package/dist/proxy/updateState.js +4 -4
  81. package/dist/rag/document/loaders.d.ts +0 -6
  82. package/dist/rag/document/loaders.js +16 -10
  83. package/dist/rag/ragIntegration.d.ts +2 -2
  84. package/dist/rag/ragIntegration.js +35 -10
  85. package/dist/rag/retrieval/vectorQueryTool.d.ts +7 -0
  86. package/dist/rag/retrieval/vectorQueryTool.js +54 -17
  87. package/dist/server/abstract/baseServerAdapter.d.ts +43 -0
  88. package/dist/server/abstract/baseServerAdapter.js +119 -6
  89. package/dist/server/adapters/expressAdapter.d.ts +3 -0
  90. package/dist/server/adapters/expressAdapter.js +78 -6
  91. package/dist/server/adapters/fastifyAdapter.d.ts +3 -0
  92. package/dist/server/adapters/fastifyAdapter.js +165 -70
  93. package/dist/server/adapters/honoAdapter.d.ts +9 -1
  94. package/dist/server/adapters/honoAdapter.js +99 -21
  95. package/dist/server/adapters/koaAdapter.d.ts +1 -0
  96. package/dist/server/adapters/koaAdapter.js +153 -74
  97. package/dist/server/index.d.ts +2 -0
  98. package/dist/server/index.js +4 -0
  99. package/dist/server/middleware/auth.js +6 -2
  100. package/dist/server/middleware/cache.js +6 -1
  101. package/dist/server/middleware/rateLimit.js +29 -4
  102. package/dist/server/routes/agentRoutes.js +24 -11
  103. package/dist/server/routes/claudeProxyRoutes.d.ts +1 -0
  104. package/dist/server/routes/claudeProxyRoutes.js +273 -84
  105. package/dist/server/routes/codexProxyRoutes.d.ts +2 -2
  106. package/dist/server/routes/codexProxyRoutes.js +125 -40
  107. package/dist/server/routes/index.d.ts +4 -0
  108. package/dist/server/routes/index.js +35 -4
  109. package/dist/server/routes/mcpRoutes.d.ts +6 -2
  110. package/dist/server/routes/mcpRoutes.js +21 -7
  111. package/dist/server/routes/memoryRoutes.js +7 -6
  112. package/dist/server/routes/toolRoutes.d.ts +12 -2
  113. package/dist/server/routes/toolRoutes.js +33 -3
  114. package/dist/server/routes/transcribeRoutes.js +60 -13
  115. package/dist/server/utils/clientAddress.d.ts +23 -0
  116. package/dist/server/utils/clientAddress.js +65 -0
  117. package/dist/server/utils/clientError.d.ts +35 -0
  118. package/dist/server/utils/clientError.js +129 -0
  119. package/dist/server/utils/pathMatch.d.ts +21 -0
  120. package/dist/server/utils/pathMatch.js +41 -0
  121. package/dist/server/utils/validation.d.ts +9 -0
  122. package/dist/server/utils/validation.js +9 -0
  123. package/dist/server/websocket/WebSocketHandler.js +22 -3
  124. package/dist/server/websocket/frameQueue.d.ts +19 -0
  125. package/dist/server/websocket/frameQueue.js +41 -3
  126. package/dist/server/websocket/transcribeWebSocket.d.ts +8 -1
  127. package/dist/server/websocket/transcribeWebSocket.js +223 -34
  128. package/dist/skills/skillStores.js +5 -0
  129. package/dist/tasks/store/fileTaskStore.d.ts +2 -0
  130. package/dist/tasks/store/fileTaskStore.js +11 -4
  131. package/dist/types/attachments.d.ts +117 -0
  132. package/dist/types/attachments.js +15 -0
  133. package/dist/types/auth.d.ts +6 -0
  134. package/dist/types/cli.d.ts +4 -0
  135. package/dist/types/config.d.ts +56 -0
  136. package/dist/types/file.d.ts +8 -0
  137. package/dist/types/hitl.d.ts +8 -0
  138. package/dist/types/index.d.ts +1 -0
  139. package/dist/types/index.js +1 -0
  140. package/dist/types/mcp.d.ts +98 -0
  141. package/dist/types/multimodal.js +0 -41
  142. package/dist/types/processor.d.ts +14 -1
  143. package/dist/types/proxy.d.ts +62 -3
  144. package/dist/types/rag.d.ts +9 -0
  145. package/dist/types/safeFetch.d.ts +40 -0
  146. package/dist/types/server.d.ts +206 -10
  147. package/dist/types/tools.d.ts +57 -0
  148. package/dist/types/transcribe.d.ts +6 -0
  149. package/dist/utils/attachmentPolicy.d.ts +89 -0
  150. package/dist/utils/attachmentPolicy.js +327 -0
  151. package/dist/utils/dotenvBootstrap.d.ts +48 -19
  152. package/dist/utils/dotenvBootstrap.js +172 -31
  153. package/dist/utils/fileDetector.js +115 -65
  154. package/dist/utils/json/coerce.js +11 -1
  155. package/dist/utils/logSanitize.d.ts +5 -0
  156. package/dist/utils/logSanitize.js +8 -0
  157. package/dist/utils/logger.js +73 -1
  158. package/dist/utils/messageBuilder.d.ts +2 -2
  159. package/dist/utils/messageBuilder.js +118 -105
  160. package/dist/utils/multimodalOptionsBuilder.d.ts +1 -0
  161. package/dist/utils/multimodalOptionsBuilder.js +2 -0
  162. package/dist/utils/redis.d.ts +12 -0
  163. package/dist/utils/redis.js +72 -0
  164. package/dist/utils/safeFetch.d.ts +22 -1
  165. package/dist/utils/safeFetch.js +132 -38
  166. package/dist/utils/safeStoreId.d.ts +19 -0
  167. package/dist/utils/safeStoreId.js +29 -0
  168. package/dist/utils/schemaConversion.d.ts +30 -3
  169. package/dist/utils/schemaConversion.js +457 -84
  170. package/dist/utils/sizeGuard.js +2 -2
  171. package/dist/utils/ssrfGuard.d.ts +3 -3
  172. package/dist/utils/ssrfGuard.js +71 -39
  173. package/dist/voice/eval/sttEvalStore.d.ts +26 -3
  174. package/dist/voice/eval/sttEvalStore.js +77 -12
  175. package/dist/voice/livekit/agentDefinition.d.ts +18 -0
  176. package/dist/voice/livekit/agentDefinition.js +22 -0
  177. package/dist/voice/livekit/realtimeMcpTools.d.ts +1 -1
  178. package/dist/voice/livekit/realtimeMcpTools.js +8 -7
  179. package/dist/voice/livekit/realtimeVoiceAgent.d.ts +2 -4
  180. package/dist/voice/livekit/realtimeVoiceAgent.js +18 -7
  181. package/dist/voice/livekit/voiceAgent.d.ts +2 -2
  182. package/dist/voice/livekit/voiceAgent.js +2 -1
  183. package/dist/voice/livekit/xaiSessionWatch.d.ts +0 -5
  184. package/dist/voice/livekit/xaiSessionWatch.js +30 -0
  185. package/dist/voice/providers/GeminiLive.js +21 -6
  186. package/dist/voice/providers/GoogleSTT.js +8 -5
  187. package/dist/voice/transcribe.d.ts +2 -2
  188. package/dist/voice/transcribe.js +34 -18
  189. package/dist/voice/whistle/audio.js +11 -5
  190. package/docs-site/static/search-index.json +71 -49
  191. package/package.json +171 -156
  192. package/scripts/observability/import-openobserve-dashboard.mjs +11 -3
@@ -24,7 +24,14 @@
24
24
  * to change them. Listeners receive every event of the speakers they follow,
25
25
  * tagged with `speaker`, and only the `output` events of their languages; a
26
26
  * session renders each utterance once into the union of its listeners'
27
- * languages. A session lives while a speaker or a listener is connected.
27
+ * languages. A session lives while a speaker or a listener is connected. The
28
+ * speaker that opens a session may add `&secret=<value>`; every later speaker
29
+ * and listener must then present the same secret, so a session id alone does
30
+ * not admit a stranger (`requireSessionSecret` makes the secret mandatory).
31
+ *
32
+ * Limits: sockets are capped in all and per remote address (503 before the
33
+ * handshake), a socket that sends faster than the engine takes is paused at a
34
+ * high-water mark, and queued audio is budgeted across the process.
28
35
  *
29
36
  * OpenAI Realtime transcription (default path `/v1/realtime`,
30
37
  * `?intent=transcription`): the same pipeline behind OpenAI's protocol, for
@@ -24,7 +24,14 @@
24
24
  * to change them. Listeners receive every event of the speakers they follow,
25
25
  * tagged with `speaker`, and only the `output` events of their languages; a
26
26
  * session renders each utterance once into the union of its listeners'
27
- * languages. A session lives while a speaker or a listener is connected.
27
+ * languages. A session lives while a speaker or a listener is connected. The
28
+ * speaker that opens a session may add `&secret=<value>`; every later speaker
29
+ * and listener must then present the same secret, so a session id alone does
30
+ * not admit a stranger (`requireSessionSecret` makes the secret mandatory).
31
+ *
32
+ * Limits: sockets are capped in all and per remote address (503 before the
33
+ * handshake), a socket that sends faster than the engine takes is paused at a
34
+ * high-water mark, and queued audio is budgeted across the process.
28
35
  *
29
36
  * OpenAI Realtime transcription (default path `/v1/realtime`,
30
37
  * `?intent=transcription`): the same pipeline behind OpenAI's protocol, for
@@ -40,10 +47,11 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
40
47
  import { join } from "node:path";
41
48
  import { logger } from "../../utils/logger.js";
42
49
  import { timingSafeEqualString } from "../voice/tokenCompare.js";
50
+ import { canonicalAddress, isLoopbackAddress } from "../utils/clientAddress.js";
43
51
  import { TranscribeOutputControlSchema, TranscribeStreamConfigSchema, TranscribeSubscribeSchema, } from "../utils/validation.js";
44
52
  import { fileModeStreamOptions } from "../../voice/transcribe.js";
45
53
  import { TranscriptionSession } from "../../voice/session/transcriptionSession.js";
46
- import { FrameQueue } from "./frameQueue.js";
54
+ import { FrameQueue, totalQueuedFrameBytes } from "./frameQueue.js";
47
55
  import { resolveRealtimeProfiles, serveRealtimeTranscription, } from "./realtimeTranscription.js";
48
56
  const DEFAULT_PATH = "/v1/audio/transcriptions/stream";
49
57
  const DEFAULT_REALTIME_PATH = "/v1/realtime";
@@ -51,6 +59,21 @@ const DEFAULT_REALTIME_PATH = "/v1/realtime";
51
59
  const MAX_QUEUED_BYTES = 16 * 1024 * 1024;
52
60
  /** In file mode the whole recording may be queued at once: 512 MB is about 4.5 hours of 16 kHz PCM. */
53
61
  const MAX_QUEUED_BYTES_FILE = 512 * 1024 * 1024;
62
+ /** Queued audio per socket above which the socket is paused until the engine catches up. */
63
+ const DEFAULT_HIGH_WATER_MARK = 8 * 1024 * 1024;
64
+ /** Queued audio across every socket of the process. */
65
+ const DEFAULT_MAX_TOTAL_QUEUED_BYTES = 512 * 1024 * 1024;
66
+ const DEFAULT_MAX_CONNECTIONS = 100;
67
+ const DEFAULT_MAX_CONNECTIONS_PER_ADDRESS = 16;
68
+ /** Subprotocol carrying the server's API key for a browser, next to `neurolink`. */
69
+ const SUBPROTOCOL_KEY_PREFIX = "neurolink-api-key.";
70
+ /** OpenAI's browser SDK sends the key as this subprotocol; honoured on the realtime path. */
71
+ const OPENAI_SUBPROTOCOL_KEY_PREFIX = "openai-insecure-api-key.";
72
+ /** Answer an upgrade before the handshake, with a status the client can read. */
73
+ function refuseUpgrade(socket, status, reason) {
74
+ socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`);
75
+ socket.destroy();
76
+ }
54
77
  /**
55
78
  * `true` when the upgrade's `Origin` is on the allow-list, the list allows
56
79
  * any origin, or the request carries no `Origin` (not a browser).
@@ -66,8 +89,26 @@ function isOriginAllowed(req, allowed) {
66
89
  const wanted = origin.trim().toLowerCase();
67
90
  return allowed.some((o) => o.trim().toLowerCase() === wanted);
68
91
  }
69
- function isAuthorized(req, token, allowSubprotocolKey = false) {
70
- const tokens = (typeof token === "string" ? [token] : (token ?? [])).filter((t) => t.length > 0);
92
+ /** The configured token(s), empty when the stream is open. */
93
+ function configuredTokens(token) {
94
+ return (typeof token === "string" ? [token] : (token ?? [])).filter((t) => t.length > 0);
95
+ }
96
+ /** The subprotocols the client offered, trimmed. */
97
+ function offeredProtocols(req) {
98
+ return String(req.headers["sec-websocket-protocol"] ?? "")
99
+ .split(",")
100
+ .map((p) => p.trim())
101
+ .filter((p) => p.length > 0);
102
+ }
103
+ /**
104
+ * Whether the upgrade carries one of `tokens`. A browser cannot set headers,
105
+ * so besides `Authorization: Bearer` the key may ride in the subprotocol list
106
+ * as `neurolink-api-key.<key>` (and, on the realtime path, as OpenAI's
107
+ * `openai-insecure-api-key.<key>`). `?token=` in the URL is accepted only when
108
+ * the operator allows it: a URL is written to access logs and `Referer`
109
+ * headers, so a key there leaks.
110
+ */
111
+ function isAuthorized(req, tokens, options) {
71
112
  if (tokens.length === 0) {
72
113
  return true;
73
114
  }
@@ -76,26 +117,37 @@ function isAuthorized(req, token, allowSubprotocolKey = false) {
76
117
  ? header.slice(7)
77
118
  : undefined;
78
119
  let urlToken;
120
+ if (options.allowQueryToken) {
121
+ try {
122
+ urlToken =
123
+ new URL(req.url ?? "/", "http://localhost").searchParams.get("token") ??
124
+ undefined;
125
+ }
126
+ catch {
127
+ urlToken = undefined;
128
+ }
129
+ }
130
+ const protocols = offeredProtocols(req);
131
+ const protocolToken = protocols
132
+ .find((p) => p.startsWith(SUBPROTOCOL_KEY_PREFIX))
133
+ ?.slice(SUBPROTOCOL_KEY_PREFIX.length) ??
134
+ (options.allowOpenAISubprotocolKey
135
+ ? protocols
136
+ .find((p) => p.startsWith(OPENAI_SUBPROTOCOL_KEY_PREFIX))
137
+ ?.slice(OPENAI_SUBPROTOCOL_KEY_PREFIX.length)
138
+ : undefined);
139
+ const provided = headerToken ?? protocolToken ?? urlToken;
140
+ return (provided !== undefined &&
141
+ tokens.some((t) => timingSafeEqualString(provided, t)));
142
+ }
143
+ /** `true` when the upgrade's URL carries a `?token=`, whether or not it is honoured. */
144
+ function carriesQueryToken(req) {
79
145
  try {
80
- urlToken =
81
- new URL(req.url ?? "/", "http://localhost").searchParams.get("token") ??
82
- undefined;
146
+ return new URL(req.url ?? "/", "http://localhost").searchParams.has("token");
83
147
  }
84
148
  catch {
85
- urlToken = undefined;
149
+ return false;
86
150
  }
87
- // OpenAI's browser SDK cannot set headers, so it sends the key as the
88
- // subprotocol "openai-insecure-api-key.<key>"; honoured on the realtime path.
89
- const protocolToken = allowSubprotocolKey
90
- ? String(req.headers["sec-websocket-protocol"] ?? "")
91
- .split(",")
92
- .map((p) => p.trim())
93
- .find((p) => p.startsWith("openai-insecure-api-key."))
94
- ?.slice("openai-insecure-api-key.".length)
95
- : undefined;
96
- const provided = headerToken ?? urlToken ?? protocolToken;
97
- return (provided !== undefined &&
98
- tokens.some((t) => timingSafeEqualString(provided, t)));
99
151
  }
100
152
  function pathOf(req) {
101
153
  try {
@@ -150,20 +202,51 @@ export async function attachTranscribeWebSocket(server, neurolink, options = {})
150
202
  ? undefined
151
203
  : (options.realtimePath ?? DEFAULT_REALTIME_PATH);
152
204
  const realtimeProfiles = resolveRealtimeProfiles(options.realtimeProfiles);
205
+ const tokens = configuredTokens(options.authToken);
206
+ const allowQueryToken = options.allowQueryToken === true;
207
+ const maxConnections = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
208
+ const maxPerAddress = options.maxConnectionsPerAddress ?? DEFAULT_MAX_CONNECTIONS_PER_ADDRESS;
209
+ const highWaterMark = options.highWaterMark ?? DEFAULT_HIGH_WATER_MARK;
210
+ const maxTotalQueued = options.maxTotalQueuedBytes ?? DEFAULT_MAX_TOTAL_QUEUED_BYTES;
153
211
  const wss = new WebSocketServer({
154
212
  noServer: true,
155
213
  maxPayload: options.maxPayload ?? 1_048_576,
156
- // OpenAI's browser SDK offers "realtime" alongside its key subprotocol.
157
- handleProtocols: (protocols) => protocols.has("realtime") ? "realtime" : false,
214
+ // OpenAI's browser SDK offers "realtime" alongside its key subprotocol;
215
+ // a browser on the stream path offers "neurolink" alongside
216
+ // "neurolink-api-key.<key>". The key-bearing protocol is never selected.
217
+ handleProtocols: (protocols) => protocols.has("realtime")
218
+ ? "realtime"
219
+ : protocols.has("neurolink")
220
+ ? "neurolink"
221
+ : false,
158
222
  });
159
- // Sessions by id, alive while a speaker or a listener holds them.
223
+ // Sessions by id, alive while a speaker or a listener holds them. A session
224
+ // whose first speaker set `?secret=` admits only joins that present it.
160
225
  const sessions = new Map();
161
- const acquire = (id) => {
162
- const entry = sessions.get(id) ?? {
226
+ /**
227
+ * Join `id` with `secret`, or the reason a join is refused: a session with a
228
+ * secret and a join without the same one, or (with `requireSessionSecret`) a
229
+ * new session that sets none.
230
+ */
231
+ const admit = (id, secret) => {
232
+ const existing = sessions.get(id);
233
+ if (existing) {
234
+ if (existing.secret !== undefined &&
235
+ (secret === undefined ||
236
+ !timingSafeEqualString(secret, existing.secret))) {
237
+ return "secret-mismatch";
238
+ }
239
+ existing.refs++;
240
+ return existing.session;
241
+ }
242
+ if (options.requireSessionSecret && !secret) {
243
+ return "secret-required";
244
+ }
245
+ const entry = {
163
246
  session: new TranscriptionSession(neurolink, { id }),
164
- refs: 0,
247
+ refs: 1,
248
+ secret,
165
249
  };
166
- entry.refs++;
167
250
  sessions.set(id, entry);
168
251
  return entry.session;
169
252
  };
@@ -178,6 +261,20 @@ export async function attachTranscribeWebSocket(server, neurolink, options = {})
178
261
  sessions.delete(id);
179
262
  }
180
263
  };
264
+ const sessionRefusal = (verdict) => verdict === "secret-mismatch"
265
+ ? "This session requires its secret (?secret=)"
266
+ : "A new session must set ?secret=<value> on this server";
267
+ // Open sockets, in all and per remote address, counted from the upgrade so
268
+ // a flood of handshakes is refused before any engine is started. The raw
269
+ // socket's close releases the count whether or not the handshake completed.
270
+ let openConnections = 0;
271
+ const perAddress = new Map();
272
+ const serverIsLoopback = () => {
273
+ const address = server.address();
274
+ return (typeof address === "object" &&
275
+ address !== null &&
276
+ isLoopbackAddress(address.address));
277
+ };
181
278
  const onUpgrade = (req, socket, head) => {
182
279
  const requested = pathOf(req);
183
280
  if (requested !== path &&
@@ -186,15 +283,46 @@ export async function attachTranscribeWebSocket(server, neurolink, options = {})
186
283
  return; // Someone else's upgrade.
187
284
  }
188
285
  if (!isOriginAllowed(req, options.allowedOrigins)) {
189
- socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
190
- socket.destroy();
286
+ refuseUpgrade(socket, 403, "Forbidden");
191
287
  return;
192
288
  }
193
- if (!isAuthorized(req, options.authToken, requested === realtimePath)) {
194
- socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
195
- socket.destroy();
289
+ if (!isAuthorized(req, tokens, {
290
+ allowQueryToken,
291
+ allowOpenAISubprotocolKey: requested === realtimePath,
292
+ })) {
293
+ if (tokens.length > 0 && !allowQueryToken && carriesQueryToken(req)) {
294
+ logger.warn("[TranscribeWebSocket] refused an upgrade that carried the key in ?token= (allowQueryToken is off)");
295
+ }
296
+ refuseUpgrade(socket, 401, "Unauthorized");
196
297
  return;
197
298
  }
299
+ const peer = canonicalAddress(req.socket.remoteAddress ?? "") ?? "unknown";
300
+ const fromPeer = perAddress.get(peer) ?? 0;
301
+ if (openConnections >= maxConnections || fromPeer >= maxPerAddress) {
302
+ logger.warn("[TranscribeWebSocket] refused an upgrade: connection cap", {
303
+ open: openConnections,
304
+ fromPeer,
305
+ });
306
+ refuseUpgrade(socket, 503, "Service Unavailable");
307
+ return;
308
+ }
309
+ openConnections++;
310
+ perAddress.set(peer, fromPeer + 1);
311
+ let released = false;
312
+ socket.once("close", () => {
313
+ if (released) {
314
+ return;
315
+ }
316
+ released = true;
317
+ openConnections--;
318
+ const left = (perAddress.get(peer) ?? 1) - 1;
319
+ if (left <= 0) {
320
+ perAddress.delete(peer);
321
+ }
322
+ else {
323
+ perAddress.set(peer, left);
324
+ }
325
+ });
198
326
  wss.handleUpgrade(req, socket, head, (client) => {
199
327
  wss.emit("connection", client, req);
200
328
  });
@@ -212,7 +340,17 @@ export async function attachTranscribeWebSocket(server, neurolink, options = {})
212
340
  client.close(1008, "session required");
213
341
  return;
214
342
  }
215
- const session = acquire(id);
343
+ const admitted = admit(id, queryParam(req, "secret"));
344
+ if (typeof admitted === "string") {
345
+ client.send(JSON.stringify({
346
+ type: "error",
347
+ message: sessionRefusal(admitted),
348
+ recoverable: false,
349
+ }));
350
+ client.close(1008, "session refused");
351
+ return;
352
+ }
353
+ const session = admitted;
216
354
  let subscription;
217
355
  const deliver = (event) => {
218
356
  if (client.readyState === WebSocket.OPEN) {
@@ -269,6 +407,20 @@ export async function attachTranscribeWebSocket(server, neurolink, options = {})
269
407
  const queue = new FrameQueue();
270
408
  let started = false;
271
409
  let fileMode = false;
410
+ // Backpressure: above the high-water mark the socket stops reading until
411
+ // the engine has taken enough of the backlog (half the mark), so a fast
412
+ // sender is slowed by TCP instead of buffered without bound.
413
+ let paused = false;
414
+ queue.setLevelListener((level) => {
415
+ if (!paused && level > highWaterMark) {
416
+ paused = true;
417
+ client.pause();
418
+ }
419
+ else if (paused && level <= highWaterMark / 2) {
420
+ paused = false;
421
+ client.resume();
422
+ }
423
+ });
272
424
  // Debugging: `NEUROLINK_STT_RECORD_DIR` keeps every session's config,
273
425
  // audio (PCM16LE) and events on disk, so a live session can be replayed
274
426
  // and compared against another engine. Off unless set.
@@ -291,6 +443,7 @@ export async function attachTranscribeWebSocket(server, neurolink, options = {})
291
443
  // Output languages, changeable mid-stream by control frames.
292
444
  let liveLanguages = [];
293
445
  const sessionId = subscribePath ? queryParam(req, "session") : undefined;
446
+ const sessionSecret = queryParam(req, "secret");
294
447
  // No name: the session assigns a unique one (speaker-1, speaker-2, …).
295
448
  const speaker = queryParam(req, "speaker") ?? "";
296
449
  const send = (event) => {
@@ -315,7 +468,16 @@ export async function attachTranscribeWebSocket(server, neurolink, options = {})
315
468
  }
316
469
  // A session speaker: events go to the session's listeners and back to
317
470
  // this socket. The session is held until this speaker's audio ends.
318
- const session = acquire(sessionId);
471
+ const admitted = admit(sessionId, sessionSecret);
472
+ if (typeof admitted === "string") {
473
+ const refusal = new Error(sessionRefusal(admitted));
474
+ return {
475
+ [Symbol.asyncIterator]: () => ({
476
+ next: () => Promise.reject(refusal),
477
+ }),
478
+ };
479
+ }
480
+ const session = admitted;
319
481
  const buffered = [];
320
482
  let wake;
321
483
  let finished = false;
@@ -415,12 +577,39 @@ export async function attachTranscribeWebSocket(server, neurolink, options = {})
415
577
  started = true;
416
578
  const { mode, ...rest } = config.data;
417
579
  fileMode = mode === "file";
580
+ // File mode buffers a whole recording (up to 512 MiB) for a
581
+ // caller; on a listener the network can reach, only a caller the
582
+ // server authenticated gets that, unless the operator opts out.
583
+ if (fileMode &&
584
+ tokens.length === 0 &&
585
+ options.allowUnauthenticatedFileMode !== true &&
586
+ !serverIsLoopback()) {
587
+ send({
588
+ type: "error",
589
+ message: 'mode "file" needs authentication on this server; stream live audio instead',
590
+ recoverable: false,
591
+ });
592
+ client.close(1008, "file mode requires authentication");
593
+ return;
594
+ }
418
595
  liveLanguages = rest.output?.languages ?? [];
419
596
  record("config.json", JSON.stringify(config.data, null, 1), false);
420
597
  void run(fileMode ? fileModeStreamOptions(rest) : rest);
421
598
  return;
422
599
  }
423
600
  if (isBinary) {
601
+ // The process-wide budget is checked before the frame is taken:
602
+ // one socket's burst must not take the others down with it.
603
+ if (totalQueuedFrameBytes() + frame.length > maxTotalQueued) {
604
+ send({
605
+ type: "error",
606
+ message: "The server is holding too much audio right now; try again later",
607
+ recoverable: false,
608
+ });
609
+ queue.end();
610
+ client.close(1013, "server busy");
611
+ return;
612
+ }
424
613
  record("audio.pcm", frame);
425
614
  queue.push(frame);
426
615
  if (queue.queuedBytes >
@@ -11,6 +11,7 @@ import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import yaml from "js-yaml";
13
13
  import { logger } from "../utils/logger.js";
14
+ import { assertSafeStoreId } from "../utils/safeStoreId.js";
14
15
  import { toSkillIndexItem } from "./skillMatcher.js";
15
16
  import { S3SkillStore } from "./skillStoreS3.js";
16
17
  import { RedisSkillStore } from "./skillStoreRedis.js";
@@ -220,12 +221,16 @@ export class FileSystemSkillStore {
220
221
  }
221
222
  }
222
223
  async put(skill) {
224
+ // The id becomes a file name under `baseDir`; refuse anything that could
225
+ // leave it (a separator, a traversal segment) before the join.
226
+ assertSafeStoreId(skill.id, "skill id");
223
227
  fs.mkdirSync(this.baseDir, { recursive: true });
224
228
  const filePath = path.join(this.baseDir, `${skill.id}.json`);
225
229
  fs.writeFileSync(filePath, JSON.stringify(normalizeSkill(skill), null, 2), "utf-8");
226
230
  this.invalidate();
227
231
  }
228
232
  async delete(id) {
233
+ assertSafeStoreId(id, "skill id");
229
234
  const filePath = path.join(this.baseDir, `${id}.json`);
230
235
  try {
231
236
  fs.unlinkSync(filePath);
@@ -28,6 +28,8 @@ export declare class FileTaskStore implements TaskStore {
28
28
  }): Promise<Task[]>;
29
29
  update(taskId: string, updates: Partial<Task>): Promise<Task>;
30
30
  delete(taskId: string): Promise<void>;
31
+ /** `{logsPath}/{taskId}.jsonl`, after refusing an id that is not a file name. */
32
+ private runLogPath;
31
33
  appendRun(taskId: string, run: TaskRunResult): Promise<void>;
32
34
  getRuns(taskId: string, options?: {
33
35
  limit?: number;
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
11
11
  import { appendFile, readFile, rename, unlink, writeFile, } from "node:fs/promises";
12
12
  import { dirname, join } from "node:path";
13
13
  import { logger } from "../../utils/logger.js";
14
+ import { assertSafeStoreId } from "../../utils/safeStoreId.js";
14
15
  import { TaskError } from "../errors.js";
15
16
  import { TASK_DEFAULTS, } from "../../types/index.js";
16
17
  export class FileTaskStore {
@@ -91,8 +92,9 @@ export class FileTaskStore {
91
92
  async delete(taskId) {
92
93
  this.tasks.delete(taskId);
93
94
  this.history.delete(taskId);
94
- // Delete run log file
95
- const logPath = join(this.logsPath, `${taskId}.jsonl`);
95
+ // Delete run log file. The id becomes a file name under `logsPath`;
96
+ // nanoid ids always pass, anything that could leave the directory does not.
97
+ const logPath = this.runLogPath(taskId);
96
98
  try {
97
99
  await unlink(logPath);
98
100
  }
@@ -102,14 +104,19 @@ export class FileTaskStore {
102
104
  await this.flush();
103
105
  }
104
106
  // ── Run Logs ──────────────────────────────────────────
107
+ /** `{logsPath}/{taskId}.jsonl`, after refusing an id that is not a file name. */
108
+ runLogPath(taskId) {
109
+ assertSafeStoreId(taskId, "task id");
110
+ return join(this.logsPath, `${taskId}.jsonl`);
111
+ }
105
112
  async appendRun(taskId, run) {
106
- const logPath = join(this.logsPath, `${taskId}.jsonl`);
113
+ const logPath = this.runLogPath(taskId);
107
114
  mkdirSync(dirname(logPath), { recursive: true });
108
115
  await appendFile(logPath, JSON.stringify(run) + "\n", "utf-8");
109
116
  await this.pruneRunLog(logPath);
110
117
  }
111
118
  async getRuns(taskId, options) {
112
- const logPath = join(this.logsPath, `${taskId}.jsonl`);
119
+ const logPath = this.runLogPath(taskId);
113
120
  if (!existsSync(logPath)) {
114
121
  return [];
115
122
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Attachment ingestion policy — the SDK-level counterpart of the server's
3
+ * default-deny path roots and SSRF guard.
4
+ *
5
+ * Every loader that turns a caller-supplied string into bytes (image URLs and
6
+ * paths in `input.images`, entries of `input.files`, `rag.files`,
7
+ * `transcribe({ audio })`, Director Mode segment images, `WebLoader`) reads
8
+ * its limits from one {@link AttachmentPolicy}. The policy is resolved once
9
+ * per `NeuroLink` instance from {@link AttachmentsConfig} and the
10
+ * `NEUROLINK_ATTACHMENT_*` environment variables, then stamped onto each
11
+ * call's options so the loaders several hops down see the same answer.
12
+ *
13
+ * Runtime helpers live in `src/lib/utils/attachmentPolicy.ts`.
14
+ */
15
+ /**
16
+ * `new NeuroLink({ attachments })` — what an instance may read or fetch on a
17
+ * caller's behalf.
18
+ *
19
+ * Every field also has an environment variable for deployments that cannot
20
+ * change constructor arguments (the CLI, containers). An explicit field wins
21
+ * over its variable.
22
+ *
23
+ * | Field | Environment variable | Default |
24
+ * | --- | --- | --- |
25
+ * | `allowedRoots` | `NEUROLINK_ATTACHMENT_ROOTS` (path-list, `:`/`;` separated) | unrestricted (warns once) |
26
+ * | `allowInsecureUrls` | `NEUROLINK_ATTACHMENT_ALLOW_INSECURE_URLS=1` | `false` |
27
+ * | `allowPrivateNetwork` | `NEUROLINK_ATTACHMENT_ALLOW_PRIVATE_NETWORK=1` | `false` |
28
+ * | `followRedirects` | `NEUROLINK_ATTACHMENT_FOLLOW_REDIRECTS=0` | `true` |
29
+ * | `maxBytes` | `NEUROLINK_ATTACHMENT_MAX_BYTES` | each loader's own cap |
30
+ */
31
+ export type AttachmentsConfig = {
32
+ /**
33
+ * Directories a local file path may resolve into. Containment is checked on
34
+ * the real (symlink-resolved) path of both the root and the target, and the
35
+ * file is opened `O_NOFOLLOW` on that real path, so a symlink planted under
36
+ * a root cannot reach outside it. Unset means any readable file — the
37
+ * pre-hardening behaviour — and the SDK warns once per process when it
38
+ * reads a path without roots. A host that forwards strings from untrusted
39
+ * callers must set this.
40
+ */
41
+ allowedRoots?: string[];
42
+ /**
43
+ * Accept `http://` attachment URLs. Default `false`: plain HTTP carries the
44
+ * bytes — and any credentials on the request — in the clear, and is the
45
+ * transport every loopback and link-local target answers on.
46
+ */
47
+ allowInsecureUrls?: boolean;
48
+ /**
49
+ * Fetch URLs whose host is, or resolves to, a loopback, RFC 1918,
50
+ * link-local, CGNAT, multicast or cloud-metadata address. Default `false`.
51
+ * The connection is still pinned to the resolved addresses.
52
+ */
53
+ allowPrivateNetwork?: boolean;
54
+ /**
55
+ * Follow HTTP redirects (up to five hops). Default `true`. Every hop is
56
+ * re-validated against the same URL policy, and caller-supplied headers
57
+ * (`authHeaders`) are never sent to a redirect target.
58
+ */
59
+ followRedirects?: boolean;
60
+ /**
61
+ * Upper bound, in bytes, for any single attachment read or download. A
62
+ * loader's own cap still applies when it is lower.
63
+ */
64
+ maxBytes?: number;
65
+ };
66
+ /**
67
+ * The resolved, immutable form of {@link AttachmentsConfig} that the loaders
68
+ * consume. `allowedRoots` holds absolute paths; `undefined` means unrestricted.
69
+ */
70
+ export type AttachmentPolicy = {
71
+ readonly kind: "neurolink-attachment-policy";
72
+ readonly allowedRoots: readonly string[] | undefined;
73
+ readonly allowInsecureUrls: boolean;
74
+ readonly allowPrivateNetwork: boolean;
75
+ readonly followRedirects: boolean;
76
+ readonly maxBytes: number | undefined;
77
+ };
78
+ /** Options for `downloadAttachment()`. */
79
+ export type AttachmentDownloadOptions = {
80
+ /** Policy to apply; the environment-derived default when omitted. */
81
+ policy?: AttachmentPolicy;
82
+ /** Hard cap on the response body in bytes (the policy's `maxBytes` lowers it). */
83
+ maxBytes: number;
84
+ /** Short label for error messages, e.g. `"image URL"`. */
85
+ label: string;
86
+ /**
87
+ * Headers for the first request only. They are dropped on every redirect
88
+ * hop so a credential meant for one host never reaches another.
89
+ */
90
+ headers?: Record<string, string>;
91
+ /** Transfer timeout in milliseconds. */
92
+ timeoutMs?: number;
93
+ /** Caller cancellation. */
94
+ signal?: AbortSignal;
95
+ /** `HEAD` fetches headers only and returns an empty body. */
96
+ method?: "GET" | "HEAD";
97
+ };
98
+ /** Options for `readAttachmentFile()`. */
99
+ export type AttachmentReadOptions = {
100
+ /** Policy to apply; the environment-derived default when omitted. */
101
+ policy?: AttachmentPolicy;
102
+ /** Hard cap on the file size in bytes (the policy's `maxBytes` lowers it). */
103
+ maxBytes: number;
104
+ /** Short label for error messages, e.g. `"image file"`. */
105
+ label: string;
106
+ };
107
+ /** What `readAttachmentFile()` returns. */
108
+ export type AttachmentReadResult = {
109
+ buffer: Buffer;
110
+ /** The symlink-resolved path that was actually opened. */
111
+ realPath: string;
112
+ };
113
+ /**
114
+ * Machine-readable reasons an attachment was refused. Carried as `code` on the
115
+ * thrown error so callers can map them without parsing messages.
116
+ */
117
+ export type AttachmentRefusalCode = "ATTACHMENT_OUTSIDE_ROOTS" | "ATTACHMENT_NOT_A_FILE" | "ATTACHMENT_TOO_LARGE" | "ATTACHMENT_URL_REFUSED";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Attachment ingestion policy — the SDK-level counterpart of the server's
3
+ * default-deny path roots and SSRF guard.
4
+ *
5
+ * Every loader that turns a caller-supplied string into bytes (image URLs and
6
+ * paths in `input.images`, entries of `input.files`, `rag.files`,
7
+ * `transcribe({ audio })`, Director Mode segment images, `WebLoader`) reads
8
+ * its limits from one {@link AttachmentPolicy}. The policy is resolved once
9
+ * per `NeuroLink` instance from {@link AttachmentsConfig} and the
10
+ * `NEUROLINK_ATTACHMENT_*` environment variables, then stamped onto each
11
+ * call's options so the loaders several hops down see the same answer.
12
+ *
13
+ * Runtime helpers live in `src/lib/utils/attachmentPolicy.ts`.
14
+ */
15
+ export {};
@@ -55,6 +55,12 @@ export type TokenStorageData = {
55
55
  /** Tokens indexed by provider name */
56
56
  providers: Record<string, StoredProviderTokens>;
57
57
  };
58
+ /** Owner record written into the token store's cross-process lock file. */
59
+ export type TokenStoreLockOwner = {
60
+ pid: number;
61
+ token: string;
62
+ acquiredAt: number;
63
+ };
58
64
  /**
59
65
  * Per-provider token storage structure
60
66
  */
@@ -805,6 +805,10 @@ export type ProxyStartArgs = {
805
805
  envFile?: string;
806
806
  passthrough?: boolean;
807
807
  dev?: boolean;
808
+ /** Bearer every inbound caller must present; also `NEUROLINK_PROXY_INBOUND_TOKEN`. */
809
+ inboundToken?: string;
810
+ /** Operator's explicit choice to bind off-loopback with no inbound auth. */
811
+ allowUnauthenticated?: boolean;
808
812
  };
809
813
  /** Arguments accepted by `neurolink proxy status` */
810
814
  export type ProxyStatusArgs = {