@nodetool-ai/websocket 0.8.3 → 0.8.4-a24.1

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 (169) hide show
  1. package/README.md +5 -0
  2. package/dist/.nodetool-build-stamp +1 -1
  3. package/dist/agent/run-node-tool.d.ts.map +1 -1
  4. package/dist/agent/run-node-tool.js +11 -1
  5. package/dist/agent/run-node-tool.js.map +1 -1
  6. package/dist/chat-prediction-forwarder.d.ts +5 -3
  7. package/dist/chat-prediction-forwarder.d.ts.map +1 -1
  8. package/dist/chat-prediction-forwarder.js +6 -4
  9. package/dist/chat-prediction-forwarder.js.map +1 -1
  10. package/dist/e2e-server.js +1 -1
  11. package/dist/e2e-server.js.map +1 -1
  12. package/dist/example-workflows.js +1 -1
  13. package/dist/example-workflows.js.map +1 -1
  14. package/dist/fake-runtime.d.ts +14 -3
  15. package/dist/fake-runtime.d.ts.map +1 -1
  16. package/dist/fake-runtime.js +23 -4
  17. package/dist/fake-runtime.js.map +1 -1
  18. package/dist/headless-job-runner.d.ts.map +1 -1
  19. package/dist/headless-job-runner.js +3 -1
  20. package/dist/headless-job-runner.js.map +1 -1
  21. package/dist/http-api.d.ts +1 -1
  22. package/dist/http-api.d.ts.map +1 -1
  23. package/dist/http-api.js +16 -7
  24. package/dist/http-api.js.map +1 -1
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/job-control.d.ts +8 -0
  30. package/dist/job-control.d.ts.map +1 -1
  31. package/dist/job-control.js +12 -0
  32. package/dist/job-control.js.map +1 -1
  33. package/dist/lib/admin.d.ts.map +1 -1
  34. package/dist/lib/admin.js +1 -2
  35. package/dist/lib/admin.js.map +1 -1
  36. package/dist/lib/asset-export.d.ts +1 -1
  37. package/dist/lib/asset-export.js +1 -1
  38. package/dist/lib/asset-paths.d.ts +1 -1
  39. package/dist/lib/asset-paths.js +1 -1
  40. package/dist/lib/asset-response.d.ts.map +1 -1
  41. package/dist/lib/asset-response.js +12 -6
  42. package/dist/lib/asset-response.js.map +1 -1
  43. package/dist/lib/example-apps.d.ts +3 -0
  44. package/dist/lib/example-apps.d.ts.map +1 -1
  45. package/dist/lib/example-apps.js +3 -2
  46. package/dist/lib/example-apps.js.map +1 -1
  47. package/dist/lib/project-archive-host.d.ts +14 -0
  48. package/dist/lib/project-archive-host.d.ts.map +1 -0
  49. package/dist/lib/project-archive-host.js +24 -0
  50. package/dist/lib/project-archive-host.js.map +1 -0
  51. package/dist/lib/project-archive.d.ts +103 -0
  52. package/dist/lib/project-archive.d.ts.map +1 -0
  53. package/dist/lib/project-archive.js +610 -0
  54. package/dist/lib/project-archive.js.map +1 -0
  55. package/dist/lib/thumbnail.d.ts.map +1 -1
  56. package/dist/lib/thumbnail.js +3 -1
  57. package/dist/lib/thumbnail.js.map +1 -1
  58. package/dist/lib/timeline-bundle.d.ts +60 -1
  59. package/dist/lib/timeline-bundle.d.ts.map +1 -1
  60. package/dist/lib/timeline-bundle.js +7 -5
  61. package/dist/lib/timeline-bundle.js.map +1 -1
  62. package/dist/lib/timeline-premiere-import.d.ts.map +1 -1
  63. package/dist/lib/timeline-premiere-import.js +31 -21
  64. package/dist/lib/timeline-premiere-import.js.map +1 -1
  65. package/dist/lib/zip-stream.d.ts +3 -0
  66. package/dist/lib/zip-stream.d.ts.map +1 -1
  67. package/dist/lib/zip-stream.js +6 -0
  68. package/dist/lib/zip-stream.js.map +1 -1
  69. package/dist/mcp-agent-tools.d.ts +48 -0
  70. package/dist/mcp-agent-tools.d.ts.map +1 -1
  71. package/dist/mcp-agent-tools.js +13 -2
  72. package/dist/mcp-agent-tools.js.map +1 -1
  73. package/dist/model-download-runtime.d.ts.map +1 -1
  74. package/dist/model-download-runtime.js +2 -1
  75. package/dist/model-download-runtime.js.map +1 -1
  76. package/dist/node-registry-setup.d.ts +8 -5
  77. package/dist/node-registry-setup.d.ts.map +1 -1
  78. package/dist/node-registry-setup.js +22 -10
  79. package/dist/node-registry-setup.js.map +1 -1
  80. package/dist/pack-source.d.ts +15 -0
  81. package/dist/pack-source.d.ts.map +1 -0
  82. package/dist/pack-source.js +85 -0
  83. package/dist/pack-source.js.map +1 -0
  84. package/dist/plugins/websocket.d.ts.map +1 -1
  85. package/dist/plugins/websocket.js +24 -3
  86. package/dist/plugins/websocket.js.map +1 -1
  87. package/dist/routes/documents.d.ts.map +1 -1
  88. package/dist/routes/documents.js +8 -1
  89. package/dist/routes/documents.js.map +1 -1
  90. package/dist/routes/games.d.ts.map +1 -1
  91. package/dist/routes/games.js +2 -1
  92. package/dist/routes/games.js.map +1 -1
  93. package/dist/routes/kie-pricing.js +1 -1
  94. package/dist/routes/kie-pricing.js.map +1 -1
  95. package/dist/routes/nodes.d.ts.map +1 -1
  96. package/dist/routes/nodes.js +11 -1
  97. package/dist/routes/nodes.js.map +1 -1
  98. package/dist/routes/project-archive.d.ts +18 -0
  99. package/dist/routes/project-archive.d.ts.map +1 -0
  100. package/dist/routes/project-archive.js +98 -0
  101. package/dist/routes/project-archive.js.map +1 -0
  102. package/dist/screenshot-docs-seed.d.ts.map +1 -1
  103. package/dist/screenshot-docs-seed.js +8 -1
  104. package/dist/screenshot-docs-seed.js.map +1 -1
  105. package/dist/screenshot-server.js +164 -2
  106. package/dist/screenshot-server.js.map +1 -1
  107. package/dist/sdk/sdk-node-type-inventory-service.d.ts +1 -1
  108. package/dist/sdk/sdk-node-type-inventory-service.d.ts.map +1 -1
  109. package/dist/sdk/sdk-node-type-inventory-service.js +4 -4
  110. package/dist/sdk/sdk-node-type-inventory-service.js.map +1 -1
  111. package/dist/sdk/sdk-v1-service.js +1 -1
  112. package/dist/sdk/sdk-v1-service.js.map +1 -1
  113. package/dist/server.d.ts +7 -1
  114. package/dist/server.d.ts.map +1 -1
  115. package/dist/server.js +1468 -1470
  116. package/dist/server.js.map +1 -1
  117. package/dist/session/chat-prompt.d.ts.map +1 -1
  118. package/dist/session/chat-prompt.js +6 -0
  119. package/dist/session/chat-prompt.js.map +1 -1
  120. package/dist/session/chat-turn.d.ts.map +1 -1
  121. package/dist/session/chat-turn.js +36 -11
  122. package/dist/session/chat-turn.js.map +1 -1
  123. package/dist/session/client-tool-gate.d.ts +23 -0
  124. package/dist/session/client-tool-gate.d.ts.map +1 -0
  125. package/dist/session/client-tool-gate.js +59 -0
  126. package/dist/session/client-tool-gate.js.map +1 -0
  127. package/dist/session/inference.d.ts.map +1 -1
  128. package/dist/session/inference.js +19 -13
  129. package/dist/session/inference.js.map +1 -1
  130. package/dist/session/job-execution.d.ts.map +1 -1
  131. package/dist/session/job-execution.js +9 -2
  132. package/dist/session/job-execution.js.map +1 -1
  133. package/dist/test-ui-server.d.ts +4 -3
  134. package/dist/test-ui-server.d.ts.map +1 -1
  135. package/dist/test-ui-server.js +11 -9
  136. package/dist/test-ui-server.js.map +1 -1
  137. package/dist/triggers/file-watch.d.ts +2 -2
  138. package/dist/triggers/file-watch.d.ts.map +1 -1
  139. package/dist/triggers/file-watch.js +10 -3
  140. package/dist/triggers/file-watch.js.map +1 -1
  141. package/dist/trpc/routers/extension.d.ts +16 -2
  142. package/dist/trpc/routers/extension.d.ts.map +1 -1
  143. package/dist/trpc/routers/extension.js +33 -4
  144. package/dist/trpc/routers/extension.js.map +1 -1
  145. package/dist/trpc/routers/games.d.ts +17387 -57244
  146. package/dist/trpc/routers/games.d.ts.map +1 -1
  147. package/dist/trpc/routers/games.js +9 -4
  148. package/dist/trpc/routers/games.js.map +1 -1
  149. package/dist/trpc/routers/jobs.d.ts.map +1 -1
  150. package/dist/trpc/routers/jobs.js +6 -1
  151. package/dist/trpc/routers/jobs.js.map +1 -1
  152. package/dist/trpc/routers/models.d.ts.map +1 -1
  153. package/dist/trpc/routers/models.js +40 -9
  154. package/dist/trpc/routers/models.js.map +1 -1
  155. package/dist/trpc/routers/packs.js +1 -1
  156. package/dist/trpc/routers/packs.js.map +1 -1
  157. package/dist/trpc/routers/threads.d.ts.map +1 -1
  158. package/dist/trpc/routers/threads.js +3 -0
  159. package/dist/trpc/routers/threads.js.map +1 -1
  160. package/dist/trpc/routers/timeline.d.ts +364 -0
  161. package/dist/trpc/routers/timeline.d.ts.map +1 -1
  162. package/dist/trpc/routers/workflows.d.ts +44 -0
  163. package/dist/trpc/routers/workflows.d.ts.map +1 -1
  164. package/dist/trpc/routers/workflows.js +4 -2
  165. package/dist/trpc/routers/workflows.js.map +1 -1
  166. package/dist/trpc/sandbox-coverage.d.ts.map +1 -1
  167. package/dist/trpc/sandbox-coverage.js +5 -0
  168. package/dist/trpc/sandbox-coverage.js.map +1 -1
  169. package/package.json +51 -48
package/dist/server.js CHANGED
@@ -13,11 +13,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
13
13
  import { createServer as createHttpServer } from "node:http";
14
14
  import { flushCompileCache } from "node:module";
15
15
  import crypto from "node:crypto";
16
- import { createLogger, configureLogging, getDefaultDbPath, getPostgresDatabaseUrl, loadEnvironment } from "@nodetool-ai/config";
17
- import { registerTransformersJsProvider } from "@nodetool-ai/transformers-js-provider";
18
- import { registerWhisperCppProviders } from "@nodetool-ai/whisper-cpp";
19
- import { setCodeNodeAgentsModule } from "@nodetool-ai/base-nodes";
20
- import * as agentsModule from "@nodetool-ai/agents";
16
+ import { createLogger, configureLogging, getDefaultDbPath, getPostgresDatabaseUrl, loadEnvironment, applyRuntimeEnv } from "@nodetool-ai/config";
21
17
  import { bootstrapNodeRegistry, mergePythonBridgeMetadata } from "./node-registry-setup.js";
22
18
  import { corsOriginDelegate } from "./cors.js";
23
19
  import { zipExtensionDist } from "./lib/extension-dist.js";
@@ -109,6 +105,7 @@ import timelineTrackObjectRoutes from "./routes/timeline-track-object.js";
109
105
  import storyboardsRoutes from "./routes/storyboards.js";
110
106
  import gamesRoutes from "./routes/games.js";
111
107
  import timelinesRoutes from "./routes/timelines.js";
108
+ import projectArchiveRoutes from "./routes/project-archive.js";
112
109
  import documentsRoutes from "./routes/documents.js";
113
110
  import sandboxModulesRoutes from "./routes/sandbox-modules.js";
114
111
  import falCreditsRoute from "./routes/fal-credits.js";
@@ -125,1577 +122,1578 @@ import { logTrpcRequestError, traceTrpcRequestError } from "./trpc/error-logging
125
122
  import { captureError, startErrorTraceMaintenance } from "./error-traces.js";
126
123
  import { detectPipMetadataRoots } from "./lib/python-metadata-roots.js";
127
124
  import { getSetting } from "./settings-registry.js";
128
- // @llamaindex/liteparse bundles a webpack pdf.js whose `isNodeJS` heuristic
129
- // resolves to false inside Electron utilityProcess (process.type === "utility"),
130
- // causing it to take browser-only code paths that reference the global `document`
131
- // and throw `ReferenceError: document is not defined` on parse(). Reporting
132
- // process.type as "browser" — the value Electron's main process uses — flips
133
- // the heuristic back to Node mode so NodeBinaryDataFactory / fs-based asset
134
- // loading is selected. No-op outside Electron.
135
- {
136
- // Electron adds these two to the Node `process`; on plain Node both reads
137
- // come back undefined and the check below fails, as it should.
138
- const electronVersion = process.versions["electron"];
139
- // SAFETY: `type` is Electron's addition to the Node `process`, so it is
140
- // declared optional here and reads undefined everywhere else.
141
- const processType = process.type;
142
- if (electronVersion && processType && processType !== "browser") {
143
- try {
144
- Object.defineProperty(process, "type", {
145
- value: "browser",
146
- writable: true,
147
- configurable: true
148
- });
149
- }
150
- catch {
151
- // Property is non-configurable in some Electron builds; ignore.
125
+ import { loadBundledPackSource, setDefaultPackSource } from "./pack-source.js";
126
+ /** Start the HTTP/WebSocket host with the caller's node-pack source. */
127
+ export async function startServer(options = {}) {
128
+ // @llamaindex/liteparse bundles a webpack pdf.js whose `isNodeJS` heuristic
129
+ // resolves to false inside Electron utilityProcess (process.type === "utility"),
130
+ // causing it to take browser-only code paths that reference the global `document`
131
+ // and throw `ReferenceError: document is not defined` on parse(). Reporting
132
+ // process.type as "browser" — the value Electron's main process uses — flips
133
+ // the heuristic back to Node mode so NodeBinaryDataFactory / fs-based asset
134
+ // loading is selected. No-op outside Electron.
135
+ {
136
+ // Electron adds these two to the Node `process`; on plain Node both reads
137
+ // come back undefined and the check below fails, as it should.
138
+ const electronVersion = process.versions["electron"];
139
+ // SAFETY: `type` is Electron's addition to the Node `process`, so it is
140
+ // declared optional here and reads undefined everywhere else.
141
+ const processType = process.type;
142
+ if (electronVersion && processType && processType !== "browser") {
143
+ try {
144
+ Object.defineProperty(process, "type", {
145
+ value: "browser",
146
+ writable: true,
147
+ configurable: true
148
+ });
149
+ }
150
+ catch {
151
+ // Property is non-configurable in some Electron builds; ignore.
152
+ }
152
153
  }
153
154
  }
154
- }
155
- loadEnvironment(resolve(dirname(fileURLToPath(import.meta.url)), "../../.."));
156
- const log = createLogger("nodetool.websocket.server");
157
- // Apply log level from NODETOOL_LOG_LEVEL / LOG_LEVEL env vars (the module
158
- // initialises eagerly, but an explicit call here picks up any env mutations
159
- // made by the process launcher before this point).
160
- configureLogging();
161
- // The Python interpreter scan runs while telemetry starts and the database
162
- // opens and migrates. The node registry below is the first reader.
163
- const pipMetadataRoots = detectPipMetadataRoots();
164
- await ensureRunTraceTelemetry();
165
- const startupT0 = performance.now();
166
- /**
167
- * When this process started. A generation or job row still in flight from
168
- * before this moment belongs to a process that is gone, and the startup sweeps
169
- * close it (docs/media-generation-tracking-design.md § 6.3).
170
- */
171
- const PROCESS_STARTED_AT = new Date().toISOString();
172
- let stopGenerationReconcileWorker = null;
173
- let stopErrorTraceMaintenance = null;
174
- function startupMs() {
175
- return `${(performance.now() - startupT0).toFixed(0)}ms`;
176
- }
177
- async function broadcastResourceChange(app, change) {
178
- const message = packWebSocketMessage({
179
- type: "resource_change",
180
- ...change
181
- });
182
- for (const client of app.websocketServer.clients) {
183
- if (client.readyState === 1) {
184
- client.send(message);
185
- }
155
+ loadEnvironment(resolve(dirname(fileURLToPath(import.meta.url)), "../../.."));
156
+ process.env["NODETOOL_HOST"] ??= "source";
157
+ process.env["NODETOOL_SERVER_PROCESS"] = "1";
158
+ applyRuntimeEnv();
159
+ const packSource = options.packSource ?? await loadBundledPackSource();
160
+ setDefaultPackSource(packSource);
161
+ const isProduction = process.env["NODETOOL_ENV"] === "production";
162
+ const log = createLogger("nodetool.websocket.server");
163
+ // Apply log level from NODETOOL_LOG_LEVEL / LOG_LEVEL env vars (the module
164
+ // initialises eagerly, but an explicit call here picks up any env mutations
165
+ // made by the process launcher before this point).
166
+ configureLogging();
167
+ // The Python interpreter scan runs while telemetry starts and the database
168
+ // opens and migrates. The node registry below is the first reader.
169
+ const pipMetadataRoots = detectPipMetadataRoots();
170
+ await ensureRunTraceTelemetry();
171
+ const startupT0 = performance.now();
172
+ /**
173
+ * When this process started. A generation or job row still in flight from
174
+ * before this moment belongs to a process that is gone, and the startup sweeps
175
+ * close it (docs/media-generation-tracking-design.md § 6.3).
176
+ */
177
+ const PROCESS_STARTED_AT = new Date().toISOString();
178
+ let stopGenerationReconcileWorker = null;
179
+ let stopErrorTraceMaintenance = null;
180
+ function startupMs() {
181
+ return `${(performance.now() - startupT0).toFixed(0)}ms`;
186
182
  }
187
- }
188
- async function notifyPythonBridgeResourceChanges(app, pythonBridge) {
189
- await broadcastResourceChange(app, {
190
- event: "updated",
191
- resource_type: "metadata",
192
- resource: { id: "nodes", etag: String(Date.now()) }
193
- });
194
- const registered = await registerPythonProvidersOnce();
195
- const discoveredProviders = await pythonBridge.listProviders();
196
- const registeredProviders = new Set(registered);
197
- for (const provider of discoveredProviders) {
198
- await broadcastResourceChange(app, {
199
- event: registeredProviders.has(provider.id) ? "created" : "updated",
200
- resource_type: "provider",
201
- resource: {
202
- id: provider.id,
203
- provider: provider.id,
204
- capabilities: provider.capabilities,
205
- etag: String(Date.now())
206
- }
183
+ async function broadcastResourceChange(app, change) {
184
+ const message = packWebSocketMessage({
185
+ type: "resource_change",
186
+ ...change
207
187
  });
188
+ for (const client of app.websocketServer.clients) {
189
+ if (client.readyState === 1) {
190
+ client.send(message);
191
+ }
192
+ }
193
+ }
194
+ async function notifyPythonBridgeResourceChanges(app, pythonBridge) {
208
195
  await broadcastResourceChange(app, {
209
196
  event: "updated",
210
- resource_type: "model",
211
- resource: {
212
- id: provider.id,
213
- provider: provider.id,
214
- etag: String(Date.now())
215
- }
197
+ resource_type: "metadata",
198
+ resource: { id: "nodes", etag: String(Date.now()) }
216
199
  });
217
- }
218
- }
219
- // ---------------------------------------------------------------------------
220
- // Database setup
221
- // ---------------------------------------------------------------------------
222
- function maskDatabaseUrl(url) {
223
- try {
224
- const parsed = new URL(url);
225
- if (parsed.password) {
226
- parsed.password = "***";
200
+ const registered = await registerPythonProvidersOnce();
201
+ const discoveredProviders = await pythonBridge.listProviders();
202
+ const registeredProviders = new Set(registered);
203
+ for (const provider of discoveredProviders) {
204
+ await broadcastResourceChange(app, {
205
+ event: registeredProviders.has(provider.id) ? "created" : "updated",
206
+ resource_type: "provider",
207
+ resource: {
208
+ id: provider.id,
209
+ provider: provider.id,
210
+ capabilities: provider.capabilities,
211
+ etag: String(Date.now())
212
+ }
213
+ });
214
+ await broadcastResourceChange(app, {
215
+ event: "updated",
216
+ resource_type: "model",
217
+ resource: {
218
+ id: provider.id,
219
+ provider: provider.id,
220
+ etag: String(Date.now())
221
+ }
222
+ });
227
223
  }
228
- return parsed.toString();
229
224
  }
230
- catch {
231
- return "(provided)";
232
- }
233
- }
234
- try {
235
- const postgresDatabaseUrl = getPostgresDatabaseUrl();
236
- if (postgresDatabaseUrl) {
237
- await initPostgresDb(postgresDatabaseUrl);
238
- log.info(`PostgreSQL database ready [${startupMs()}]`, {
239
- url: maskDatabaseUrl(postgresDatabaseUrl)
240
- });
225
+ // ---------------------------------------------------------------------------
226
+ // Database setup
227
+ // ---------------------------------------------------------------------------
228
+ function maskDatabaseUrl(url) {
229
+ try {
230
+ const parsed = new URL(url);
231
+ if (parsed.password) {
232
+ parsed.password = "***";
233
+ }
234
+ return parsed.toString();
235
+ }
236
+ catch {
237
+ return "(provided)";
238
+ }
241
239
  }
242
- else {
243
- const dbPath = getDefaultDbPath();
244
- log.info(`Initializing SQLite database at: ${dbPath}`);
245
- mkdirSync(dirname(dbPath), { recursive: true });
246
- const appliedMigrations = await migrateSqliteDb(dbPath);
247
- if (appliedMigrations.length > 0) {
248
- log.info(`SQLite migrations applied [${startupMs()}]`, {
249
- count: appliedMigrations.length,
250
- versions: appliedMigrations
240
+ try {
241
+ const postgresDatabaseUrl = getPostgresDatabaseUrl();
242
+ if (postgresDatabaseUrl) {
243
+ await initPostgresDb(postgresDatabaseUrl);
244
+ log.info(`PostgreSQL database ready [${startupMs()}]`, {
245
+ url: maskDatabaseUrl(postgresDatabaseUrl)
246
+ });
247
+ }
248
+ else {
249
+ const dbPath = getDefaultDbPath();
250
+ log.info(`Initializing SQLite database at: ${dbPath}`);
251
+ mkdirSync(dirname(dbPath), { recursive: true });
252
+ const appliedMigrations = await migrateSqliteDb(dbPath);
253
+ if (appliedMigrations.length > 0) {
254
+ log.info(`SQLite migrations applied [${startupMs()}]`, {
255
+ count: appliedMigrations.length,
256
+ versions: appliedMigrations
257
+ });
258
+ }
259
+ initDb(dbPath);
260
+ log.info(`SQLite database ready [${startupMs()}]`, { path: dbPath });
261
+ }
262
+ // Initialize master key from keychain before any secret access. Callers
263
+ // that need provider credentials bind their own `(key) => getSecret(key,
264
+ // userId)` closure when invoking provider APIs — there is no shared
265
+ // global resolver.
266
+ //
267
+ // A machine with no keychain (a container, CI, a server without a D-Bus
268
+ // session) cannot unlock the store. The server still starts, the way the
269
+ // CLI commands do: provider keys from the environment keep working, and
270
+ // only reading or writing a stored secret fails. Exiting here made
271
+ // `npx @nodetool-ai/cli serve` unusable on every headless machine.
272
+ try {
273
+ await initMasterKey();
274
+ }
275
+ catch (err) {
276
+ log.warn("Secret store is locked: stored secrets are unavailable, and provider " +
277
+ "keys are read from the environment. Set SECRETS_MASTER_KEY to a " +
278
+ "base64-encoded 32-byte key to enable the store.", { error: err instanceof Error ? err.message : String(err) });
279
+ }
280
+ // Transformers.js fixes its cache directory on first import, which happens
281
+ // after this point, so a directory saved in Settings must be applied now.
282
+ try {
283
+ const tjsCacheDir = await applyTransformersJsCacheSetting(LOCAL_USER_ID);
284
+ if (tjsCacheDir) {
285
+ log.info("Transformers.js cache directory from Settings", { path: tjsCacheDir });
286
+ }
287
+ }
288
+ catch (err) {
289
+ log.warn("Could not read TRANSFORMERS_JS_CACHE_DIR from Settings", {
290
+ error: err instanceof Error ? err.message : String(err)
251
291
  });
252
292
  }
253
- initDb(dbPath);
254
- log.info(`SQLite database ready [${startupMs()}]`, { path: dbPath });
293
+ // Bookkeeping for media generations: close the rows a restart orphaned, and
294
+ // keep refining estimates into billed amounts while the server runs.
295
+ void sweepInterruptedGenerations(PROCESS_STARTED_AT).catch((err) => {
296
+ log.warn("Interrupted-generation sweep failed", {
297
+ error: err instanceof Error ? err.message : String(err)
298
+ });
299
+ });
300
+ void sweepInterruptedJobs(PROCESS_STARTED_AT, {
301
+ sharedDatabase: Boolean(postgresDatabaseUrl)
302
+ }).catch((err) => {
303
+ log.warn("Interrupted-job sweep failed", {
304
+ error: err instanceof Error ? err.message : String(err)
305
+ });
306
+ });
307
+ stopGenerationReconcileWorker = startGenerationReconcileWorker({
308
+ resolveSecret: (key, userId) => getSecret(key, userId)
309
+ });
310
+ stopErrorTraceMaintenance = startErrorTraceMaintenance();
311
+ const runHistoryCleanup = () => {
312
+ void runScheduledStorageCleanup(LOCAL_USER_ID)
313
+ .then((result) => {
314
+ if (result && result.total > 0) {
315
+ log.info("Cleaned retained workflow and run history", result);
316
+ }
317
+ })
318
+ .catch((error) => {
319
+ log.warn("Automatic history cleanup failed", error instanceof Error ? error : new Error(String(error)));
320
+ });
321
+ // `/oauth/register` is anonymous and writes a durable row, so the rows it
322
+ // leaves behind need a sweep of their own: a client nobody ever approved
323
+ // is dead weight. One with any grant — active or revoked — is kept.
324
+ void McpOauthClient.gcUnused(UNUSED_OAUTH_CLIENT_TTL_MS)
325
+ .then((deleted) => {
326
+ if (deleted > 0) {
327
+ log.info("Deleted unapproved MCP OAuth clients", { deleted });
328
+ }
329
+ })
330
+ .catch((error) => {
331
+ log.warn("MCP OAuth client cleanup failed", error instanceof Error ? error : new Error(String(error)));
332
+ });
333
+ };
334
+ runHistoryCleanup();
335
+ const historyCleanupTimer = setInterval(runHistoryCleanup, 6 * 60 * 60 * 1000);
336
+ historyCleanupTimer.unref?.();
255
337
  }
256
- // Initialize master key from keychain before any secret access. Callers
257
- // that need provider credentials bind their own `(key) => getSecret(key,
258
- // userId)` closure when invoking provider APIs — there is no shared
259
- // global resolver.
260
- //
261
- // A machine with no keychain (a container, CI, a server without a D-Bus
262
- // session) cannot unlock the store. The server still starts, the way the
263
- // CLI commands do: provider keys from the environment keep working, and
264
- // only reading or writing a stored secret fails. Exiting here made
265
- // `npx @nodetool-ai/cli serve` unusable on every headless machine.
338
+ catch (err) {
339
+ log.error("Database setup failed", err instanceof Error ? err : new Error(String(err)));
340
+ process.exit(1);
341
+ }
342
+ // Persistence for every context this process builds. Each entrance used to
343
+ // wire its own, and the ones that forgot produced runs that could not save an
344
+ // asset — the same workflow ran from the editor and threw over HTTP. A
345
+ // context that wires its own still wins.
346
+ setDefaultModelInterfaces(serverModelInterfaces());
347
+ // Seed built-in workflows — non-fatal; a seed failure should not prevent startup.
266
348
  try {
267
- await initMasterKey();
349
+ await runSeeds();
350
+ log.info(`Seeds applied [${startupMs()}]`);
268
351
  }
269
352
  catch (err) {
270
- log.warn("Secret store is locked: stored secrets are unavailable, and provider " +
271
- "keys are read from the environment. Set SECRETS_MASTER_KEY to a " +
272
- "base64-encoded 32-byte key to enable the store.", { error: err instanceof Error ? err.message : String(err) });
353
+ log.warn("Seeds failed (non-fatal)", err instanceof Error ? err : new Error(String(err)));
273
354
  }
274
- // Transformers.js fixes its cache directory on first import, which happens
275
- // after this point, so a directory saved in Settings must be applied now.
355
+ // User-defined OpenAI-compatible providers for the local user, so a workflow
356
+ // run through a headless entrance reaches them without a settings request
357
+ // first. Each request re-syncs the caller's own catalog.
276
358
  try {
277
- const tjsCacheDir = await applyTransformersJsCacheSetting(LOCAL_USER_ID);
278
- if (tjsCacheDir) {
279
- log.info("Transformers.js cache directory from Settings", { path: tjsCacheDir });
359
+ const ids = await syncCustomProviderRegistry(LOCAL_USER_ID);
360
+ if (ids.length > 0) {
361
+ log.info(`Registered custom providers: ${ids.join(", ")}`);
280
362
  }
281
363
  }
282
364
  catch (err) {
283
- log.warn("Could not read TRANSFORMERS_JS_CACHE_DIR from Settings", {
284
- error: err instanceof Error ? err.message : String(err)
285
- });
365
+ log.warn("Custom provider sync failed (non-fatal)", err instanceof Error ? err : new Error(String(err)));
286
366
  }
287
- // Bookkeeping for media generations: close the rows a restart orphaned, and
288
- // keep refining estimates into billed amounts while the server runs.
289
- void sweepInterruptedGenerations(PROCESS_STARTED_AT).catch((err) => {
290
- log.warn("Interrupted-generation sweep failed", {
291
- error: err instanceof Error ? err.message : String(err)
292
- });
367
+ const metadataRoots = await pipMetadataRoots;
368
+ // Also scan local TS node packages that have nodetool/package_metadata
369
+ const localPackagesDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../");
370
+ if (existsSync(localPackagesDir)) {
371
+ try {
372
+ for (const entry of readdirSync(localPackagesDir, {
373
+ withFileTypes: true
374
+ })) {
375
+ if (!entry.isDirectory())
376
+ continue;
377
+ const metaDir = join(localPackagesDir, entry.name, "nodetool", "package_metadata");
378
+ if (existsSync(metaDir)) {
379
+ const resolved = resolve(localPackagesDir, entry.name);
380
+ if (!metadataRoots.includes(resolved)) {
381
+ metadataRoots.push(resolved);
382
+ }
383
+ }
384
+ }
385
+ }
386
+ catch {
387
+ // ignore scan errors
388
+ }
389
+ }
390
+ log.info(`Metadata roots detected [${startupMs()}]`, {
391
+ roots: metadataRoots
293
392
  });
294
- void sweepInterruptedJobs(PROCESS_STARTED_AT, {
295
- sharedDatabase: Boolean(postgresDatabaseUrl)
296
- }).catch((err) => {
297
- log.warn("Interrupted-job sweep failed", {
298
- error: err instanceof Error ? err.message : String(err)
299
- });
393
+ // ---------------------------------------------------------------------------
394
+ // Node registry
395
+ // ---------------------------------------------------------------------------
396
+ const registry = await bootstrapNodeRegistry({
397
+ packSource,
398
+ metadataRoots,
399
+ log
300
400
  });
301
- stopGenerationReconcileWorker = startGenerationReconcileWorker({
302
- resolveSecret: (key, userId) => getSecret(key, userId)
401
+ log.info(`Node registry ready [${startupMs()}]`);
402
+ await packSource.registerProviders({ production: isProduction });
403
+ // ---------------------------------------------------------------------------
404
+ // Python bridge
405
+ // ---------------------------------------------------------------------------
406
+ const localBridge = createPythonBridge({
407
+ workerArgs: process.env["NODETOOL_WORKER_NAMESPACES"]
408
+ ? ["--namespaces", process.env["NODETOOL_WORKER_NAMESPACES"]]
409
+ : [],
410
+ workerEnv: () => resolveWorkerSettingsEnv(LOCAL_USER_ID)
411
+ });
412
+ // The ONE stable bridge reference handed to every consumer. It delegates to the
413
+ // local bridge by default; attaching a worker swaps in a dedicated WebSocket
414
+ // bridge to that worker (so ALL Python work — execution AND model management —
415
+ // targets the worker's GPU) and detach swaps back. Because the reference is
416
+ // stable and re-emits its target's events, consumers never re-read it and
417
+ // listeners are wired exactly once.
418
+ const pythonBridge = new SwappableBridge(localBridge);
419
+ /**
420
+ * Python-only providers (`huggingface-local`) exist only once the worker has
421
+ * listed them. A run that selects one must wait for this, not just for the
422
+ * connection: registration follows the connect, and a run that arrives in
423
+ * between is refused for an unknown provider.
424
+ */
425
+ const registerPythonProvidersOnce = createProviderRegistrar(async () => {
426
+ const registered = await registerPythonProviders(pythonBridge);
427
+ if (registered.length > 0) {
428
+ log.info(`Registered Python providers: ${registered.join(", ")}`);
429
+ }
430
+ return registered;
303
431
  });
304
- stopErrorTraceMaintenance = startErrorTraceMaintenance();
305
- const runHistoryCleanup = () => {
306
- void runScheduledStorageCleanup(LOCAL_USER_ID)
307
- .then((result) => {
308
- if (result && result.total > 0) {
309
- log.info("Cleaned retained workflow and run history", result);
432
+ let pythonBridgeReady = false;
433
+ // Worker bridge bookkeeping so it can be closed after swapping away. Only the
434
+ // remote WebSocket bridge is tracked here; the local stdio bridge is owned by
435
+ // `localBridge` above and never closed on swap.
436
+ let workerBridge = null;
437
+ const isLocalActive = () => pythonBridge.target === localBridge;
438
+ const getPythonBridgeReady = () => isLocalActive() ? pythonBridgeReady : pythonBridge.isConnected;
439
+ // ---------------------------------------------------------------------------
440
+ // Worker provisioning (GPU workers via @nodetool-ai/compute)
441
+ // ---------------------------------------------------------------------------
442
+ const workerManager = new WorkerManager();
443
+ /**
444
+ * Re-point the Python bridge for worker attach/detach by SWAPPING the
445
+ * SwappableBridge's target (a local stdio bridge cannot be re-pointed in place).
446
+ *
447
+ * On attach: build a dedicated WebSocket bridge to the worker, connect it
448
+ * (discover + status), swap it in as the target — so subsequent execution and
449
+ * model-management calls run on the worker — then close the previous worker
450
+ * bridge. Throws (failing the attach) if the worker can't be reached. On detach
451
+ * (`null`): swap back to the local bridge and dispose the worker bridge.
452
+ *
453
+ * `swap()` itself never connects or closes a bridge — lifecycle is owned here:
454
+ * connect before swapping in, close after swapping away.
455
+ */
456
+ async function repointPythonBridge(target) {
457
+ if (target) {
458
+ const next = new WebsocketPythonBridge({
459
+ wsUrl: target.wsUrl,
460
+ workerToken: target.token ?? undefined
461
+ });
462
+ try {
463
+ await next.connect();
310
464
  }
311
- })
312
- .catch((error) => {
313
- log.warn("Automatic history cleanup failed", error instanceof Error ? error : new Error(String(error)));
314
- });
315
- // `/oauth/register` is anonymous and writes a durable row, so the rows it
316
- // leaves behind need a sweep of their own: a client nobody ever approved
317
- // is dead weight. One with any grant — active or revoked — is kept.
318
- void McpOauthClient.gcUnused(UNUSED_OAUTH_CLIENT_TTL_MS)
319
- .then((deleted) => {
320
- if (deleted > 0) {
321
- log.info("Deleted unapproved MCP OAuth clients", { deleted });
322
- }
323
- })
324
- .catch((error) => {
325
- log.warn("MCP OAuth client cleanup failed", error instanceof Error ? error : new Error(String(error)));
326
- });
327
- };
328
- runHistoryCleanup();
329
- const historyCleanupTimer = setInterval(runHistoryCleanup, 6 * 60 * 60 * 1000);
330
- historyCleanupTimer.unref?.();
331
- }
332
- catch (err) {
333
- log.error("Database setup failed", err instanceof Error ? err : new Error(String(err)));
334
- process.exit(1);
335
- }
336
- // Persistence for every context this process builds. Each entrance used to
337
- // wire its own, and the ones that forgot produced runs that could not save an
338
- // asset — the same workflow ran from the editor and threw over HTTP. A
339
- // context that wires its own still wins.
340
- setDefaultModelInterfaces(serverModelInterfaces());
341
- // Seed built-in workflows — non-fatal; a seed failure should not prevent startup.
342
- try {
343
- await runSeeds();
344
- log.info(`Seeds applied [${startupMs()}]`);
345
- }
346
- catch (err) {
347
- log.warn("Seeds failed (non-fatal)", err instanceof Error ? err : new Error(String(err)));
348
- }
349
- // User-defined OpenAI-compatible providers for the local user, so a workflow
350
- // run through a headless entrance reaches them without a settings request
351
- // first. Each request re-syncs the caller's own catalog.
352
- try {
353
- const ids = await syncCustomProviderRegistry(LOCAL_USER_ID);
354
- if (ids.length > 0) {
355
- log.info(`Registered custom providers: ${ids.join(", ")}`);
356
- }
357
- }
358
- catch (err) {
359
- log.warn("Custom provider sync failed (non-fatal)", err instanceof Error ? err : new Error(String(err)));
360
- }
361
- const metadataRoots = await pipMetadataRoots;
362
- // Also scan local TS node packages that have nodetool/package_metadata
363
- const localPackagesDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../");
364
- if (existsSync(localPackagesDir)) {
365
- try {
366
- for (const entry of readdirSync(localPackagesDir, {
367
- withFileTypes: true
368
- })) {
369
- if (!entry.isDirectory())
370
- continue;
371
- const metaDir = join(localPackagesDir, entry.name, "nodetool", "package_metadata");
372
- if (existsSync(metaDir)) {
373
- const resolved = resolve(localPackagesDir, entry.name);
374
- if (!metadataRoots.includes(resolved)) {
375
- metadataRoots.push(resolved);
376
- }
465
+ catch (err) {
466
+ next.close();
467
+ throw err;
377
468
  }
469
+ const previous = workerBridge;
470
+ workerBridge = next;
471
+ pythonBridge.swap(next);
472
+ if (previous)
473
+ previous.close();
474
+ log.info("Python bridge re-pointed to attached worker");
475
+ return;
378
476
  }
477
+ const previous = workerBridge;
478
+ workerBridge = null;
479
+ pythonBridge.swap(localBridge);
480
+ if (previous)
481
+ previous.close();
482
+ log.info("Python bridge re-pointed back to the local worker");
379
483
  }
380
- catch {
381
- // ignore scan errors
382
- }
383
- }
384
- log.info(`Metadata roots detected [${startupMs()}]`, {
385
- roots: metadataRoots
386
- });
387
- // ---------------------------------------------------------------------------
388
- // Node registry
389
- // ---------------------------------------------------------------------------
390
- const registry = await bootstrapNodeRegistry({
391
- metadataRoots,
392
- log
393
- });
394
- log.info(`Node registry ready [${startupMs()}]`);
395
- // The Code node reaches the toolbelt and its `@nodetool-ai/sandbox-nodetool/*`
396
- // imports through the agents package, which it resolves by bare specifier.
397
- // That specifier resolves in a checkout and nowhere in the bundled backend —
398
- // esbuild inlines the workspace packages into server.mjs — so hand it the copy
399
- // this module already holds. Without it, a Code node body in the desktop app
400
- // and in the Docker image runs with an empty `nodetool.capabilities()`.
401
- setCodeNodeAgentsModule(agentsModule);
402
- if (process.env["NODETOOL_ENV"] !== "production") {
403
- registerTransformersJsProvider();
404
- registerWhisperCppProviders();
405
- }
406
- // ---------------------------------------------------------------------------
407
- // Python bridge
408
- // ---------------------------------------------------------------------------
409
- const localBridge = createPythonBridge({
410
- workerArgs: process.env["NODETOOL_WORKER_NAMESPACES"]
411
- ? ["--namespaces", process.env["NODETOOL_WORKER_NAMESPACES"]]
412
- : [],
413
- workerEnv: () => resolveWorkerSettingsEnv(LOCAL_USER_ID)
414
- });
415
- // The ONE stable bridge reference handed to every consumer. It delegates to the
416
- // local bridge by default; attaching a worker swaps in a dedicated WebSocket
417
- // bridge to that worker (so ALL Python work — execution AND model management —
418
- // targets the worker's GPU) and detach swaps back. Because the reference is
419
- // stable and re-emits its target's events, consumers never re-read it and
420
- // listeners are wired exactly once.
421
- const pythonBridge = new SwappableBridge(localBridge);
422
- /**
423
- * Python-only providers (`huggingface-local`) exist only once the worker has
424
- * listed them. A run that selects one must wait for this, not just for the
425
- * connection: registration follows the connect, and a run that arrives in
426
- * between is refused for an unknown provider.
427
- */
428
- const registerPythonProvidersOnce = createProviderRegistrar(async () => {
429
- const registered = await registerPythonProviders(pythonBridge);
430
- if (registered.length > 0) {
431
- log.info(`Registered Python providers: ${registered.join(", ")}`);
432
- }
433
- return registered;
434
- });
435
- let pythonBridgeReady = false;
436
- // Worker bridge bookkeeping so it can be closed after swapping away. Only the
437
- // remote WebSocket bridge is tracked here; the local stdio bridge is owned by
438
- // `localBridge` above and never closed on swap.
439
- let workerBridge = null;
440
- const isLocalActive = () => pythonBridge.target === localBridge;
441
- const getPythonBridgeReady = () => isLocalActive() ? pythonBridgeReady : pythonBridge.isConnected;
442
- // ---------------------------------------------------------------------------
443
- // Worker provisioning (GPU workers via @nodetool-ai/compute)
444
- // ---------------------------------------------------------------------------
445
- const workerManager = new WorkerManager();
446
- /**
447
- * Re-point the Python bridge for worker attach/detach by SWAPPING the
448
- * SwappableBridge's target (a local stdio bridge cannot be re-pointed in place).
449
- *
450
- * On attach: build a dedicated WebSocket bridge to the worker, connect it
451
- * (discover + status), swap it in as the target — so subsequent execution and
452
- * model-management calls run on the worker — then close the previous worker
453
- * bridge. Throws (failing the attach) if the worker can't be reached. On detach
454
- * (`null`): swap back to the local bridge and dispose the worker bridge.
455
- *
456
- * `swap()` itself never connects or closes a bridge — lifecycle is owned here:
457
- * connect before swapping in, close after swapping away.
458
- */
459
- async function repointPythonBridge(target) {
460
- if (target) {
461
- const next = new WebsocketPythonBridge({
484
+ /** Bound for the readiness probe: TCP/WS handshake + discover RPC. */
485
+ const WORKER_PROBE_TIMEOUT_MS = 8000;
486
+ /**
487
+ * Probe a `running` worker's readiness by opening a transient bridge to its
488
+ * `{wsUrl, token}` — the same handshake attach performs — then closing it. A
489
+ * resolved connect (WS open + `discover` answered) means the worker process is
490
+ * actually serving, not merely that the pod's port is mapped. Never throws:
491
+ * failures (still booting, unreachable) report `{ healthy: false }`.
492
+ */
493
+ async function probeWorkerHealth(target) {
494
+ if (!target.wsUrl) {
495
+ return { healthy: false, error: "Worker has no WebSocket URL yet" };
496
+ }
497
+ const probe = new WebsocketPythonBridge({
462
498
  wsUrl: target.wsUrl,
463
- workerToken: target.token ?? undefined
499
+ workerToken: target.token ?? undefined,
500
+ // One-shot probe: no reconnect, bounded handshake.
501
+ autoRestart: false,
502
+ startupTimeoutMs: WORKER_PROBE_TIMEOUT_MS,
503
+ reconnectRpcTimeoutMs: WORKER_PROBE_TIMEOUT_MS
464
504
  });
505
+ // A probe must never escalate a socket 'error' into a crash.
506
+ probe.on("error", () => { });
465
507
  try {
466
- await next.connect();
508
+ await probe.connect();
509
+ return {
510
+ healthy: true,
511
+ protocolVersion: probe.supportsModelManagement() ? 2 : 1
512
+ };
467
513
  }
468
514
  catch (err) {
469
- next.close();
470
- throw err;
515
+ return {
516
+ healthy: false,
517
+ error: err instanceof Error ? err.message : String(err)
518
+ };
519
+ }
520
+ finally {
521
+ try {
522
+ probe.close();
523
+ }
524
+ catch {
525
+ /* best-effort cleanup */
526
+ }
471
527
  }
472
- const previous = workerBridge;
473
- workerBridge = next;
474
- pythonBridge.swap(next);
475
- if (previous)
476
- previous.close();
477
- log.info("Python bridge re-pointed to attached worker");
478
- return;
479
528
  }
480
- const previous = workerBridge;
481
- workerBridge = null;
482
- pythonBridge.swap(localBridge);
483
- if (previous)
484
- previous.close();
485
- log.info("Python bridge re-pointed back to the local worker");
486
- }
487
- /** Bound for the readiness probe: TCP/WS handshake + discover RPC. */
488
- const WORKER_PROBE_TIMEOUT_MS = 8000;
489
- /**
490
- * Probe a `running` worker's readiness by opening a transient bridge to its
491
- * `{wsUrl, token}` — the same handshake attach performs — then closing it. A
492
- * resolved connect (WS open + `discover` answered) means the worker process is
493
- * actually serving, not merely that the pod's port is mapped. Never throws:
494
- * failures (still booting, unreachable) report `{ healthy: false }`.
495
- */
496
- async function probeWorkerHealth(target) {
497
- if (!target.wsUrl) {
498
- return { healthy: false, error: "Worker has no WebSocket URL yet" };
529
+ function logPythonBridgeDiagnostics(context) {
530
+ const loadErrors = localBridge.getLoadErrors?.() ?? [];
531
+ if (loadErrors.length === 0)
532
+ return;
533
+ log.warn(`Python bridge ${context} with ${loadErrors.length} load error(s)`);
534
+ for (const entry of loadErrors.slice(0, 10)) {
535
+ log.warn(`[python-worker][load-error] ${entry.module} (${entry.phase}): ${entry.error}`);
536
+ }
537
+ if (loadErrors.length > 10) {
538
+ log.warn(`[python-worker][load-error] ... ${loadErrors.length - 10} additional load error(s) omitted`);
539
+ }
499
540
  }
500
- const probe = new WebsocketPythonBridge({
501
- wsUrl: target.wsUrl,
502
- workerToken: target.token ?? undefined,
503
- // One-shot probe: no reconnect, bounded handshake.
504
- autoRestart: false,
505
- startupTimeoutMs: WORKER_PROBE_TIMEOUT_MS,
506
- reconnectRpcTimeoutMs: WORKER_PROBE_TIMEOUT_MS
541
+ pythonBridge.on("stderr", (msg) => {
542
+ for (const line of msg.split("\n")) {
543
+ logPythonWorkerStderr(line, log);
544
+ }
507
545
  });
508
- // A probe must never escalate a socket 'error' into a crash.
509
- probe.on("error", () => { });
510
- try {
511
- await probe.connect();
512
- return {
513
- healthy: true,
514
- protocolVersion: probe.supportsModelManagement() ? 2 : 1
515
- };
546
+ pythonBridge.on("error", (err) => {
547
+ log.error(`Python bridge protocol error: ${err.message}`);
548
+ pythonBridgeReady = false;
549
+ });
550
+ pythonBridge.on("exit", (code) => {
551
+ log.warn(`Python worker exited with code ${code}`);
552
+ pythonBridgeReady = false;
553
+ });
554
+ // Keep the attached worker's `last_activity_at` fresh so the cost guard's
555
+ // reaper measures idle time from real bridge traffic, not from creation. The
556
+ // throttling and the best-effort write live in @nodetool-ai/compute so the CLI
557
+ // gets the same behaviour; a local stdio bridge emits no "activity", so this is
558
+ // inert there.
559
+ attachWorkerActivityHeartbeat(pythonBridge, {
560
+ resolveInstanceId: async () => (await workerManager.getActiveWorker())?.id ?? null
561
+ });
562
+ // ---------------------------------------------------------------------------
563
+ // TLS configuration
564
+ // ---------------------------------------------------------------------------
565
+ function findCert(envVar, filename) {
566
+ const fromEnv = process.env[envVar];
567
+ if (fromEnv && existsSync(fromEnv))
568
+ return fromEnv;
569
+ // Walk up from cwd looking for the cert file
570
+ let dir = resolve(process.cwd());
571
+ for (let i = 0; i < 5; i++) {
572
+ const candidate = join(dir, filename);
573
+ if (existsSync(candidate))
574
+ return candidate;
575
+ const parent = dirname(dir);
576
+ if (parent === dir)
577
+ break;
578
+ dir = parent;
579
+ }
580
+ return undefined;
516
581
  }
517
- catch (err) {
518
- return {
519
- healthy: false,
520
- error: err instanceof Error ? err.message : String(err)
582
+ const tlsCertPath = findCert("TLS_CERT", "cert.pem");
583
+ const tlsKeyPath = findCert("TLS_KEY", "key.pem");
584
+ const tlsEnabled = Boolean(tlsCertPath && tlsKeyPath);
585
+ let httpsOptions;
586
+ if (tlsEnabled && tlsCertPath && tlsKeyPath) {
587
+ httpsOptions = {
588
+ cert: readFileSync(tlsCertPath),
589
+ key: readFileSync(tlsKeyPath)
521
590
  };
591
+ log.info("TLS enabled", { cert: tlsCertPath, key: tlsKeyPath });
522
592
  }
523
- finally {
524
- try {
525
- probe.close();
593
+ // ---------------------------------------------------------------------------
594
+ // Host / port
595
+ // ---------------------------------------------------------------------------
596
+ const port = Number(process.env["PORT"] ?? 7777);
597
+ // In production, bind to all interfaces unless HOST is explicitly set
598
+ const host = process.env["HOST"] ?? (isProduction ? "0.0.0.0" : "127.0.0.1");
599
+ // The `/mcp` streamable-HTTP mount. Off in production by default: it carries
600
+ // the full agent toolbelt for whichever user it binds, so a deployment opts in
601
+ // with NODETOOL_ENABLE_MCP=1 and authenticates the agent like any other client.
602
+ // The gate is shared with the settings UI, which reports it (see lib/mcp-mount).
603
+ const mcpHttpEnabled = isMcpHttpEnabled();
604
+ // The `WWW-Authenticate` challenge for an unauthenticated or invalid `/mcp`
605
+ // request (docs/mcp-oauth-design.md § "401 challenge") comes from
606
+ // `oauth/gate.ts` — the same gate the AS routes use, so the server never
607
+ // advertises a discovery flow the AS answers with 404. A server where the
608
+ // flow cannot complete answers the plain 401 it always has.
609
+ // Reverse proxies whose X-Forwarded-For header may be trusted to determine the
610
+ // real client IP. Comma-separated IPs/CIDRs (e.g. "127.0.0.1,10.0.0.0/8").
611
+ // When empty, X-Forwarded-For is NOT trusted: req.ip falls back to the socket
612
+ // peer address so clients cannot spoof their origin via headers.
613
+ const trustedProxies = parseTrustedProxies(process.env["NODETOOL_TRUSTED_PROXIES"]);
614
+ // ---------------------------------------------------------------------------
615
+ // Fastify app
616
+ // ---------------------------------------------------------------------------
617
+ const serverOptions = {
618
+ // Only trust X-Forwarded-For from explicitly configured proxies. With no
619
+ // proxies configured this is `false`, so req.ip is the unspoofable socket
620
+ // peer address.
621
+ trustProxy: trustedProxies.length > 0 ? trustedProxies : false,
622
+ bodyLimit: 100 * 1024 * 1024, // 100 MB
623
+ logger: false,
624
+ // tRPC's httpBatchLink encodes all batched procedure names into a single
625
+ // URL path segment joined with commas (e.g. `/trpc/foo,bar,baz`). Fastify's
626
+ // default `maxParamLength` of 100 rejects larger batches with a 404, and 2000
627
+ // was not enough either: one model-picker mount produced a ~2900 char path.
628
+ // The clients cap a batch at 20 procedures (`maxItems`); this is the backstop.
629
+ routerOptions: {
630
+ ignoreTrailingSlash: true,
631
+ maxParamLength: 8000
632
+ },
633
+ genReqId: (req) => {
634
+ return req.headers["x-request-id"] || crypto.randomUUID();
526
635
  }
527
- catch {
528
- /* best-effort cleanup */
636
+ };
637
+ const app = Fastify(httpsOptions ? { https: httpsOptions, ...serverOptions } : serverOptions);
638
+ // First hook, so every later hook and handler runs inside the request span.
639
+ // ---------------------------------------------------------------------------
640
+ // Request ID correlation
641
+ // ---------------------------------------------------------------------------
642
+ app.addHook("onRequest", async (request) => {
643
+ request.log.info({ reqId: request.id, method: request.method, url: request.url }, "incoming request");
644
+ });
645
+ // A REST handler that throws a 5xx leaves a redacted trace. 4xx errors are
646
+ // the caller's mistake and are not traced.
647
+ app.addHook("onError", async (request, _reply, error) => {
648
+ const status = error.statusCode ?? 500;
649
+ if (status < 500)
650
+ return;
651
+ captureError(error, {
652
+ source: "http",
653
+ userId: request.userId ?? null,
654
+ context: {
655
+ route: request.routeOptions.url ?? request.url.split("?")[0],
656
+ method: request.method,
657
+ http_status: status,
658
+ request_id: request.id
659
+ }
660
+ });
661
+ });
662
+ app.addHook("onSend", async (request, reply) => {
663
+ reply.header("X-Request-Id", request.id);
664
+ // Baseline security response headers. The web app's CSP lives in a `<meta>`
665
+ // tag, which browsers ignore for `frame-ancestors` — that directive is only
666
+ // honoured in a header, so clickjacking protection has to be set here.
667
+ //
668
+ // `/api/storage/*` is exempt: those bytes are designed to be embedded from
669
+ // other origins (MCP App iframes, the Electron renderer, external preview
670
+ // clients — see storage-api.ts), and the endpoint deliberately serves
671
+ // unknown extensions as `application/octet-stream` for `<img>`/`<video>` to
672
+ // sniff. Both `nosniff` and the frame headers would break that contract.
673
+ const path = request.url.split("?")[0] ?? "/";
674
+ if (!path.startsWith("/api/storage/")) {
675
+ reply.header("X-Content-Type-Options", "nosniff");
676
+ reply.header("X-Frame-Options", "SAMEORIGIN");
677
+ reply.header("Content-Security-Policy", "frame-ancestors 'self'");
529
678
  }
679
+ reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
680
+ });
681
+ // CORS must be registered before hooks that can short-circuit. Auth failures
682
+ // and rate-limit responses still need ACAO for first-party web deployments.
683
+ await app.register(fastifyCors, { origin: corsOriginDelegate });
684
+ // ---------------------------------------------------------------------------
685
+ // Per-IP HTTP rate limiting
686
+ // ---------------------------------------------------------------------------
687
+ // Registered before the auth hook so floods are rejected with 429 before any
688
+ // token verification work. Localhost is exempt; tune via NODETOOL_RATE_LIMIT_*.
689
+ const httpRateLimit = getHttpRateLimitConfig();
690
+ // Always register so every request path (including the auth hook) shares one
691
+ // limiter. When disabled via env, use a cap large enough to be inert in
692
+ // practice while keeping CodeQL/static setup able to see the plugin.
693
+ const httpRateLimitMax = httpRateLimit.enabled
694
+ ? httpRateLimit.max
695
+ : Number.MAX_SAFE_INTEGER;
696
+ await app.register(fastifyRateLimit, {
697
+ global: true,
698
+ max: httpRateLimitMax,
699
+ timeWindow: httpRateLimit.timeWindow,
700
+ keyGenerator: (req) => rateLimitKey(req, httpRateLimit.trustProxy),
701
+ allowList: (req) => isRateLimitExempt(rateLimitKey(req, httpRateLimit.trustProxy))
702
+ });
703
+ log.info(httpRateLimit.enabled
704
+ ? "Per-IP HTTP rate limiting enabled"
705
+ : "Per-IP HTTP rate limiting disabled (limiter registered with no practical cap)", {
706
+ max: httpRateLimitMax,
707
+ timeWindowMs: httpRateLimit.timeWindow,
708
+ trustProxy: httpRateLimit.trustProxy
709
+ });
710
+ // ---------------------------------------------------------------------------
711
+ // Auth
712
+ // ---------------------------------------------------------------------------
713
+ const supabaseUrl = process.env["SUPABASE_URL"];
714
+ const supabaseKey = process.env["SUPABASE_KEY"];
715
+ const supabaseMode = Boolean(supabaseUrl && supabaseKey);
716
+ const supabaseProvider = supabaseMode
717
+ ? new SupabaseAuthProvider({
718
+ supabaseUrl: supabaseUrl,
719
+ supabaseKey: supabaseKey
720
+ })
721
+ : null;
722
+ const localProvider = supabaseProvider ? null : new LocalAuthProvider();
723
+ // A server that enforces auth but never got an anon key serves a healthy
724
+ // /health and a well-formed /api/config while no user can log in. Say so at
725
+ // boot, where an operator reading the deploy log will see it.
726
+ const missingAnonKey = describeMissingAnonKey();
727
+ if (missingAnonKey) {
728
+ log.error(missingAnonKey);
530
729
  }
531
- }
532
- function logPythonBridgeDiagnostics(context) {
533
- const loadErrors = localBridge.getLoadErrors?.() ?? [];
534
- if (loadErrors.length === 0)
535
- return;
536
- log.warn(`Python bridge ${context} with ${loadErrors.length} load error(s)`);
537
- for (const entry of loadErrors.slice(0, 10)) {
538
- log.warn(`[python-worker][load-error] ${entry.module} (${entry.phase}): ${entry.error}`);
730
+ // Auth is enforced whenever a remote identity provider is configured.
731
+ const enforceAuth = supabaseMode;
732
+ // Whether loopback connections may bypass auth as user "1". Explicit
733
+ // NODETOOL_TRUST_LOCALHOST wins; otherwise this defaults off when auth is
734
+ // enforced (so reverse-proxied/containerized deployments — where the proxy
735
+ // connects from loopback — don't silently disable auth) and on for local dev.
736
+ const trustLocalhost = resolveTrustLocalhost({
737
+ envValue: process.env["NODETOOL_TRUST_LOCALHOST"],
738
+ enforceAuth
739
+ });
740
+ if (enforceAuth && trustLocalhost) {
741
+ log.warn("NODETOOL_TRUST_LOCALHOST is enabled while auth is enforced: any " +
742
+ "connection from loopback (including a reverse proxy) bypasses " +
743
+ "authentication. Unset it unless the proxy is fully trusted.");
539
744
  }
540
- if (loadErrors.length > 10) {
541
- log.warn(`[python-worker][load-error] ... ${loadErrors.length - 10} additional load error(s) omitted`);
745
+ // Source networks trusted to run as the local user "1" without a token. Needed
746
+ // for containerized self-hosting: Docker NATs a published-port connection to
747
+ // the bridge gateway (e.g. 172.17.0.1), so it never arrives from loopback and
748
+ // the isLoopbackAddress bypass can't fire. Honored ONLY in Local mode — when
749
+ // auth is enforced a CIDR must never bypass a real login, so the list is empty.
750
+ const trustLocalNetworks = enforceAuth
751
+ ? []
752
+ : parseTrustedLocalNetworks(process.env["NODETOOL_TRUST_LOCAL_NETWORKS"]);
753
+ // The bearer token `nodetool deploy` starts a container with. Local mode only,
754
+ // like the trusted networks above: Supabase mode needs a real login.
755
+ const serverAuthToken = enforceAuth
756
+ ? null
757
+ : resolveServerAuthToken(process.env["SERVER_AUTH_TOKEN"]);
758
+ // API users created with `nodetool deploy users-add` (the tRPC users router)
759
+ // live in USERS_FILE. Local mode only, for the same reason as the token above.
760
+ const apiUsers = enforceAuth ? null : new FileUserManager();
761
+ if (enforceAuth && process.env["SERVER_AUTH_TOKEN"]) {
762
+ log.warn("SERVER_AUTH_TOKEN is set while auth is enforced (Supabase mode); it is " +
763
+ "ignored. Clients must sign in.");
542
764
  }
543
- }
544
- pythonBridge.on("stderr", (msg) => {
545
- for (const line of msg.split("\n")) {
546
- logPythonWorkerStderr(line, log);
765
+ if (enforceAuth && process.env["NODETOOL_TRUST_LOCAL_NETWORKS"]) {
766
+ log.warn("NODETOOL_TRUST_LOCAL_NETWORKS is set while auth is enforced (Supabase " +
767
+ "mode); it is ignored so every request must present a valid token.");
547
768
  }
548
- });
549
- pythonBridge.on("error", (err) => {
550
- log.error(`Python bridge protocol error: ${err.message}`);
551
- pythonBridgeReady = false;
552
- });
553
- pythonBridge.on("exit", (code) => {
554
- log.warn(`Python worker exited with code ${code}`);
555
- pythonBridgeReady = false;
556
- });
557
- // Keep the attached worker's `last_activity_at` fresh so the cost guard's
558
- // reaper measures idle time from real bridge traffic, not from creation. The
559
- // throttling and the best-effort write live in @nodetool-ai/compute so the CLI
560
- // gets the same behaviour; a local stdio bridge emits no "activity", so this is
561
- // inert there.
562
- attachWorkerActivityHeartbeat(pythonBridge, {
563
- resolveInstanceId: async () => (await workerManager.getActiveWorker())?.id ?? null
564
- });
565
- // ---------------------------------------------------------------------------
566
- // TLS configuration
567
- // ---------------------------------------------------------------------------
568
- function findCert(envVar, filename) {
569
- const fromEnv = process.env[envVar];
570
- if (fromEnv && existsSync(fromEnv))
571
- return fromEnv;
572
- // Walk up from cwd looking for the cert file
573
- let dir = resolve(process.cwd());
574
- for (let i = 0; i < 5; i++) {
575
- const candidate = join(dir, filename);
576
- if (existsSync(candidate))
577
- return candidate;
578
- const parent = dirname(dir);
579
- if (parent === dir)
580
- break;
581
- dir = parent;
769
+ else if (trustLocalNetworks.length > 0) {
770
+ log.warn(`Local mode: trusting connections from ${trustLocalNetworks.join(", ")} ` +
771
+ 'as user "1" without authentication. Only expose this instance to ' +
772
+ "networks you trust.");
582
773
  }
583
- return undefined;
584
- }
585
- const tlsCertPath = findCert("TLS_CERT", "cert.pem");
586
- const tlsKeyPath = findCert("TLS_KEY", "key.pem");
587
- const tlsEnabled = Boolean(tlsCertPath && tlsKeyPath);
588
- let httpsOptions;
589
- if (tlsEnabled && tlsCertPath && tlsKeyPath) {
590
- httpsOptions = {
591
- cert: readFileSync(tlsCertPath),
592
- key: readFileSync(tlsKeyPath)
593
- };
594
- log.info("TLS enabled", { cert: tlsCertPath, key: tlsKeyPath });
595
- }
596
- // ---------------------------------------------------------------------------
597
- // Host / port
598
- // ---------------------------------------------------------------------------
599
- const port = Number(process.env["PORT"] ?? 7777);
600
- const isProduction = process.env["NODETOOL_ENV"] === "production";
601
- // In production, bind to all interfaces unless HOST is explicitly set
602
- const host = process.env["HOST"] ?? (isProduction ? "0.0.0.0" : "127.0.0.1");
603
- // The `/mcp` streamable-HTTP mount. Off in production by default: it carries
604
- // the full agent toolbelt for whichever user it binds, so a deployment opts in
605
- // with NODETOOL_ENABLE_MCP=1 and authenticates the agent like any other client.
606
- // The gate is shared with the settings UI, which reports it (see lib/mcp-mount).
607
- const mcpHttpEnabled = isMcpHttpEnabled();
608
- // The `WWW-Authenticate` challenge for an unauthenticated or invalid `/mcp`
609
- // request (docs/mcp-oauth-design.md § "401 challenge") comes from
610
- // `oauth/gate.ts` — the same gate the AS routes use, so the server never
611
- // advertises a discovery flow the AS answers with 404. A server where the
612
- // flow cannot complete answers the plain 401 it always has.
613
- // Reverse proxies whose X-Forwarded-For header may be trusted to determine the
614
- // real client IP. Comma-separated IPs/CIDRs (e.g. "127.0.0.1,10.0.0.0/8").
615
- // When empty, X-Forwarded-For is NOT trusted: req.ip falls back to the socket
616
- // peer address so clients cannot spoof their origin via headers.
617
- const trustedProxies = parseTrustedProxies(process.env["NODETOOL_TRUSTED_PROXIES"]);
618
- // ---------------------------------------------------------------------------
619
- // Fastify app
620
- // ---------------------------------------------------------------------------
621
- const serverOptions = {
622
- // Only trust X-Forwarded-For from explicitly configured proxies. With no
623
- // proxies configured this is `false`, so req.ip is the unspoofable socket
624
- // peer address.
625
- trustProxy: trustedProxies.length > 0 ? trustedProxies : false,
626
- bodyLimit: 100 * 1024 * 1024, // 100 MB
627
- logger: false,
628
- // tRPC's httpBatchLink encodes all batched procedure names into a single
629
- // URL path segment joined with commas (e.g. `/trpc/foo,bar,baz`). Fastify's
630
- // default `maxParamLength` of 100 rejects larger batches with a 404, and 2000
631
- // was not enough either: one model-picker mount produced a ~2900 char path.
632
- // The clients cap a batch at 20 procedures (`maxItems`); this is the backstop.
633
- routerOptions: {
634
- ignoreTrailingSlash: true,
635
- maxParamLength: 8000
636
- },
637
- genReqId: (req) => {
638
- return req.headers["x-request-id"] || crypto.randomUUID();
774
+ // Messaging integrations (Telegram, later Discord). The service token is what
775
+ // enables the surface: without it there are no integration routes and no
776
+ // delegated tokens to verify.
777
+ const integrationServiceToken = process.env["NODETOOL_INTEGRATION_TOKEN"];
778
+ const integrationsEnabled = Boolean(integrationServiceToken && integrationServiceToken.length >= 16);
779
+ if (integrationServiceToken && !integrationsEnabled) {
780
+ log.warn("NODETOOL_INTEGRATION_TOKEN is shorter than 16 characters and was " +
781
+ "ignored; the /api/integrations routes are not registered.");
639
782
  }
640
- };
641
- const app = Fastify(httpsOptions ? { https: httpsOptions, ...serverOptions } : serverOptions);
642
- // First hook, so every later hook and handler runs inside the request span.
643
- // ---------------------------------------------------------------------------
644
- // Request ID correlation
645
- // ---------------------------------------------------------------------------
646
- app.addHook("onRequest", async (request) => {
647
- request.log.info({ reqId: request.id, method: request.method, url: request.url }, "incoming request");
648
- });
649
- // A REST handler that throws a 5xx leaves a redacted trace. 4xx errors are
650
- // the caller's mistake and are not traced.
651
- app.addHook("onError", async (request, _reply, error) => {
652
- const status = error.statusCode ?? 500;
653
- if (status < 500)
654
- return;
655
- captureError(error, {
656
- source: "http",
657
- userId: request.userId ?? null,
658
- context: {
659
- route: request.routeOptions.url ?? request.url.split("?")[0],
660
- method: request.method,
661
- http_status: status,
662
- request_id: request.id
783
+ // Delegated tokens are signed with a key derived from the master key, so
784
+ // rotating the master key revokes every outstanding one. Derived lazily: a
785
+ // server that never mints one never reads the key.
786
+ let cachedDelegatedKey = null;
787
+ const delegatedSigningKey = () => (cachedDelegatedKey ??= deriveKey(getMasterKey(), "nodetool-delegated-token"));
788
+ const delegatedProvider = integrationsEnabled
789
+ ? new DelegatedTokenProvider(delegatedSigningKey)
790
+ : null;
791
+ // A deployed mini app's run sessions are signed with their own key, derived
792
+ // from the same master key under a different label. Rotating the master key
793
+ // invalidates every outstanding session; a distinct label keeps a session
794
+ // token from ever verifying as a delegated one, or the reverse.
795
+ let cachedAppSessionKey = null;
796
+ const appSessionSigningKey = () => (cachedAppSessionKey ??= deriveKey(getMasterKey(), "nodetool-app-session"));
797
+ // Public app deployments exist only in production; outside it the routes are
798
+ // still registered and every handler refuses, so the surface is decided by one
799
+ // predicate at request time rather than by boot order.
800
+ const appSessionProvider = new AppSessionTokenProvider(appSessionSigningKey);
801
+ app.decorateRequest("userId", null);
802
+ app.decorateRequest("authToken", null);
803
+ app.decorateRequest("appSession", null);
804
+ // Global @fastify/rate-limit (registered above) runs before this hook on every
805
+ // request, including public auth exemptions handled by isPublicAuthExemptRoute.
806
+ // CodeQL js/missing-rate-limiting does not attribute a global Fastify plugin to
807
+ // a hook handler, so it would read this as unlimited. It ships in
808
+ // security-extended, which our default-setup code scanning does not run, so it
809
+ // never fires here. Nothing excludes it either: default setup reads no config
810
+ // file, so suppressing an alert means dismissing it in the Security UI.
811
+ app.addHook("onRequest", async (req, reply) => {
812
+ // Let CORS preflight through — the @fastify/cors plugin handles OPTIONS responses
813
+ if (req.method === "OPTIONS")
814
+ return;
815
+ // Public routes — no auth required (still rate-limited globally above).
816
+ // Match the decoded path the router uses, or `/%61pi/...` skips auth.
817
+ const pathname = routedPathname(req.url);
818
+ if (pathname !== null &&
819
+ (isPublicAuthExemptRoute(pathname, req.method) ||
820
+ (isSdkV1DiscoveryRequest(pathname, req.method) &&
821
+ !isSdkV1AuthenticationRequired(process.env, enforceAuth)))) {
822
+ return;
663
823
  }
664
- });
665
- });
666
- app.addHook("onSend", async (request, reply) => {
667
- reply.header("X-Request-Id", request.id);
668
- // Baseline security response headers. The web app's CSP lives in a `<meta>`
669
- // tag, which browsers ignore for `frame-ancestors` — that directive is only
670
- // honoured in a header, so clickjacking protection has to be set here.
671
- //
672
- // `/api/storage/*` is exempt: those bytes are designed to be embedded from
673
- // other origins (MCP App iframes, the Electron renderer, external preview
674
- // clients — see storage-api.ts), and the endpoint deliberately serves
675
- // unknown extensions as `application/octet-stream` for `<img>`/`<video>` to
676
- // sniff. Both `nosniff` and the frame headers would break that contract.
677
- const path = request.url.split("?")[0] ?? "/";
678
- if (!path.startsWith("/api/storage/")) {
679
- reply.header("X-Content-Type-Options", "nosniff");
680
- reply.header("X-Frame-Options", "SAMEORIGIN");
681
- reply.header("Content-Security-Policy", "frame-ancestors 'self'");
682
- }
683
- reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
684
- });
685
- // CORS must be registered before hooks that can short-circuit. Auth failures
686
- // and rate-limit responses still need ACAO for first-party web deployments.
687
- await app.register(fastifyCors, { origin: corsOriginDelegate });
688
- // ---------------------------------------------------------------------------
689
- // Per-IP HTTP rate limiting
690
- // ---------------------------------------------------------------------------
691
- // Registered before the auth hook so floods are rejected with 429 before any
692
- // token verification work. Localhost is exempt; tune via NODETOOL_RATE_LIMIT_*.
693
- const httpRateLimit = getHttpRateLimitConfig();
694
- // Always register so every request path (including the auth hook) shares one
695
- // limiter. When disabled via env, use a cap large enough to be inert in
696
- // practice while keeping CodeQL/static setup able to see the plugin.
697
- const httpRateLimitMax = httpRateLimit.enabled
698
- ? httpRateLimit.max
699
- : Number.MAX_SAFE_INTEGER;
700
- await app.register(fastifyRateLimit, {
701
- global: true,
702
- max: httpRateLimitMax,
703
- timeWindow: httpRateLimit.timeWindow,
704
- keyGenerator: (req) => rateLimitKey(req, httpRateLimit.trustProxy),
705
- allowList: (req) => isRateLimitExempt(rateLimitKey(req, httpRateLimit.trustProxy))
706
- });
707
- log.info(httpRateLimit.enabled
708
- ? "Per-IP HTTP rate limiting enabled"
709
- : "Per-IP HTTP rate limiting disabled (limiter registered with no practical cap)", {
710
- max: httpRateLimitMax,
711
- timeWindowMs: httpRateLimit.timeWindow,
712
- trustProxy: httpRateLimit.trustProxy
713
- });
714
- // ---------------------------------------------------------------------------
715
- // Auth
716
- // ---------------------------------------------------------------------------
717
- const supabaseUrl = process.env["SUPABASE_URL"];
718
- const supabaseKey = process.env["SUPABASE_KEY"];
719
- const supabaseMode = Boolean(supabaseUrl && supabaseKey);
720
- const supabaseProvider = supabaseMode
721
- ? new SupabaseAuthProvider({
722
- supabaseUrl: supabaseUrl,
723
- supabaseKey: supabaseKey
724
- })
725
- : null;
726
- const localProvider = supabaseProvider ? null : new LocalAuthProvider();
727
- // A server that enforces auth but never got an anon key serves a healthy
728
- // /health and a well-formed /api/config while no user can log in. Say so at
729
- // boot, where an operator reading the deploy log will see it.
730
- const missingAnonKey = describeMissingAnonKey();
731
- if (missingAnonKey) {
732
- log.error(missingAnonKey);
733
- }
734
- // Auth is enforced whenever a remote identity provider is configured.
735
- const enforceAuth = supabaseMode;
736
- // Whether loopback connections may bypass auth as user "1". Explicit
737
- // NODETOOL_TRUST_LOCALHOST wins; otherwise this defaults off when auth is
738
- // enforced (so reverse-proxied/containerized deployments — where the proxy
739
- // connects from loopback — don't silently disable auth) and on for local dev.
740
- const trustLocalhost = resolveTrustLocalhost({
741
- envValue: process.env["NODETOOL_TRUST_LOCALHOST"],
742
- enforceAuth
743
- });
744
- if (enforceAuth && trustLocalhost) {
745
- log.warn("NODETOOL_TRUST_LOCALHOST is enabled while auth is enforced: any " +
746
- "connection from loopback (including a reverse proxy) bypasses " +
747
- "authentication. Unset it unless the proxy is fully trusted.");
748
- }
749
- // Source networks trusted to run as the local user "1" without a token. Needed
750
- // for containerized self-hosting: Docker NATs a published-port connection to
751
- // the bridge gateway (e.g. 172.17.0.1), so it never arrives from loopback and
752
- // the isLoopbackAddress bypass can't fire. Honored ONLY in Local mode — when
753
- // auth is enforced a CIDR must never bypass a real login, so the list is empty.
754
- const trustLocalNetworks = enforceAuth
755
- ? []
756
- : parseTrustedLocalNetworks(process.env["NODETOOL_TRUST_LOCAL_NETWORKS"]);
757
- // The bearer token `nodetool deploy` starts a container with. Local mode only,
758
- // like the trusted networks above: Supabase mode needs a real login.
759
- const serverAuthToken = enforceAuth
760
- ? null
761
- : resolveServerAuthToken(process.env["SERVER_AUTH_TOKEN"]);
762
- // API users created with `nodetool deploy users-add` (the tRPC users router)
763
- // live in USERS_FILE. Local mode only, for the same reason as the token above.
764
- const apiUsers = enforceAuth ? null : new FileUserManager();
765
- if (enforceAuth && process.env["SERVER_AUTH_TOKEN"]) {
766
- log.warn("SERVER_AUTH_TOKEN is set while auth is enforced (Supabase mode); it is " +
767
- "ignored. Clients must sign in.");
768
- }
769
- if (enforceAuth && process.env["NODETOOL_TRUST_LOCAL_NETWORKS"]) {
770
- log.warn("NODETOOL_TRUST_LOCAL_NETWORKS is set while auth is enforced (Supabase " +
771
- "mode); it is ignored so every request must present a valid token.");
772
- }
773
- else if (trustLocalNetworks.length > 0) {
774
- log.warn(`Local mode: trusting connections from ${trustLocalNetworks.join(", ")} ` +
775
- 'as user "1" without authentication. Only expose this instance to ' +
776
- "networks you trust.");
777
- }
778
- // Messaging integrations (Telegram, later Discord). The service token is what
779
- // enables the surface: without it there are no integration routes and no
780
- // delegated tokens to verify.
781
- const integrationServiceToken = process.env["NODETOOL_INTEGRATION_TOKEN"];
782
- const integrationsEnabled = Boolean(integrationServiceToken && integrationServiceToken.length >= 16);
783
- if (integrationServiceToken && !integrationsEnabled) {
784
- log.warn("NODETOOL_INTEGRATION_TOKEN is shorter than 16 characters and was " +
785
- "ignored; the /api/integrations routes are not registered.");
786
- }
787
- // Delegated tokens are signed with a key derived from the master key, so
788
- // rotating the master key revokes every outstanding one. Derived lazily: a
789
- // server that never mints one never reads the key.
790
- let cachedDelegatedKey = null;
791
- const delegatedSigningKey = () => (cachedDelegatedKey ??= deriveKey(getMasterKey(), "nodetool-delegated-token"));
792
- const delegatedProvider = integrationsEnabled
793
- ? new DelegatedTokenProvider(delegatedSigningKey)
794
- : null;
795
- // A deployed mini app's run sessions are signed with their own key, derived
796
- // from the same master key under a different label. Rotating the master key
797
- // invalidates every outstanding session; a distinct label keeps a session
798
- // token from ever verifying as a delegated one, or the reverse.
799
- let cachedAppSessionKey = null;
800
- const appSessionSigningKey = () => (cachedAppSessionKey ??= deriveKey(getMasterKey(), "nodetool-app-session"));
801
- // Public app deployments exist only in production; outside it the routes are
802
- // still registered and every handler refuses, so the surface is decided by one
803
- // predicate at request time rather than by boot order.
804
- const appSessionProvider = new AppSessionTokenProvider(appSessionSigningKey);
805
- app.decorateRequest("userId", null);
806
- app.decorateRequest("authToken", null);
807
- app.decorateRequest("appSession", null);
808
- // Global @fastify/rate-limit (registered above) runs before this hook on every
809
- // request, including public auth exemptions handled by isPublicAuthExemptRoute.
810
- // CodeQL js/missing-rate-limiting does not attribute a global Fastify plugin to
811
- // a hook handler, so it would read this as unlimited. It ships in
812
- // security-extended, which our default-setup code scanning does not run, so it
813
- // never fires here. Nothing excludes it either: default setup reads no config
814
- // file, so suppressing an alert means dismissing it in the Security UI.
815
- app.addHook("onRequest", async (req, reply) => {
816
- // Let CORS preflight through — the @fastify/cors plugin handles OPTIONS responses
817
- if (req.method === "OPTIONS")
818
- return;
819
- // Public routes — no auth required (still rate-limited globally above).
820
- // Match the decoded path the router uses, or `/%61pi/...` skips auth.
821
- const pathname = routedPathname(req.url);
822
- if (pathname !== null &&
823
- (isPublicAuthExemptRoute(pathname, req.method) ||
824
- (isSdkV1DiscoveryRequest(pathname, req.method) &&
825
- !isSdkV1AuthenticationRequired(process.env, enforceAuth)))) {
826
- return;
827
- }
828
- // Static frontend assets don't require auth (served by fastifyStatic).
829
- if (hasStaticApp && isStaticAppRequest(pathname, req.method)) {
830
- return;
831
- }
832
- // req.ip reflects X-Forwarded-For only when the request arrived through a
833
- // configured trusted proxy (see NODETOOL_TRUSTED_PROXIES); otherwise it is
834
- // the unspoofable socket peer address.
835
- const clientIp = req.ip;
836
- // Loopback connections bypass auth as user "1" only when explicitly trusted.
837
- // Behind a reverse proxy/container/SSH tunnel the proxy itself connects from
838
- // loopback, so this is gated by NODETOOL_TRUST_LOCALHOST (off by default when
839
- // auth is enforced). Docker's bridge NAT means the peer is the gateway rather
840
- // than loopback, so NODETOOL_TRUST_LOCAL_NETWORKS additionally trusts that
841
- // source range (Local mode only).
842
- if ((trustLocalhost && isLoopbackAddress(clientIp)) ||
843
- isTrustedLocalAddress(clientIp, trustLocalNetworks)) {
844
- req.userId = "1";
845
- return;
846
- }
847
- // Extract token from the appropriate source
848
- const isWs = isWebSocketUpgrade(req);
849
- const searchParams = new URLSearchParams(req.url.split("?")[1] ?? "");
850
- const provider = supabaseProvider ?? localProvider;
851
- const token = isWs
852
- ? provider.extractTokenFromWs(req.headers, searchParams)
853
- : provider.extractTokenFromHeaders(req.headers);
854
- // MCP OAuth access tokens (`nta_`) authorize `/mcp` ONLY — the audience
855
- // binding is structural, not claim-parsing (docs/mcp-oauth-design.md §
856
- // "Token model and storage"). A valid token whose stored `resource` no
857
- // longer matches this deployment's canonical `/mcp` URI (the server was
858
- // rehosted since it was minted) or that is presented outside `/mcp` is
859
- // denied with `invalid_token`, never silently ignored — falling through to
860
- // the `ntk_`/session checks below would let an audience-scoped credential
861
- // be probed against the rest of the API.
862
- if (token && token.startsWith(MCP_OAUTH_ACCESS_TOKEN_PREFIX)) {
863
- const decision = await authenticateMcpAccessToken(token, req.url.split("?")[0] ?? "/");
864
- if (decision.ok) {
865
- req.userId = decision.userId;
866
- req.authToken = token;
824
+ // Static frontend assets don't require auth (served by fastifyStatic).
825
+ if (hasStaticApp && isStaticAppRequest(pathname, req.method)) {
867
826
  return;
868
827
  }
869
- denyUnauthorized(req, reply, { error: "invalid_token" }, decision.challenge);
870
- return;
871
- }
872
- // Access tokens (`ntk_`) are the credential a person mints in the app and
873
- // pastes into an MCP client's config. They authenticate in every auth mode,
874
- // including local: a self-hoster exposing /mcp beyond loopback should be
875
- // able to hand out a revocable per-agent token rather than widen
876
- // NODETOOL_TRUST_LOCAL_NETWORKS. A token that fails here falls through, so
877
- // an expired or revoked one is refused by the mode's own rules.
878
- if (token && isAccessToken(token)) {
879
- const accessToken = await AccessToken.verify(token);
880
- if (accessToken) {
881
- req.userId = accessToken.user_id;
882
- req.authToken = token;
883
- void accessToken.touch().catch(() => {
884
- // last_used_at is a convenience for the revoke UI; a failed write
885
- // must not fail the request it was recording.
886
- });
828
+ // req.ip reflects X-Forwarded-For only when the request arrived through a
829
+ // configured trusted proxy (see NODETOOL_TRUSTED_PROXIES); otherwise it is
830
+ // the unspoofable socket peer address.
831
+ const clientIp = req.ip;
832
+ // Loopback connections bypass auth as user "1" only when explicitly trusted.
833
+ // Behind a reverse proxy/container/SSH tunnel the proxy itself connects from
834
+ // loopback, so this is gated by NODETOOL_TRUST_LOCALHOST (off by default when
835
+ // auth is enforced). Docker's bridge NAT means the peer is the gateway rather
836
+ // than loopback, so NODETOOL_TRUST_LOCAL_NETWORKS additionally trusts that
837
+ // source range (Local mode only).
838
+ if ((trustLocalhost && isLoopbackAddress(clientIp)) ||
839
+ isTrustedLocalAddress(clientIp, trustLocalNetworks)) {
840
+ req.userId = "1";
887
841
  return;
888
842
  }
889
- }
890
- // A deployed mini app's session token (`nda_`) authenticates a visitor who
891
- // has the app's hidden URL. It carries the owner's user id *and* the one
892
- // application it may act on, and its whole point is that it reaches nothing
893
- // else — so the confinement is applied right here, at the door, rather than
894
- // trusted to every handler downstream. `/ws` is the only path it opens: the
895
- // run transport, and exactly that path — `/ws/download` is model downloads,
896
- // which a visitor to somebody's app has no business opening. The public app routes
897
- // need no token at all (they are auth-exempt above), and every other path —
898
- // tRPC, the REST library, assets, settings — is refused, because the
899
- // owner's account is not what the visitor was given a link to.
900
- //
901
- // A failed verification does *not* fall through to the providers below. An
902
- // expired or tampered app session must be refused as itself; letting it
903
- // reach the session check would turn a confined credential into a probe
904
- // against the rest of the API.
905
- if (token && isAppSessionToken(token)) {
906
- const session = await appSessionProvider.verifyToken(token);
907
- if (session.ok &&
908
- session.applicationId &&
909
- session.userId &&
910
- appDeploymentsEnabled() &&
911
- pathname === "/ws") {
912
- req.userId = session.userId;
913
- req.authToken = token;
914
- req.appSession = {
915
- applicationId: session.applicationId,
916
- version: session.applicationVersion ?? 0
917
- };
843
+ // Extract token from the appropriate source
844
+ const isWs = isWebSocketUpgrade(req);
845
+ const searchParams = new URLSearchParams(req.url.split("?")[1] ?? "");
846
+ const provider = supabaseProvider ?? localProvider;
847
+ const token = isWs
848
+ ? provider.extractTokenFromWs(req.headers, searchParams)
849
+ : provider.extractTokenFromHeaders(req.headers);
850
+ // MCP OAuth access tokens (`nta_`) authorize `/mcp` ONLY — the audience
851
+ // binding is structural, not claim-parsing (docs/mcp-oauth-design.md §
852
+ // "Token model and storage"). A valid token whose stored `resource` no
853
+ // longer matches this deployment's canonical `/mcp` URI (the server was
854
+ // rehosted since it was minted) or that is presented outside `/mcp` is
855
+ // denied with `invalid_token`, never silently ignored — falling through to
856
+ // the `ntk_`/session checks below would let an audience-scoped credential
857
+ // be probed against the rest of the API.
858
+ if (token && token.startsWith(MCP_OAUTH_ACCESS_TOKEN_PREFIX)) {
859
+ const decision = await authenticateMcpAccessToken(token, req.url.split("?")[0] ?? "/");
860
+ if (decision.ok) {
861
+ req.userId = decision.userId;
862
+ req.authToken = token;
863
+ return;
864
+ }
865
+ denyUnauthorized(req, reply, { error: "invalid_token" }, decision.challenge);
918
866
  return;
919
867
  }
920
- denyUnauthorized(req, reply, { error: "Unauthorized" });
921
- return;
922
- }
923
- // Delegated tokens (a messaging bridge acting as the linked user) are
924
- // checked only when they announce themselves by prefix. Anything else —
925
- // including a delegated token that is expired or tampered with — falls
926
- // through to the configured provider, which rejects it.
927
- if (delegatedProvider && token && isDelegatedToken(token)) {
928
- const delegated = await delegatedProvider.verifyToken(token);
929
- if (delegated.ok) {
930
- req.userId = delegated.userId ?? null;
868
+ // Access tokens (`ntk_`) are the credential a person mints in the app and
869
+ // pastes into an MCP client's config. They authenticate in every auth mode,
870
+ // including local: a self-hoster exposing /mcp beyond loopback should be
871
+ // able to hand out a revocable per-agent token rather than widen
872
+ // NODETOOL_TRUST_LOCAL_NETWORKS. A token that fails here falls through, so
873
+ // an expired or revoked one is refused by the mode's own rules.
874
+ if (token && isAccessToken(token)) {
875
+ const accessToken = await AccessToken.verify(token);
876
+ if (accessToken) {
877
+ req.userId = accessToken.user_id;
878
+ req.authToken = token;
879
+ void accessToken.touch().catch(() => {
880
+ // last_used_at is a convenience for the revoke UI; a failed write
881
+ // must not fail the request it was recording.
882
+ });
883
+ return;
884
+ }
885
+ }
886
+ // A deployed mini app's session token (`nda_`) authenticates a visitor who
887
+ // has the app's hidden URL. It carries the owner's user id *and* the one
888
+ // application it may act on, and its whole point is that it reaches nothing
889
+ // else — so the confinement is applied right here, at the door, rather than
890
+ // trusted to every handler downstream. `/ws` is the only path it opens: the
891
+ // run transport, and exactly that path — `/ws/download` is model downloads,
892
+ // which a visitor to somebody's app has no business opening. The public app routes
893
+ // need no token at all (they are auth-exempt above), and every other path —
894
+ // tRPC, the REST library, assets, settings — is refused, because the
895
+ // owner's account is not what the visitor was given a link to.
896
+ //
897
+ // A failed verification does *not* fall through to the providers below. An
898
+ // expired or tampered app session must be refused as itself; letting it
899
+ // reach the session check would turn a confined credential into a probe
900
+ // against the rest of the API.
901
+ if (token && isAppSessionToken(token)) {
902
+ const session = await appSessionProvider.verifyToken(token);
903
+ if (session.ok &&
904
+ session.applicationId &&
905
+ session.userId &&
906
+ appDeploymentsEnabled() &&
907
+ pathname === "/ws") {
908
+ req.userId = session.userId;
909
+ req.authToken = token;
910
+ req.appSession = {
911
+ applicationId: session.applicationId,
912
+ version: session.applicationVersion ?? 0
913
+ };
914
+ return;
915
+ }
916
+ denyUnauthorized(req, reply, { error: "Unauthorized" });
917
+ return;
918
+ }
919
+ // Delegated tokens (a messaging bridge acting as the linked user) are
920
+ // checked only when they announce themselves by prefix. Anything else —
921
+ // including a delegated token that is expired or tampered with — falls
922
+ // through to the configured provider, which rejects it.
923
+ if (delegatedProvider && token && isDelegatedToken(token)) {
924
+ const delegated = await delegatedProvider.verifyToken(token);
925
+ if (delegated.ok) {
926
+ req.userId = delegated.userId ?? null;
927
+ req.authToken = token;
928
+ return;
929
+ }
930
+ }
931
+ // /mcp gets a WWW-Authenticate challenge on every unauthenticated/invalid
932
+ // denial below it — but only when the OAuth flow can actually complete
933
+ // (see mcpBearerChallenge). Every other path is unaffected.
934
+ const challenge = pathname?.startsWith("/mcp")
935
+ ? mcpBearerChallenge()
936
+ : undefined;
937
+ if (supabaseMode) {
938
+ if (!token) {
939
+ denyUnauthorized(req, reply, { error: "Unauthorized" }, challenge);
940
+ return;
941
+ }
942
+ const result = await supabaseProvider.verifyToken(token);
943
+ if (!result.ok) {
944
+ denyUnauthorized(req, reply, { error: result.error ?? "Unauthorized" }, challenge);
945
+ return;
946
+ }
947
+ req.userId = result.userId ?? null;
931
948
  req.authToken = token;
932
949
  return;
933
950
  }
934
- }
935
- // /mcp gets a WWW-Authenticate challenge on every unauthenticated/invalid
936
- // denial below it — but only when the OAuth flow can actually complete
937
- // (see mcpBearerChallenge). Every other path is unaffected.
938
- const challenge = pathname?.startsWith("/mcp")
939
- ? mcpBearerChallenge()
940
- : undefined;
941
- if (supabaseMode) {
942
- if (!token) {
943
- denyUnauthorized(req, reply, { error: "Unauthorized" }, challenge);
951
+ if (matchesServerAuthToken(token, serverAuthToken)) {
952
+ req.userId = "1";
953
+ req.authToken = token;
944
954
  return;
945
955
  }
946
- const result = await supabaseProvider.verifyToken(token);
947
- if (!result.ok) {
948
- denyUnauthorized(req, reply, { error: result.error ?? "Unauthorized" }, challenge);
956
+ const apiUser = token && apiUsers ? await apiUsers.verifyToken(token) : null;
957
+ if (apiUser) {
958
+ req.userId = apiUser.id;
959
+ req.authToken = token;
949
960
  return;
950
961
  }
951
- req.userId = result.userId ?? null;
952
- req.authToken = token;
953
- return;
954
- }
955
- if (matchesServerAuthToken(token, serverAuthToken)) {
956
- req.userId = "1";
957
- req.authToken = token;
958
- return;
959
- }
960
- const apiUser = token && apiUsers ? await apiUsers.verifyToken(token) : null;
961
- if (apiUser) {
962
- req.userId = apiUser.id;
963
- req.authToken = token;
964
- return;
965
- }
966
- denyUnauthorized(req, reply, { error: "Remote access requires authentication" }, challenge);
967
- });
968
- registerHttpTracing(app, {
969
- async authorizeTraceParent(request, parent) {
970
- if (!request.userId || request.appSession) {
971
- return false;
962
+ denyUnauthorized(req, reply, { error: "Remote access requires authentication" }, challenge);
963
+ });
964
+ registerHttpTracing(app, {
965
+ async authorizeTraceParent(request, parent) {
966
+ if (!request.userId || request.appSession) {
967
+ return false;
968
+ }
969
+ return Boolean(await getRegisteredRunTrace(request.userId, parent.traceId));
972
970
  }
973
- return Boolean(await getRegisteredRunTrace(request.userId, parent.traceId));
971
+ });
972
+ // Multi-instance only: a handshake asking to resume a run that another machine
973
+ // owns is answered with a fly-replay instruction instead of being accepted
974
+ // here, so the client reconnects onto the process holding the run's session.
975
+ // Registered after the auth hook because it scopes the lookup to req.userId.
976
+ // Inert (one env read, no DB) without NODETOOL_INSTANCE_ID / FLY_MACHINE_ID.
977
+ app.addHook("onRequest", async (req, reply) => {
978
+ await replayUpgradeToOwner(req, reply);
979
+ });
980
+ // A machine that is draining takes no new sockets: the handshake is answered
981
+ // with 503 rather than accepted, so the client's reconnect backoff carries it
982
+ // to a machine that is staying. Its /health is already failing, so the proxy
983
+ // normally routes elsewhere before this is reached; this covers the window
984
+ // between the drain starting and the next health check.
985
+ app.addHook("onRequest", async (req, reply) => {
986
+ if (!isDraining() || !isWebSocketUpgrade(req))
987
+ return;
988
+ const path = req.url.split("?")[0] ?? "";
989
+ if (path !== "/ws" && !path.startsWith("/ws/"))
990
+ return;
991
+ denyDraining(req, reply);
992
+ });
993
+ // WebSocket support
994
+ await app.register(fastifyWebSocket);
995
+ // Parse all request bodies as raw Buffer — the Web API handlers do their own parsing
996
+ app.removeAllContentTypeParsers();
997
+ app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => {
998
+ done(null, body);
999
+ });
1000
+ // ---------------------------------------------------------------------------
1001
+ // Examples directory detection
1002
+ // ---------------------------------------------------------------------------
1003
+ // Resolve examples directory so that example workflow JSON files can be served
1004
+ // without requiring a Python installation.
1005
+ //
1006
+ // Resolution order:
1007
+ // 1. NODETOOL_BASE_EXAMPLES_DIR env var (explicit override)
1008
+ // 2. Co-located with server entry point — used when the backend is bundled
1009
+ // (e.g. resources/backend/server.mjs → resources/backend/examples/nodetool-base/)
1010
+ // 3. Monorepo layout — used in development and dist runs where server.js lives
1011
+ // inside packages/websocket/src/ or packages/websocket/dist/
1012
+ const _serverDir = dirname(fileURLToPath(import.meta.url));
1013
+ const _envExamplesDir = process.env["NODETOOL_BASE_EXAMPLES_DIR"];
1014
+ const _bundledExamplesDir = resolve(_serverDir, "examples", "nodetool-base");
1015
+ const _bundledAssetsDir = resolve(_serverDir, "assets", "nodetool-base");
1016
+ const _monoExamplesDir = resolve(_serverDir, "..", "..", "..", "packages", "base-nodes", "nodetool", "examples", "nodetool-base");
1017
+ const _examplesDirCandidates = [
1018
+ _envExamplesDir && existsSync(_envExamplesDir) ? _envExamplesDir : null,
1019
+ existsSync(_bundledExamplesDir) ? _bundledExamplesDir : null,
1020
+ existsSync(_monoExamplesDir) ? _monoExamplesDir : null
1021
+ ];
1022
+ const _resolvedExamplesDir = _examplesDirCandidates.find((d) => Boolean(d)) ?? null;
1023
+ if (_resolvedExamplesDir) {
1024
+ log.info(`Examples directory resolved: ${_resolvedExamplesDir}`);
974
1025
  }
975
- });
976
- // Multi-instance only: a handshake asking to resume a run that another machine
977
- // owns is answered with a fly-replay instruction instead of being accepted
978
- // here, so the client reconnects onto the process holding the run's session.
979
- // Registered after the auth hook because it scopes the lookup to req.userId.
980
- // Inert (one env read, no DB) without NODETOOL_INSTANCE_ID / FLY_MACHINE_ID.
981
- app.addHook("onRequest", async (req, reply) => {
982
- await replayUpgradeToOwner(req, reply);
983
- });
984
- // A machine that is draining takes no new sockets: the handshake is answered
985
- // with 503 rather than accepted, so the client's reconnect backoff carries it
986
- // to a machine that is staying. Its /health is already failing, so the proxy
987
- // normally routes elsewhere before this is reached; this covers the window
988
- // between the drain starting and the next health check.
989
- app.addHook("onRequest", async (req, reply) => {
990
- if (!isDraining() || !isWebSocketUpgrade(req))
991
- return;
992
- const path = req.url.split("?")[0] ?? "";
993
- if (path !== "/ws" && !path.startsWith("/ws/"))
994
- return;
995
- denyDraining(req, reply);
996
- });
997
- // WebSocket support
998
- await app.register(fastifyWebSocket);
999
- // Parse all request bodies as raw Buffer — the Web API handlers do their own parsing
1000
- app.removeAllContentTypeParsers();
1001
- app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => {
1002
- done(null, body);
1003
- });
1004
- // ---------------------------------------------------------------------------
1005
- // Examples directory detection
1006
- // ---------------------------------------------------------------------------
1007
- // Resolve examples directory so that example workflow JSON files can be served
1008
- // without requiring a Python installation.
1009
- //
1010
- // Resolution order:
1011
- // 1. NODETOOL_BASE_EXAMPLES_DIR env var (explicit override)
1012
- // 2. Co-located with server entry point — used when the backend is bundled
1013
- // (e.g. resources/backend/server.mjs → resources/backend/examples/nodetool-base/)
1014
- // 3. Monorepo layout — used in development and dist runs where server.js lives
1015
- // inside packages/websocket/src/ or packages/websocket/dist/
1016
- const _serverDir = dirname(fileURLToPath(import.meta.url));
1017
- const _envExamplesDir = process.env["NODETOOL_BASE_EXAMPLES_DIR"];
1018
- const _bundledExamplesDir = resolve(_serverDir, "examples", "nodetool-base");
1019
- const _bundledAssetsDir = resolve(_serverDir, "assets", "nodetool-base");
1020
- const _monoExamplesDir = resolve(_serverDir, "..", "..", "..", "packages", "base-nodes", "nodetool", "examples", "nodetool-base");
1021
- const _examplesDirCandidates = [
1022
- _envExamplesDir && existsSync(_envExamplesDir) ? _envExamplesDir : null,
1023
- existsSync(_bundledExamplesDir) ? _bundledExamplesDir : null,
1024
- existsSync(_monoExamplesDir) ? _monoExamplesDir : null
1025
- ];
1026
- const _resolvedExamplesDir = _examplesDirCandidates.find((d) => Boolean(d)) ?? null;
1027
- if (_resolvedExamplesDir) {
1028
- log.info(`Examples directory resolved: ${_resolvedExamplesDir}`);
1029
- }
1030
- else {
1031
- log.warn("Examples directory not found — template workflows will be unavailable");
1032
- }
1033
- // ---------------------------------------------------------------------------
1034
- // API options for HTTP route handlers
1035
- // ---------------------------------------------------------------------------
1036
- const sdkLiveRunnerRegistry = new SdkLiveRunnerRegistry();
1037
- const frontendRendererRegistry = new FrontendRendererRegistry();
1038
- const resolveExecutionTarget = createSdkV1ExecutionTargetReadiness({
1039
- getActiveWorker: () => workerManager.getActiveWorker()
1040
- });
1041
- function listRegistryRecommendedModels() {
1042
- const models = [];
1043
- for (const metadata of registry.listMetadata()) {
1044
- for (const candidate of metadata.recommended_models ?? []) {
1045
- const parsed = unifiedModel.safeParse(candidate);
1046
- if (parsed.success) {
1047
- models.push({
1048
- ...parsed.data,
1049
- provider: parsed.data.provider == null ? undefined : parsed.data.provider
1050
- });
1026
+ else {
1027
+ log.warn("Examples directory not found — template workflows will be unavailable");
1028
+ }
1029
+ // ---------------------------------------------------------------------------
1030
+ // API options for HTTP route handlers
1031
+ // ---------------------------------------------------------------------------
1032
+ const sdkLiveRunnerRegistry = new SdkLiveRunnerRegistry();
1033
+ const frontendRendererRegistry = new FrontendRendererRegistry();
1034
+ const resolveExecutionTarget = createSdkV1ExecutionTargetReadiness({
1035
+ getActiveWorker: () => workerManager.getActiveWorker()
1036
+ });
1037
+ function listRegistryRecommendedModels() {
1038
+ const models = [];
1039
+ for (const metadata of registry.listMetadata()) {
1040
+ for (const candidate of metadata.recommended_models ?? []) {
1041
+ const parsed = unifiedModel.safeParse(candidate);
1042
+ if (parsed.success) {
1043
+ models.push({
1044
+ ...parsed.data,
1045
+ provider: parsed.data.provider == null ? undefined : parsed.data.provider
1046
+ });
1047
+ }
1051
1048
  }
1052
1049
  }
1050
+ return models;
1053
1051
  }
1054
- return models;
1055
- }
1056
- const sdkModelDownloadService = createSdkV1ModelDownloadService({
1057
- startWorkerDownload: (userId, request, operationId, onProgress) => relayWorkerDownload({
1058
- send: (data) => onProgress(JSON.parse(data))
1059
- }, pythonBridge, workerManager, {
1060
- command: "start_download",
1061
- repo_id: request.repo_id,
1062
- path: request.path ?? null,
1063
- allow_patterns: request.allow_patterns ?? null,
1064
- ignore_patterns: request.ignore_patterns ?? null,
1065
- model_type: request.model_type,
1066
- scope: "worker"
1067
- }, operationId, userId),
1068
- cancelWorkerDownload: (operationId) => pythonBridge.cancelModelDownload(operationId)
1069
- });
1070
- const apiOptions = {
1071
- metadataRoots,
1072
- registry,
1073
- getPythonBridgeReady,
1074
- sdkV1Boundary: createSdkV1ImplementationBoundary(createSdkV1Service({
1075
- getCapabilities: createNodeToolSdkV1CapabilitiesProvider({
1076
- nodetoolVersion: getVersion(),
1077
- registry,
1078
- pythonBridge: () => (getPythonBridgeReady() ? "ready" : "starting"),
1079
- profiles: {
1080
- discovery: "available",
1081
- execution: "available",
1082
- preflight: "available",
1083
- model_catalog: "available",
1084
- model_download: "available",
1085
- temporary_asset_upload: "available"
1086
- },
1087
- authModes: enforceAuth ? ["bearer"] : ["trusted_local"],
1088
- assetUriSchemes: ["asset"],
1089
- limits: {
1090
- maxRpcBatch: 100,
1091
- // The initial SDK profile deliberately prefers asset references for
1092
- // media instead of promising an inline payload size.
1093
- maxInlineBytes: 0,
1094
- maxQueuedJobs: 0,
1095
- maxJobEventReplay: 0,
1096
- requestTimeoutSeconds: 30
1097
- }
1098
- }),
1099
- preflightService: createNodeToolSdkV1PreflightService({
1100
- registry,
1101
- getPythonBridgeReady,
1102
- getExecutionTargetReadiness: ({ request, principal }) => {
1103
- const target = request.execution_target;
1104
- if (target?.kind === "runner") {
1105
- const ready = sdkLiveRunnerRegistry.has(target.runner_id, principal.userId);
1106
- return {
1107
- id: target.runner_id,
1108
- name: "Live runner",
1109
- ready,
1110
- message: ready ? null : "Selected live runner is not available."
1111
- };
1112
- }
1113
- return resolveExecutionTarget(target);
1114
- },
1115
- getExecutionCapacitySnapshot: ({ request, principal }) => {
1116
- const target = request.execution_target;
1117
- if (target?.kind !== "runner") {
1118
- throw new Error("Execution capacity requires a selected live runner.");
1052
+ const sdkModelDownloadService = createSdkV1ModelDownloadService({
1053
+ startWorkerDownload: (userId, request, operationId, onProgress) => relayWorkerDownload({
1054
+ send: (data) => onProgress(JSON.parse(data))
1055
+ }, pythonBridge, workerManager, {
1056
+ command: "start_download",
1057
+ repo_id: request.repo_id,
1058
+ path: request.path ?? null,
1059
+ allow_patterns: request.allow_patterns ?? null,
1060
+ ignore_patterns: request.ignore_patterns ?? null,
1061
+ model_type: request.model_type,
1062
+ scope: "worker"
1063
+ }, operationId, userId),
1064
+ cancelWorkerDownload: (operationId) => pythonBridge.cancelModelDownload(operationId)
1065
+ });
1066
+ const apiOptions = {
1067
+ metadataRoots,
1068
+ registry,
1069
+ getPythonBridgeReady,
1070
+ sdkV1Boundary: createSdkV1ImplementationBoundary(createSdkV1Service({
1071
+ getCapabilities: createNodeToolSdkV1CapabilitiesProvider({
1072
+ nodetoolVersion: getVersion(),
1073
+ registry,
1074
+ pythonBridge: () => (getPythonBridgeReady() ? "ready" : "starting"),
1075
+ profiles: {
1076
+ discovery: "available",
1077
+ execution: "available",
1078
+ preflight: "available",
1079
+ model_catalog: "available",
1080
+ model_download: "available",
1081
+ temporary_asset_upload: "available"
1082
+ },
1083
+ authModes: enforceAuth ? ["bearer"] : ["trusted_local"],
1084
+ assetUriSchemes: ["asset"],
1085
+ limits: {
1086
+ maxRpcBatch: 100,
1087
+ // The initial SDK profile deliberately prefers asset references for
1088
+ // media instead of promising an inline payload size.
1089
+ maxInlineBytes: 0,
1090
+ maxQueuedJobs: 0,
1091
+ maxJobEventReplay: 0,
1092
+ requestTimeoutSeconds: 30
1119
1093
  }
1120
- return sdkLiveRunnerRegistry.getCapacity({
1121
- runnerId: target.runner_id,
1122
- userId: principal.userId,
1123
- workflowId: request.workflow_id,
1124
- concurrent: target.concurrent
1125
- });
1126
- }
1127
- }),
1128
- modelCatalogService: {
1129
- list: (args) => getSdkV1ModelCatalog({
1130
- ...args,
1131
- recommendedModels: listRegistryRecommendedModels(),
1132
- getWorkerModels: async () => {
1133
- const activeWorker = await workerManager?.getActiveWorker();
1134
- if (!activeWorker) {
1135
- throw new SdkModelCatalogServiceError("No worker is attached to this server.");
1136
- }
1137
- if (!pythonBridge.supportsModelManagement()) {
1138
- throw new SdkModelCatalogServiceError("The attached worker does not support model management.");
1094
+ }),
1095
+ preflightService: createNodeToolSdkV1PreflightService({
1096
+ registry,
1097
+ getPythonBridgeReady,
1098
+ getExecutionTargetReadiness: ({ request, principal }) => {
1099
+ const target = request.execution_target;
1100
+ if (target?.kind === "runner") {
1101
+ const ready = sdkLiveRunnerRegistry.has(target.runner_id, principal.userId);
1102
+ return {
1103
+ id: target.runner_id,
1104
+ name: "Live runner",
1105
+ ready,
1106
+ message: ready ? null : "Selected live runner is not available."
1107
+ };
1139
1108
  }
1140
- const models = [];
1141
- for (const candidate of await pythonBridge.listCachedModels()) {
1142
- const parsed = unifiedModel.safeParse(candidate);
1143
- if (parsed.success)
1144
- models.push(parsed.data);
1109
+ return resolveExecutionTarget(target);
1110
+ },
1111
+ getExecutionCapacitySnapshot: ({ request, principal }) => {
1112
+ const target = request.execution_target;
1113
+ if (target?.kind !== "runner") {
1114
+ throw new Error("Execution capacity requires a selected live runner.");
1145
1115
  }
1146
- return models;
1116
+ return sdkLiveRunnerRegistry.getCapacity({
1117
+ runnerId: target.runner_id,
1118
+ userId: principal.userId,
1119
+ workflowId: request.workflow_id,
1120
+ concurrent: target.concurrent
1121
+ });
1147
1122
  }
1123
+ }),
1124
+ modelCatalogService: {
1125
+ list: (args) => getSdkV1ModelCatalog({
1126
+ ...args,
1127
+ recommendedModels: listRegistryRecommendedModels(),
1128
+ getWorkerModels: async () => {
1129
+ const activeWorker = await workerManager?.getActiveWorker();
1130
+ if (!activeWorker) {
1131
+ throw new SdkModelCatalogServiceError("No worker is attached to this server.");
1132
+ }
1133
+ if (!pythonBridge.supportsModelManagement()) {
1134
+ throw new SdkModelCatalogServiceError("The attached worker does not support model management.");
1135
+ }
1136
+ const models = [];
1137
+ for (const candidate of await pythonBridge.listCachedModels()) {
1138
+ const parsed = unifiedModel.safeParse(candidate);
1139
+ if (parsed.success)
1140
+ models.push(parsed.data);
1141
+ }
1142
+ return models;
1143
+ }
1144
+ })
1145
+ },
1146
+ modelDownloadService: sdkModelDownloadService,
1147
+ temporaryAssetService: createSdkV1TemporaryAssetService({
1148
+ getStorage: getTempAdapter
1148
1149
  })
1149
- },
1150
- modelDownloadService: sdkModelDownloadService,
1151
- temporaryAssetService: createSdkV1TemporaryAssetService({
1152
- getStorage: getTempAdapter
1153
- })
1154
- }))
1155
- };
1156
- if (_resolvedExamplesDir) {
1157
- apiOptions.examplesDir = _resolvedExamplesDir;
1158
- if (existsSync(_bundledAssetsDir)) {
1159
- apiOptions.examplesAssetsFallbackDir = _bundledAssetsDir;
1150
+ }))
1151
+ };
1152
+ if (_resolvedExamplesDir) {
1153
+ apiOptions.examplesDir = _resolvedExamplesDir;
1154
+ if (existsSync(_bundledAssetsDir)) {
1155
+ apiOptions.examplesAssetsFallbackDir = _bundledAssetsDir;
1156
+ }
1160
1157
  }
1161
- }
1162
- // Constant package assets (`package://<pkg>/<file>`) are served read-only at
1163
- // `/api/assets/packages/<pkg>/<file>` from `<root>/<pkg>/<file>`. The bundled
1164
- // root sits next to server.mjs (no Python needed); the monorepo root is used
1165
- // in dev/dist runs.
1166
- const _bundledPackageAssetsRoot = resolve(_serverDir, "assets");
1167
- const _monoPackageAssetsRoot = resolve(_serverDir, "..", "..", "..", "packages", "base-nodes", "nodetool", "assets");
1168
- // The bundled typefaces (D8). Two layouts, checked rather than assumed: the
1169
- // packaged backend stages them beside `server.mjs`, a checkout keeps them in
1170
- // the timeline package's own `fonts/` directory. `registerBundledFonts` in
1171
- // `@nodetool-ai/timeline/fonts/node` resolves the same two for the Node
1172
- // rasterizers; this is the copy the HTTP route serves to the browser from.
1173
- const _bundledFontsDir = [
1174
- resolve(_serverDir, "fonts"),
1175
- resolve(_serverDir, "..", "..", "..", "packages", "timeline", "fonts")
1176
- ].find((dir) => existsSync(dir));
1177
- if (_bundledFontsDir) {
1178
- apiOptions.bundledFontsDir = _bundledFontsDir;
1179
- }
1180
- const _packageAssetsRoots = [
1181
- existsSync(_bundledPackageAssetsRoot) ? _bundledPackageAssetsRoot : null,
1182
- existsSync(_monoPackageAssetsRoot) ? _monoPackageAssetsRoot : null
1183
- ].filter((d) => Boolean(d));
1184
- if (_packageAssetsRoots.length > 0) {
1185
- apiOptions.packageAssetsRoots = _packageAssetsRoots;
1186
- // Let in-process workflow execution resolve `package://` refs straight from
1187
- // disk instead of guessing the loopback port for an HTTP round-trip.
1188
- if (!process.env["NODETOOL_PACKAGE_ASSETS_DIR"]) {
1189
- process.env["NODETOOL_PACKAGE_ASSETS_DIR"] = _packageAssetsRoots[0];
1158
+ // Constant package assets (`package://<pkg>/<file>`) are served read-only at
1159
+ // `/api/assets/packages/<pkg>/<file>` from `<root>/<pkg>/<file>`. The bundled
1160
+ // root sits next to server.mjs (no Python needed); the monorepo root is used
1161
+ // in dev/dist runs.
1162
+ const _bundledPackageAssetsRoot = resolve(_serverDir, "assets");
1163
+ const _monoPackageAssetsRoot = resolve(_serverDir, "..", "..", "..", "packages", "base-nodes", "nodetool", "assets");
1164
+ // The bundled typefaces (D8). Two layouts, checked rather than assumed: the
1165
+ // packaged backend stages them beside `server.mjs`, a checkout keeps them in
1166
+ // the timeline package's own `fonts/` directory. `registerBundledFonts` in
1167
+ // `@nodetool-ai/timeline/fonts/node` resolves the same two for the Node
1168
+ // rasterizers; this is the copy the HTTP route serves to the browser from.
1169
+ const _bundledFontsDir = [
1170
+ resolve(_serverDir, "fonts"),
1171
+ resolve(_serverDir, "..", "..", "..", "packages", "timeline", "fonts")
1172
+ ].find((dir) => existsSync(dir));
1173
+ if (_bundledFontsDir) {
1174
+ apiOptions.bundledFontsDir = _bundledFontsDir;
1190
1175
  }
1191
- }
1192
- const staticFolder = process.env["STATIC_FOLDER"];
1193
- const hasStaticApp = Boolean(staticFolder && existsSync(staticFolder));
1194
- // ---------------------------------------------------------------------------
1195
- // Register route plugins
1196
- // ---------------------------------------------------------------------------
1197
- const createContext = createContextFactory({
1198
- registry,
1199
- apiOptions,
1200
- pythonBridge,
1201
- getPythonBridgeReady,
1202
- workerManager,
1203
- repointPythonBridge,
1204
- probeWorkerHealth
1205
- });
1206
- await app.register(fastifyTRPCPlugin, {
1207
- prefix: "/trpc",
1208
- trpcOptions: {
1209
- router: appRouter,
1210
- createContext,
1211
- // The web client forces all batches to POST (httpBatchLink
1212
- // `methodOverride: "POST"`) so long batched inputs ride in the body instead
1213
- // of the URL, which reverse proxies reject once the query string grows too
1214
- // large (see web/src/trpc/client.ts and issues #3979/#3981). Without this
1215
- // flag the server rejects POST-to-query with 405, leaving panels empty.
1216
- allowMethodOverride: true,
1217
- maxBatchSize: TRPC_MAX_BATCH_SIZE,
1218
- onError({ path, error, req, ctx }) {
1219
- logTrpcRequestError(log, {
1220
- path,
1221
- error,
1222
- requestId: req.id
1223
- });
1224
- traceTrpcRequestError({
1225
- path,
1226
- error,
1227
- requestId: req.id,
1228
- userId: ctx?.userId
1229
- });
1176
+ const _packageAssetsRoots = [
1177
+ existsSync(_bundledPackageAssetsRoot) ? _bundledPackageAssetsRoot : null,
1178
+ existsSync(_monoPackageAssetsRoot) ? _monoPackageAssetsRoot : null
1179
+ ].filter((d) => Boolean(d));
1180
+ if (_packageAssetsRoots.length > 0) {
1181
+ apiOptions.packageAssetsRoots = _packageAssetsRoots;
1182
+ // Let in-process workflow execution resolve `package://` refs straight from
1183
+ // disk instead of guessing the loopback port for an HTTP round-trip.
1184
+ if (!process.env["NODETOOL_PACKAGE_ASSETS_DIR"]) {
1185
+ process.env["NODETOOL_PACKAGE_ASSETS_DIR"] = _packageAssetsRoots[0];
1230
1186
  }
1231
1187
  }
1232
- });
1233
- await app.register(websocketPlugin, {
1234
- registry,
1235
- pythonBridge,
1236
- apiOptions,
1237
- sdkLiveRunnerRegistry,
1238
- frontendRendererRegistry,
1239
- workerManager,
1240
- getPythonBridgeReady,
1241
- ensurePythonBridge: async () => {
1242
- if (getPythonBridgeReady()) {
1243
- await registerPythonProvidersOnce();
1244
- return;
1245
- }
1246
- log.info(`Lazily starting Python bridge [${startupMs()}]`);
1247
- try {
1248
- await pythonBridge.ensureConnected();
1249
- }
1250
- catch (err) {
1251
- log.warn(`Python bridge lazy start failed [${startupMs()}]`, err instanceof Error ? err : new Error(String(err)));
1252
- throw err;
1253
- }
1254
- // A worker bridge is connected eagerly on attach; readiness then derives
1255
- // from its live connection state. Only the local bridge does the one-time
1256
- // node-metadata registration below.
1257
- if (!isLocalActive()) {
1258
- log.info(`Worker Python bridge connected [${startupMs()}]`);
1259
- return;
1188
+ const staticFolder = process.env["STATIC_FOLDER"];
1189
+ const hasStaticApp = Boolean(staticFolder && existsSync(staticFolder));
1190
+ // ---------------------------------------------------------------------------
1191
+ // Register route plugins
1192
+ // ---------------------------------------------------------------------------
1193
+ const createContext = createContextFactory({
1194
+ registry,
1195
+ apiOptions,
1196
+ pythonBridge,
1197
+ getPythonBridgeReady,
1198
+ workerManager,
1199
+ repointPythonBridge,
1200
+ probeWorkerHealth
1201
+ });
1202
+ await app.register(fastifyTRPCPlugin, {
1203
+ prefix: "/trpc",
1204
+ trpcOptions: {
1205
+ router: appRouter,
1206
+ createContext,
1207
+ // The web client forces all batches to POST (httpBatchLink
1208
+ // `methodOverride: "POST"`) so long batched inputs ride in the body instead
1209
+ // of the URL, which reverse proxies reject once the query string grows too
1210
+ // large (see web/src/trpc/client.ts and issues #3979/#3981). Without this
1211
+ // flag the server rejects POST-to-query with 405, leaving panels empty.
1212
+ allowMethodOverride: true,
1213
+ maxBatchSize: TRPC_MAX_BATCH_SIZE,
1214
+ onError({ path, error, req, ctx }) {
1215
+ logTrpcRequestError(log, {
1216
+ path,
1217
+ error,
1218
+ requestId: req.id
1219
+ });
1220
+ traceTrpcRequestError({
1221
+ path,
1222
+ error,
1223
+ requestId: req.id,
1224
+ userId: ctx?.userId
1225
+ });
1226
+ }
1260
1227
  }
1261
- pythonBridgeReady = true;
1262
- log.info(`Python bridge lazy start completed [${startupMs()}]`);
1263
- const meta = pythonBridge.getNodeMetadata();
1264
- const mergeResult = mergePythonBridgeMetadata(registry, meta);
1265
- log.info(`Python bridge connected [${startupMs()}] — ${mergeResult.total} Python nodes (${mergeResult.bridgeOnly} bridge-only, ${mergeResult.alreadyKnown} already registered)`);
1266
- pythonBridge
1267
- .getWorkerStatus?.()
1268
- ?.then((status) => {
1269
- log.info(`Python bridge status [${startupMs()}]`, {
1270
- protocol_version: status.protocol_version,
1271
- node_count: status.node_count,
1272
- provider_count: status.provider_count,
1273
- namespaces: status.namespaces,
1274
- transport: status.transport,
1275
- max_frame_size: status.max_frame_size,
1276
- load_error_count: status.load_errors.length
1228
+ });
1229
+ await app.register(websocketPlugin, {
1230
+ registry,
1231
+ pythonBridge,
1232
+ apiOptions,
1233
+ sdkLiveRunnerRegistry,
1234
+ frontendRendererRegistry,
1235
+ workerManager,
1236
+ getPythonBridgeReady,
1237
+ ensurePythonBridge: async () => {
1238
+ if (getPythonBridgeReady()) {
1239
+ await registerPythonProvidersOnce();
1240
+ return;
1241
+ }
1242
+ log.info(`Lazily starting Python bridge [${startupMs()}]`);
1243
+ try {
1244
+ await pythonBridge.ensureConnected();
1245
+ }
1246
+ catch (err) {
1247
+ log.warn(`Python bridge lazy start failed [${startupMs()}]`, err instanceof Error ? err : new Error(String(err)));
1248
+ throw err;
1249
+ }
1250
+ // A worker bridge is connected eagerly on attach; readiness then derives
1251
+ // from its live connection state. Only the local bridge does the one-time
1252
+ // node-metadata registration below.
1253
+ if (!isLocalActive()) {
1254
+ log.info(`Worker Python bridge connected [${startupMs()}]`);
1255
+ return;
1256
+ }
1257
+ pythonBridgeReady = true;
1258
+ log.info(`Python bridge lazy start completed [${startupMs()}]`);
1259
+ const meta = pythonBridge.getNodeMetadata();
1260
+ const mergeResult = mergePythonBridgeMetadata(registry, meta);
1261
+ log.info(`Python bridge connected [${startupMs()}] — ${mergeResult.total} Python nodes (${mergeResult.bridgeOnly} bridge-only, ${mergeResult.alreadyKnown} already registered)`);
1262
+ pythonBridge
1263
+ .getWorkerStatus?.()
1264
+ ?.then((status) => {
1265
+ log.info(`Python bridge status [${startupMs()}]`, {
1266
+ protocol_version: status.protocol_version,
1267
+ node_count: status.node_count,
1268
+ provider_count: status.provider_count,
1269
+ namespaces: status.namespaces,
1270
+ transport: status.transport,
1271
+ max_frame_size: status.max_frame_size,
1272
+ load_error_count: status.load_errors.length
1273
+ });
1274
+ })
1275
+ .catch((err) => {
1276
+ log.warn(`Failed to fetch Python bridge status [${startupMs()}]`, err instanceof Error ? err : new Error(String(err)));
1277
1277
  });
1278
- })
1279
- .catch((err) => {
1280
- log.warn(`Failed to fetch Python bridge status [${startupMs()}]`, err instanceof Error ? err : new Error(String(err)));
1281
- });
1282
- logPythonBridgeDiagnostics("connected");
1283
- // The run that asked for the bridge may select a Python-only provider.
1284
- await registerPythonProvidersOnce();
1285
- notifyPythonBridgeResourceChanges(app, pythonBridge).catch((err) => {
1286
- log.warn("Failed to notify Python bridge resource changes", err instanceof Error ? err : new Error(String(err)));
1287
- });
1278
+ logPythonBridgeDiagnostics("connected");
1279
+ // The run that asked for the bridge may select a Python-only provider.
1280
+ await registerPythonProvidersOnce();
1281
+ notifyPythonBridgeResourceChanges(app, pythonBridge).catch((err) => {
1282
+ log.warn("Failed to notify Python bridge resource changes", err instanceof Error ? err : new Error(String(err)));
1283
+ });
1284
+ }
1285
+ });
1286
+ // One poller for the whole process, not one per connection: it re-reads this
1287
+ // instance's own running runs so a cancel written on another instance lands
1288
+ // here. The registry it consults is process-wide.
1289
+ const stopJobCancelPoller = startJobCancelPoller();
1290
+ await app.register(healthRoute);
1291
+ await app.register(configRoute);
1292
+ // All HTTP API routes receive apiOptions
1293
+ const routeOpts = { apiOptions };
1294
+ await app.register(sdkV1Routes, routeOpts);
1295
+ await app.register(assetsRoutes, routeOpts);
1296
+ await app.register(timelineFontRoutes, routeOpts);
1297
+ await app.register(workflowsRoutes, routeOpts);
1298
+ await app.register(nodesRoutes, routeOpts);
1299
+ await app.register(storageRoutes, routeOpts);
1300
+ await app.register(openaiRoutes, routeOpts);
1301
+ await app.register(oauthRoutes, routeOpts);
1302
+ // MCP OAuth 2.1 AS + well-known discovery routes (docs/mcp-oauth-design.md).
1303
+ // Self-contained: registers its own content-type parsers on its own
1304
+ // encapsulated context, so it is unaffected by the raw-buffer parser the
1305
+ // outer app installs above. 404s every route on its own when the flow is
1306
+ // unconfigured or NODETOOL_DISABLE_MCP_OAUTH=1 — nothing to gate here.
1307
+ await app.register(oauthAsRoutes);
1308
+ await app.register(workspaceRoutes, routeOpts);
1309
+ // A cloud deployment keeps workspaces in the asset bucket rather than on the
1310
+ // machine's disk, which it would lose on every replacement. No-op locally.
1311
+ initWorkspaceStorage();
1312
+ // The Workspace Explorer has no other way to hear about a file a run wrote:
1313
+ // workspace files are not database rows.
1314
+ initWorkspaceChangeEvents();
1315
+ await app.register(filesRoutes, routeOpts);
1316
+ await app.register(collectionsRoutes, routeOpts);
1317
+ await app.register(appRunsRoutes);
1318
+ await app.register(runSpansRoutes);
1319
+ await app.register(applicationsRoutes, routeOpts);
1320
+ await app.register(publicAppRoutes, { appSessionSigningKey });
1321
+ await app.register(publicSharedWorkflowRoutes);
1322
+ await app.register(jsScriptsRoutes, routeOpts);
1323
+ await app.register(timelineAnimationRoutes, routeOpts);
1324
+ await app.register(timelineAudioBakeRoutes, routeOpts);
1325
+ await app.register(timelineIsolateSubjectRoutes, routeOpts);
1326
+ await app.register(timelineTrackObjectRoutes, routeOpts);
1327
+ await app.register(storyboardsRoutes, routeOpts);
1328
+ await app.register(gamesRoutes, routeOpts);
1329
+ await app.register(timelinesRoutes, routeOpts);
1330
+ await app.register(projectArchiveRoutes, routeOpts);
1331
+ // PDF/DOCX text extraction for script and screenplay imports. Stores
1332
+ // nothing and needs no apiOptions.
1333
+ await app.register(documentsRoutes);
1334
+ // Art. 20 export and Art. 17 erasure. The subject is always the caller:
1335
+ // no admin path, no subject id anywhere in the URL or body.
1336
+ await app.register(accountRoutes, routeOpts);
1337
+ await app.register(sandboxModulesRoutes);
1338
+ await app.register(falCreditsRoute);
1339
+ await app.register(falPricingRoute);
1340
+ await app.register(falPricingEstimateRoute);
1341
+ await app.register(kieCreditsRoute);
1342
+ await app.register(kiePricingRoute);
1343
+ await app.register(kieWebhookRoute);
1344
+ // fal callbacks authenticate with signed headers and commit to the durable
1345
+ // generation inbox before the route acknowledges them.
1346
+ await app.register(falWebhookRoute);
1347
+ // AtlasCloud callbacks authenticate with an Ed25519 signature over the raw
1348
+ // body and hand the finished prediction to the run waiting on it.
1349
+ await app.register(atlasCloudWebhookRoute);
1350
+ // Messaging-integration identity routes (`/api/integrations/:provider/*`).
1351
+ // The plugin registers nothing unless NODETOOL_INTEGRATION_TOKEN is set, so a
1352
+ // server without it answers 404 on every one of these paths.
1353
+ await app.register(createIntegrationRoutes({ signingKey: delegatedSigningKey, enforceAuth }));
1354
+ // Trigger webhook ingestion (`POST /api/webhooks/:token`). Registered here
1355
+ // because Fastify routes must exist before `app.listen`; the plugin reaches the
1356
+ // wakeup service and dispatcher started below through module accessors.
1357
+ if (triggersEnabled()) {
1358
+ await app.register(createTriggerWebhookRoute());
1288
1359
  }
1289
- });
1290
- // One poller for the whole process, not one per connection: it re-reads this
1291
- // instance's own running runs so a cancel written on another instance lands
1292
- // here. The registry it consults is process-wide.
1293
- const stopJobCancelPoller = startJobCancelPoller();
1294
- await app.register(healthRoute);
1295
- await app.register(configRoute);
1296
- // All HTTP API routes receive apiOptions
1297
- const routeOpts = { apiOptions };
1298
- await app.register(sdkV1Routes, routeOpts);
1299
- await app.register(assetsRoutes, routeOpts);
1300
- await app.register(timelineFontRoutes, routeOpts);
1301
- await app.register(workflowsRoutes, routeOpts);
1302
- await app.register(nodesRoutes, routeOpts);
1303
- await app.register(storageRoutes, routeOpts);
1304
- await app.register(openaiRoutes, routeOpts);
1305
- await app.register(oauthRoutes, routeOpts);
1306
- // MCP OAuth 2.1 AS + well-known discovery routes (docs/mcp-oauth-design.md).
1307
- // Self-contained: registers its own content-type parsers on its own
1308
- // encapsulated context, so it is unaffected by the raw-buffer parser the
1309
- // outer app installs above. 404s every route on its own when the flow is
1310
- // unconfigured or NODETOOL_DISABLE_MCP_OAUTH=1 — nothing to gate here.
1311
- await app.register(oauthAsRoutes);
1312
- await app.register(workspaceRoutes, routeOpts);
1313
- // A cloud deployment keeps workspaces in the asset bucket rather than on the
1314
- // machine's disk, which it would lose on every replacement. No-op locally.
1315
- initWorkspaceStorage();
1316
- // The Workspace Explorer has no other way to hear about a file a run wrote:
1317
- // workspace files are not database rows.
1318
- initWorkspaceChangeEvents();
1319
- await app.register(filesRoutes, routeOpts);
1320
- await app.register(collectionsRoutes, routeOpts);
1321
- await app.register(appRunsRoutes);
1322
- await app.register(runSpansRoutes);
1323
- await app.register(applicationsRoutes, routeOpts);
1324
- await app.register(publicAppRoutes, { appSessionSigningKey });
1325
- await app.register(publicSharedWorkflowRoutes);
1326
- await app.register(jsScriptsRoutes, routeOpts);
1327
- await app.register(timelineAnimationRoutes, routeOpts);
1328
- await app.register(timelineAudioBakeRoutes, routeOpts);
1329
- await app.register(timelineIsolateSubjectRoutes, routeOpts);
1330
- await app.register(timelineTrackObjectRoutes, routeOpts);
1331
- await app.register(storyboardsRoutes, routeOpts);
1332
- await app.register(gamesRoutes, routeOpts);
1333
- await app.register(timelinesRoutes, routeOpts);
1334
- // PDF/DOCX text extraction for script and screenplay imports. Stores
1335
- // nothing and needs no apiOptions.
1336
- await app.register(documentsRoutes);
1337
- // Art. 20 export and Art. 17 erasure. The subject is always the caller:
1338
- // no admin path, no subject id anywhere in the URL or body.
1339
- await app.register(accountRoutes, routeOpts);
1340
- await app.register(sandboxModulesRoutes);
1341
- await app.register(falCreditsRoute);
1342
- await app.register(falPricingRoute);
1343
- await app.register(falPricingEstimateRoute);
1344
- await app.register(kieCreditsRoute);
1345
- await app.register(kiePricingRoute);
1346
- await app.register(kieWebhookRoute);
1347
- // fal callbacks authenticate with signed headers and commit to the durable
1348
- // generation inbox before the route acknowledges them.
1349
- await app.register(falWebhookRoute);
1350
- // AtlasCloud callbacks authenticate with an Ed25519 signature over the raw
1351
- // body and hand the finished prediction to the run waiting on it.
1352
- await app.register(atlasCloudWebhookRoute);
1353
- // Messaging-integration identity routes (`/api/integrations/:provider/*`).
1354
- // The plugin registers nothing unless NODETOOL_INTEGRATION_TOKEN is set, so a
1355
- // server without it answers 404 on every one of these paths.
1356
- await app.register(createIntegrationRoutes({ signingKey: delegatedSigningKey, enforceAuth }));
1357
- // Trigger webhook ingestion (`POST /api/webhooks/:token`). Registered here
1358
- // because Fastify routes must exist before `app.listen`; the plugin reaches the
1359
- // wakeup service and dispatcher started below through module accessors.
1360
- if (triggersEnabled()) {
1361
- await app.register(createTriggerWebhookRoute());
1362
- }
1363
- // The `/mcp` mount is a bare MCP over-HTTP transport (the configuration
1364
- // endpoints moved to the tRPC `mcpConfig` router, which stays local-only). In
1365
- // production it registers only with NODETOOL_ENABLE_MCP=1, and it binds the
1366
- // user the auth hook resolved rather than the local single user.
1367
- if (mcpHttpEnabled) {
1368
- app.all("/mcp", async (req, reply) => {
1369
- const protocol = httpsOptions ? "https" : "http";
1370
- const url = `${protocol}://${req.headers.host ?? `127.0.0.1:${port}`}${req.url}`;
1371
- const headers = new Headers();
1372
- for (const [key, value] of Object.entries(req.headers)) {
1373
- if (Array.isArray(value)) {
1374
- for (const item of value) {
1375
- headers.append(key, item);
1360
+ // The `/mcp` mount is a bare MCP over-HTTP transport (the configuration
1361
+ // endpoints moved to the tRPC `mcpConfig` router, which stays local-only). In
1362
+ // production it registers only with NODETOOL_ENABLE_MCP=1, and it binds the
1363
+ // user the auth hook resolved rather than the local single user.
1364
+ if (mcpHttpEnabled) {
1365
+ app.all("/mcp", async (req, reply) => {
1366
+ const protocol = httpsOptions ? "https" : "http";
1367
+ const url = `${protocol}://${req.headers.host ?? `127.0.0.1:${port}`}${req.url}`;
1368
+ const headers = new Headers();
1369
+ for (const [key, value] of Object.entries(req.headers)) {
1370
+ if (Array.isArray(value)) {
1371
+ for (const item of value) {
1372
+ headers.append(key, item);
1373
+ }
1374
+ }
1375
+ else if (value != null) {
1376
+ headers.set(key, value);
1376
1377
  }
1377
1378
  }
1378
- else if (value != null) {
1379
- headers.set(key, value);
1380
- }
1381
- }
1382
- const requestBody = req.method === "GET" || req.method === "DELETE"
1383
- ? undefined
1384
- : Buffer.isBuffer(req.body)
1385
- ? req.body
1386
- : isString(req.body)
1379
+ const requestBody = req.method === "GET" || req.method === "DELETE"
1380
+ ? undefined
1381
+ : Buffer.isBuffer(req.body)
1387
1382
  ? req.body
1388
- : req.body == null
1389
- ? Buffer.alloc(0)
1390
- : JSON.stringify(req.body);
1391
- const request = new Request(url, {
1392
- method: req.method,
1393
- headers,
1394
- body: Buffer.isBuffer(requestBody)
1395
- ? new Uint8Array(requestBody)
1396
- : requestBody,
1397
- // `duplex` is required by Node's fetch when streaming bodies but is
1398
- // missing from some `@types/node`/`undici-types` versions of RequestInit.
1399
- ...{ duplex: "half" }
1400
- });
1401
- // The scope is the user the auth hook resolved for this request. With no
1402
- // user, no scope is passed and mcp-server.ts answers 401 at initialize
1403
- // instead of handing out an anonymous full-access session.
1404
- const response = await handleMcpHttpRequest(request, {
1405
- metadataRoots,
1406
- registry,
1407
- examplesDir: apiOptions.examplesDir,
1408
- frontendRendererRegistry,
1409
- allowLocalFilePaths: !isProduction && isLoopbackAddress(req.ip),
1410
- agentToolsScope: req.userId
1411
- ? {
1412
- userId: req.userId,
1413
- source: isProduction ? "http-session" : "local-dev-http"
1414
- }
1415
- : undefined,
1416
- unauthorizedChallenge: mcpBearerChallenge()
1417
- });
1418
- if (!response) {
1419
- reply.status(404).send({ error: "Not found" });
1420
- return;
1421
- }
1422
- reply.status(response.status);
1423
- response.headers.forEach((value, key) => {
1424
- reply.header(key, value);
1383
+ : isString(req.body)
1384
+ ? req.body
1385
+ : req.body == null
1386
+ ? Buffer.alloc(0)
1387
+ : JSON.stringify(req.body);
1388
+ const request = new Request(url, {
1389
+ method: req.method,
1390
+ headers,
1391
+ body: Buffer.isBuffer(requestBody)
1392
+ ? new Uint8Array(requestBody)
1393
+ : requestBody,
1394
+ // `duplex` is required by Node's fetch when streaming bodies but is
1395
+ // missing from some `@types/node`/`undici-types` versions of RequestInit.
1396
+ ...{ duplex: "half" }
1397
+ });
1398
+ // The scope is the user the auth hook resolved for this request. With no
1399
+ // user, no scope is passed and mcp-server.ts answers 401 at initialize
1400
+ // instead of handing out an anonymous full-access session.
1401
+ const response = await handleMcpHttpRequest(request, {
1402
+ metadataRoots,
1403
+ registry,
1404
+ examplesDir: apiOptions.examplesDir,
1405
+ frontendRendererRegistry,
1406
+ allowLocalFilePaths: !isProduction && isLoopbackAddress(req.ip),
1407
+ agentToolsScope: req.userId
1408
+ ? {
1409
+ userId: req.userId,
1410
+ source: isProduction ? "http-session" : "local-dev-http"
1411
+ }
1412
+ : undefined,
1413
+ unauthorizedChallenge: mcpBearerChallenge()
1414
+ });
1415
+ if (!response) {
1416
+ reply.status(404).send({ error: "Not found" });
1417
+ return;
1418
+ }
1419
+ reply.status(response.status);
1420
+ response.headers.forEach((value, key) => {
1421
+ reply.header(key, value);
1422
+ });
1423
+ const payload = Buffer.from(await response.arrayBuffer());
1424
+ reply.send(payload);
1425
1425
  });
1426
- const payload = Buffer.from(await response.arrayBuffer());
1427
- reply.send(payload);
1428
- });
1429
- }
1430
- else {
1431
- log.info(`MCP over HTTP (/mcp) disabled in production; set ${MCP_ENABLE_FLAG}=1 to enable`);
1432
- }
1433
- log.info(`Routes registered [${startupMs()}]`);
1434
- if (hasStaticApp && staticFolder) {
1435
- log.info("Serving static app", { root: staticFolder });
1436
- await app.register(fastifyStatic, {
1437
- root: staticFolder,
1438
- prefix: "/"
1439
- });
1440
- app.get("/", async (_req, reply) => {
1441
- return reply.sendFile("index.html");
1442
- });
1443
- app.get("/apps/index.html", async (_req, reply) => {
1444
- return reply.sendFile("index.html");
1445
- });
1446
- }
1447
- // Download the built Chrome extension as a zip, for the install helper UI.
1448
- app.get("/api/extension/download", async (_req, reply) => {
1449
- try {
1450
- const zip = await zipExtensionDist();
1451
- return reply
1452
- .header("Content-Type", "application/zip")
1453
- .header("Content-Disposition", 'attachment; filename="nodetool-chrome-extension.zip"')
1454
- .send(zip);
1455
1426
  }
1456
- catch (err) {
1457
- log.warn(`Extension download failed: ${err instanceof Error ? err.message : String(err)}`);
1458
- return reply.status(404).send({ detail: "Extension build not found" });
1427
+ else {
1428
+ log.info(`MCP over HTTP (/mcp) disabled in production; set ${MCP_ENABLE_FLAG}=1 to enable`);
1459
1429
  }
1460
- });
1461
- // Log all 404s so we can identify missing routes
1462
- app.setNotFoundHandler((req, reply) => {
1463
- const pathname = req.url.split("?")[0] ?? "/";
1464
- if (hasStaticApp &&
1465
- req.method === "GET" &&
1466
- !pathname.startsWith("/api") &&
1467
- !pathname.startsWith("/health") &&
1468
- !pathname.startsWith("/ws") &&
1469
- !pathname.startsWith("/v1") &&
1470
- !pathname.startsWith("/.well-known/") &&
1471
- // /oauth/consent is the one /oauth path that IS the SPA — the MCP OAuth
1472
- // consent page (docs/mcp-oauth-design.md). Every other /oauth/* path is
1473
- // an AS API route (authorize/token/register/revoke), and unmatched
1474
- // requests there must 404 rather than get served index.html.
1475
- (pathname === "/oauth/consent" || !pathname.startsWith("/oauth")) &&
1476
- !pathname.startsWith("/trpc") &&
1477
- !pathname.startsWith("/mcp") &&
1478
- !pathname.includes(".")) {
1479
- return reply.sendFile("index.html");
1430
+ log.info(`Routes registered [${startupMs()}]`);
1431
+ if (hasStaticApp && staticFolder) {
1432
+ log.info("Serving static app", { root: staticFolder });
1433
+ await app.register(fastifyStatic, {
1434
+ root: staticFolder,
1435
+ prefix: "/"
1436
+ });
1437
+ app.get("/", async (_req, reply) => {
1438
+ return reply.sendFile("index.html");
1439
+ });
1440
+ app.get("/apps/index.html", async (_req, reply) => {
1441
+ return reply.sendFile("index.html");
1442
+ });
1480
1443
  }
1481
- log.warn(`404 Not Found: ${req.method} ${req.url}`);
1482
- return reply.status(404).send({ detail: "Not found" });
1483
- });
1484
- // ---------------------------------------------------------------------------
1485
- // HTTP → HTTPS redirect server (when TLS is active)
1486
- // ---------------------------------------------------------------------------
1487
- if (tlsEnabled) {
1488
- const redirectPort = Number(process.env["REDIRECT_PORT"] ?? 80);
1489
- const redirectServer = createHttpServer((req, res) => {
1490
- const reqHost = req.headers.host?.split(":")[0] ?? "localhost";
1491
- const location = `https://${reqHost}:${port}${req.url ?? "/"}`;
1492
- res.statusCode = 301;
1493
- res.setHeader("Location", location);
1494
- res.end();
1495
- });
1496
- redirectServer.on("error", (err) => {
1497
- if (err.code === "EACCES") {
1498
- log.warn(`HTTP redirect server skipped — port ${redirectPort} requires elevated privileges`);
1444
+ // Download the built Chrome extension as a zip, for the install helper UI.
1445
+ app.get("/api/extension/download", async (_req, reply) => {
1446
+ try {
1447
+ const zip = await zipExtensionDist();
1448
+ return reply
1449
+ .header("Content-Type", "application/zip")
1450
+ .header("Content-Disposition", 'attachment; filename="nodetool-chrome-extension.zip"')
1451
+ .send(zip);
1499
1452
  }
1500
- else {
1501
- log.error("HTTP redirect server error", err);
1453
+ catch (err) {
1454
+ log.warn(`Extension download failed: ${err instanceof Error ? err.message : String(err)}`);
1455
+ return reply.status(404).send({ detail: "Extension build not found" });
1502
1456
  }
1503
1457
  });
1504
- redirectServer.listen(redirectPort, "0.0.0.0", () => {
1505
- log.info(`HTTP → HTTPS redirect server listening on :${redirectPort}`);
1458
+ // Log all 404s so we can identify missing routes
1459
+ app.setNotFoundHandler((req, reply) => {
1460
+ const pathname = req.url.split("?")[0] ?? "/";
1461
+ if (hasStaticApp &&
1462
+ req.method === "GET" &&
1463
+ !pathname.startsWith("/api") &&
1464
+ !pathname.startsWith("/health") &&
1465
+ !pathname.startsWith("/ws") &&
1466
+ !pathname.startsWith("/v1") &&
1467
+ !pathname.startsWith("/.well-known/") &&
1468
+ // /oauth/consent is the one /oauth path that IS the SPA — the MCP OAuth
1469
+ // consent page (docs/mcp-oauth-design.md). Every other /oauth/* path is
1470
+ // an AS API route (authorize/token/register/revoke), and unmatched
1471
+ // requests there must 404 rather than get served index.html.
1472
+ (pathname === "/oauth/consent" || !pathname.startsWith("/oauth")) &&
1473
+ !pathname.startsWith("/trpc") &&
1474
+ !pathname.startsWith("/mcp") &&
1475
+ !pathname.includes(".")) {
1476
+ return reply.sendFile("index.html");
1477
+ }
1478
+ log.warn(`404 Not Found: ${req.method} ${req.url}`);
1479
+ return reply.status(404).send({ detail: "Not found" });
1506
1480
  });
1507
- }
1508
- // ---------------------------------------------------------------------------
1509
- // Start Fastify server immediately, Python bridge connects in background
1510
- // ---------------------------------------------------------------------------
1511
- const proto = tlsEnabled ? "https" : "http";
1512
- app.listen({ port, host }, (err) => {
1513
- if (err) {
1514
- log.error("Failed to start server", err);
1515
- process.exit(1);
1481
+ // ---------------------------------------------------------------------------
1482
+ // HTTP → HTTPS redirect server (when TLS is active)
1483
+ // ---------------------------------------------------------------------------
1484
+ if (tlsEnabled) {
1485
+ const redirectPort = Number(process.env["REDIRECT_PORT"] ?? 80);
1486
+ const redirectServer = createHttpServer((req, res) => {
1487
+ const reqHost = req.headers.host?.split(":")[0] ?? "localhost";
1488
+ const location = `https://${reqHost}:${port}${req.url ?? "/"}`;
1489
+ res.statusCode = 301;
1490
+ res.setHeader("Location", location);
1491
+ res.end();
1492
+ });
1493
+ redirectServer.on("error", (err) => {
1494
+ if (err.code === "EACCES") {
1495
+ log.warn(`HTTP redirect server skipped — port ${redirectPort} requires elevated privileges`);
1496
+ }
1497
+ else {
1498
+ log.error("HTTP redirect server error", err);
1499
+ }
1500
+ });
1501
+ redirectServer.listen(redirectPort, "0.0.0.0", () => {
1502
+ log.info(`HTTP → HTTPS redirect server listening on :${redirectPort}`);
1503
+ });
1516
1504
  }
1505
+ // ---------------------------------------------------------------------------
1506
+ // Start Fastify server immediately, Python bridge connects in background
1507
+ // ---------------------------------------------------------------------------
1508
+ const proto = tlsEnabled ? "https" : "http";
1509
+ await app.listen({ port, host });
1517
1510
  log.info(`Server listening on ${proto}://${host}:${port} [${startupMs()}]`);
1518
1511
  log.info(`WebSocket endpoint: ${tlsEnabled ? "wss" : "ws"}://${host}:${port}/ws`);
1519
- // When the launcher sets NODE_COMPILE_CACHE, Node writes the V8 code cache
1520
- // only on a clean exit. Writing it once startup has compiled the server keeps
1521
- // it for the next start even when this process is killed. No-op otherwise.
1512
+ // Persist compiled code once startup finishes, including launches later killed.
1522
1513
  flushCompileCache();
1523
- });
1524
- // ---------------------------------------------------------------------------
1525
- // Triggers — durable wakeup service, dispatcher, and ingestion adapters
1526
- // ---------------------------------------------------------------------------
1527
- //
1528
- // Starting the dispatcher also drains the boot backlog: every unprocessed
1529
- // `trigger_inputs` row a previous process life stored is dispatched now. Set
1530
- // NODETOOL_DISABLE_TRIGGERS=1 to opt this process out.
1531
- const triggerServices = startTriggerServices({ registry });
1532
- // ---------------------------------------------------------------------------
1533
- // Worker cost guard — reconcile orphaned GPU pods on boot, then reap on a loop
1534
- // ---------------------------------------------------------------------------
1535
- const WORKER_REAPER_INTERVAL_MS = 60_000;
1536
- workerManager
1537
- .reconcile()
1538
- .then((summary) => {
1539
- if (summary.liveCount > 0 || summary.orphans.length > 0) {
1540
- log.info(`Worker reconcile: ${summary.liveCount} live, ` +
1541
- `${summary.orphans.length} orphaned, ~$${summary.estimatedCostUsd.toFixed(2)}`);
1542
- }
1543
- })
1544
- .catch((reconcileErr) => {
1545
- log.warn("Worker reconcile failed at startup", reconcileErr instanceof Error
1546
- ? reconcileErr
1547
- : new Error(String(reconcileErr)));
1548
- });
1549
- const stopReaper = startReaper({
1550
- manager: workerManager,
1551
- getProfile: (name) => getWorkerProfile(name),
1552
- now: () => Date.now()
1553
- }, WORKER_REAPER_INTERVAL_MS);
1554
- const generationRecoveryWorker = createGenerationRecoveryWorker();
1555
- const generationRecoveryTimer = setInterval(() => {
1556
- void generationRecoveryWorker.runOnce().catch((error) => {
1557
- log.warn("Durable generation recovery failed", error instanceof Error ? error : new Error(String(error)));
1514
+ // ---------------------------------------------------------------------------
1515
+ // Triggers — durable wakeup service, dispatcher, and ingestion adapters
1516
+ // ---------------------------------------------------------------------------
1517
+ //
1518
+ // Starting the dispatcher also drains the boot backlog: every unprocessed
1519
+ // `trigger_inputs` row a previous process life stored is dispatched now. Set
1520
+ // NODETOOL_DISABLE_TRIGGERS=1 to opt this process out.
1521
+ const triggerServices = startTriggerServices({ registry });
1522
+ // ---------------------------------------------------------------------------
1523
+ // Worker cost guard — reconcile orphaned GPU pods on boot, then reap on a loop
1524
+ // ---------------------------------------------------------------------------
1525
+ const WORKER_REAPER_INTERVAL_MS = 60_000;
1526
+ workerManager
1527
+ .reconcile()
1528
+ .then((summary) => {
1529
+ if (summary.liveCount > 0 || summary.orphans.length > 0) {
1530
+ log.info(`Worker reconcile: ${summary.liveCount} live, ` +
1531
+ `${summary.orphans.length} orphaned, ~$${summary.estimatedCostUsd.toFixed(2)}`);
1532
+ }
1533
+ })
1534
+ .catch((reconcileErr) => {
1535
+ log.warn("Worker reconcile failed at startup", reconcileErr instanceof Error
1536
+ ? reconcileErr
1537
+ : new Error(String(reconcileErr)));
1558
1538
  });
1559
- }, 30_000);
1560
- generationRecoveryTimer.unref?.();
1561
- void generationRecoveryWorker.runOnce().catch((error) => {
1562
- log.warn("Durable generation recovery failed at startup", error instanceof Error ? error : new Error(String(error)));
1563
- });
1564
- // ---------------------------------------------------------------------------
1565
- // Graceful shutdown — ensure child processes (Python worker, etc.) are killed
1566
- // ---------------------------------------------------------------------------
1567
- /**
1568
- * How long SIGTERM waits for aborted turns and runs to settle. Under Fly's
1569
- * 300 s cap between the signal and the kill; the drain the deploy script runs
1570
- * first is what actually waits for a long turn, so this is the fallback for a
1571
- * restart nobody drained.
1572
- */
1573
- function shutdownGraceMs() {
1574
- const raw = process.env["NODETOOL_SHUTDOWN_GRACE_MS"];
1575
- if (!raw)
1576
- return 240_000;
1577
- const parsed = Number.parseInt(raw, 10);
1578
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 240_000;
1579
- }
1580
- let shuttingDown = false;
1581
- async function shutdown(signal) {
1582
- // The wait below can last minutes; a second signal in that window must not
1583
- // run the teardown a second time on top of the first.
1584
- if (shuttingDown) {
1585
- log.info(`${signal} received again — shutdown already in progress`);
1586
- return;
1587
- }
1588
- shuttingDown = true;
1589
- log.info(`${signal} received — shutting down`);
1590
- // Take no new work first, so nothing starts during the wait below.
1591
- startDrain();
1592
- const abortedTurns = chatTurnRegistry.abortAll("shutdown");
1593
- const cancelledJobs = jobRunRegistry.cancelAll();
1594
- if (abortedTurns > 0 || cancelledJobs > 0) {
1595
- log.info("Aborting in-flight work", {
1596
- turns: abortedTurns,
1597
- jobs: cancelledJobs
1598
- });
1599
- }
1600
- const grace = shutdownGraceMs();
1601
- const [turnsDrained, jobsDrained] = await Promise.all([
1602
- chatTurnRegistry.drained(grace),
1603
- jobRunRegistry.drained(grace)
1604
- ]);
1605
- if (!turnsDrained || !jobsDrained) {
1606
- // The turn's own `finally` writes the stand-in tool-result rows, so a
1607
- // turn still running here is one whose transcript stays incomplete.
1608
- log.warn("Shutdown grace expired with work still running", {
1609
- graceMs: grace,
1610
- turns: chatTurnRegistry.runningCount(),
1611
- jobs: jobRunRegistry.runningCount()
1539
+ const stopReaper = startReaper({
1540
+ manager: workerManager,
1541
+ getProfile: (name) => getWorkerProfile(name),
1542
+ now: () => Date.now()
1543
+ }, WORKER_REAPER_INTERVAL_MS);
1544
+ const generationRecoveryWorker = createGenerationRecoveryWorker();
1545
+ const generationRecoveryTimer = setInterval(() => {
1546
+ void generationRecoveryWorker.runOnce().catch((error) => {
1547
+ log.warn("Durable generation recovery failed", error instanceof Error ? error : new Error(String(error)));
1612
1548
  });
1549
+ }, 30_000);
1550
+ generationRecoveryTimer.unref?.();
1551
+ void generationRecoveryWorker.runOnce().catch((error) => {
1552
+ log.warn("Durable generation recovery failed at startup", error instanceof Error ? error : new Error(String(error)));
1553
+ });
1554
+ // ---------------------------------------------------------------------------
1555
+ // Graceful shutdown — ensure child processes (Python worker, etc.) are killed
1556
+ // ---------------------------------------------------------------------------
1557
+ /**
1558
+ * How long SIGTERM waits for aborted turns and runs to settle. Under Fly's
1559
+ * 300 s cap between the signal and the kill; the drain the deploy script runs
1560
+ * first is what actually waits for a long turn, so this is the fallback for a
1561
+ * restart nobody drained.
1562
+ */
1563
+ function shutdownGraceMs() {
1564
+ const raw = process.env["NODETOOL_SHUTDOWN_GRACE_MS"];
1565
+ if (!raw)
1566
+ return 240_000;
1567
+ const parsed = Number.parseInt(raw, 10);
1568
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 240_000;
1613
1569
  }
1614
- // Spans are batched, so without this every span the last turns produced dies
1615
- // with the process.
1616
- await shutdownTelemetry();
1617
- log.info("Closing Python bridge");
1618
- stopReaper();
1619
- clearInterval(generationRecoveryTimer);
1620
- stopJobCancelPoller();
1621
- stopGenerationReconcileWorker?.();
1622
- stopErrorTraceMaintenance?.();
1623
- try {
1624
- await triggerServices.stop();
1625
- }
1626
- catch (err) {
1627
- log.warn("Trigger services failed to stop cleanly", err instanceof Error ? err : new Error(String(err)));
1570
+ let shuttingDown = false;
1571
+ async function shutdown(signal) {
1572
+ // The wait below can last minutes; a second signal in that window must not
1573
+ // run the teardown a second time on top of the first.
1574
+ if (shuttingDown) {
1575
+ log.info(`${signal} received again — shutdown already in progress`);
1576
+ return;
1577
+ }
1578
+ shuttingDown = true;
1579
+ log.info(`${signal} received — shutting down`);
1580
+ // Take no new work first, so nothing starts during the wait below.
1581
+ startDrain();
1582
+ const abortedTurns = chatTurnRegistry.abortAll("shutdown");
1583
+ const cancelledJobs = jobRunRegistry.cancelAll();
1584
+ if (abortedTurns > 0 || cancelledJobs > 0) {
1585
+ log.info("Aborting in-flight work", {
1586
+ turns: abortedTurns,
1587
+ jobs: cancelledJobs
1588
+ });
1589
+ }
1590
+ const grace = shutdownGraceMs();
1591
+ const [turnsDrained, jobsDrained] = await Promise.all([
1592
+ chatTurnRegistry.drained(grace),
1593
+ jobRunRegistry.drained(grace)
1594
+ ]);
1595
+ if (!turnsDrained || !jobsDrained) {
1596
+ // The turn's own `finally` writes the stand-in tool-result rows, so a
1597
+ // turn still running here is one whose transcript stays incomplete.
1598
+ log.warn("Shutdown grace expired with work still running", {
1599
+ graceMs: grace,
1600
+ turns: chatTurnRegistry.runningCount(),
1601
+ jobs: jobRunRegistry.runningCount()
1602
+ });
1603
+ }
1604
+ // Spans are batched, so without this every span the last turns produced dies
1605
+ // with the process.
1606
+ await shutdownTelemetry();
1607
+ log.info("Closing Python bridge");
1608
+ stopReaper();
1609
+ clearInterval(generationRecoveryTimer);
1610
+ stopJobCancelPoller();
1611
+ stopGenerationReconcileWorker?.();
1612
+ stopErrorTraceMaintenance?.();
1613
+ try {
1614
+ await triggerServices.stop();
1615
+ }
1616
+ catch (err) {
1617
+ log.warn("Trigger services failed to stop cleanly", err instanceof Error ? err : new Error(String(err)));
1618
+ }
1619
+ localBridge.close();
1620
+ workerBridge?.close();
1621
+ try {
1622
+ await app.close();
1623
+ }
1624
+ catch {
1625
+ // ignore close errors
1626
+ }
1627
+ process.exit(0);
1628
1628
  }
1629
- localBridge.close();
1630
- workerBridge?.close();
1631
- try {
1632
- await app.close();
1629
+ process.on("SIGINT", () => void shutdown("SIGINT"));
1630
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
1631
+ // The deploy's drain signal: stop taking work and let the turns in flight
1632
+ // finish, without a deadline. `scripts/fly-rolling-deploy.sh` sends it, waits
1633
+ // for /health to report no turns and no jobs, and only then replaces the
1634
+ // machine — which is what keeps a 30-minute turn off Fly's 300 s SIGTERM cap.
1635
+ process.on("SIGUSR2", () => {
1636
+ log.info("SIGUSR2 received — draining");
1637
+ startDrain();
1638
+ });
1639
+ // On Windows, Ctrl+C in some terminals fires SIGBREAK instead of SIGINT
1640
+ if (process.platform === "win32") {
1641
+ process.on("SIGBREAK", () => void shutdown("SIGBREAK"));
1633
1642
  }
1634
- catch {
1635
- // ignore close errors
1643
+ // Start the Python bridge eagerly if a worker is available, unless the user
1644
+ // chose to start it on first use. Runs that need it start it through
1645
+ // `ensurePythonBridge` either way.
1646
+ const pythonOnDemand = (await getSetting("NODETOOL_PYTHON_ON_DEMAND")) === "true";
1647
+ if (pythonBridge.isAvailable() && pythonOnDemand) {
1648
+ log.info("Python bridge starts on first use (NODETOOL_PYTHON_ON_DEMAND=true)");
1636
1649
  }
1637
- process.exit(0);
1638
- }
1639
- process.on("SIGINT", () => void shutdown("SIGINT"));
1640
- process.on("SIGTERM", () => void shutdown("SIGTERM"));
1641
- // The deploy's drain signal: stop taking work and let the turns in flight
1642
- // finish, without a deadline. `scripts/fly-rolling-deploy.sh` sends it, waits
1643
- // for /health to report no turns and no jobs, and only then replaces the
1644
- // machine — which is what keeps a 30-minute turn off Fly's 300 s SIGTERM cap.
1645
- process.on("SIGUSR2", () => {
1646
- log.info("SIGUSR2 received — draining");
1647
- startDrain();
1648
- });
1649
- // On Windows, Ctrl+C in some terminals fires SIGBREAK instead of SIGINT
1650
- if (process.platform === "win32") {
1651
- process.on("SIGBREAK", () => void shutdown("SIGBREAK"));
1652
- }
1653
- // Start the Python bridge eagerly if a worker is available, unless the user
1654
- // chose to start it on first use. Runs that need it start it through
1655
- // `ensurePythonBridge` either way.
1656
- const pythonOnDemand = (await getSetting("NODETOOL_PYTHON_ON_DEMAND")) === "true";
1657
- if (pythonBridge.isAvailable() && pythonOnDemand) {
1658
- log.info("Python bridge starts on first use (NODETOOL_PYTHON_ON_DEMAND=true)");
1659
- }
1660
- else if (pythonBridge.isAvailable()) {
1661
- log.info(`Starting Python bridge eagerly [${startupMs()}]`);
1662
- pythonBridge
1663
- .ensureConnected()
1664
- .then(() => {
1665
- pythonBridgeReady = true;
1666
- log.info(`Python bridge eager start completed [${startupMs()}]`);
1667
- const meta = pythonBridge.getNodeMetadata();
1668
- const mergeResult = mergePythonBridgeMetadata(registry, meta);
1669
- log.info(`Python bridge connected [${startupMs()}] — ${mergeResult.total} Python nodes (${mergeResult.bridgeOnly} bridge-only, ${mergeResult.alreadyKnown} already registered)`);
1650
+ else if (pythonBridge.isAvailable()) {
1651
+ log.info(`Starting Python bridge eagerly [${startupMs()}]`);
1670
1652
  pythonBridge
1671
- .getWorkerStatus?.()
1672
- ?.then((status) => {
1673
- log.info(`Python bridge status [${startupMs()}]`, {
1674
- protocol_version: status.protocol_version,
1675
- node_count: status.node_count,
1676
- provider_count: status.provider_count,
1677
- namespaces: status.namespaces,
1678
- transport: status.transport,
1679
- max_frame_size: status.max_frame_size,
1680
- load_error_count: status.load_errors.length
1653
+ .ensureConnected()
1654
+ .then(() => {
1655
+ pythonBridgeReady = true;
1656
+ log.info(`Python bridge eager start completed [${startupMs()}]`);
1657
+ const meta = pythonBridge.getNodeMetadata();
1658
+ const mergeResult = mergePythonBridgeMetadata(registry, meta);
1659
+ log.info(`Python bridge connected [${startupMs()}] — ${mergeResult.total} Python nodes (${mergeResult.bridgeOnly} bridge-only, ${mergeResult.alreadyKnown} already registered)`);
1660
+ pythonBridge
1661
+ .getWorkerStatus?.()
1662
+ ?.then((status) => {
1663
+ log.info(`Python bridge status [${startupMs()}]`, {
1664
+ protocol_version: status.protocol_version,
1665
+ node_count: status.node_count,
1666
+ provider_count: status.provider_count,
1667
+ namespaces: status.namespaces,
1668
+ transport: status.transport,
1669
+ max_frame_size: status.max_frame_size,
1670
+ load_error_count: status.load_errors.length
1671
+ });
1672
+ })
1673
+ .catch((err) => {
1674
+ log.warn(`Failed to fetch Python bridge status [${startupMs()}]`, err instanceof Error ? err : new Error(String(err)));
1675
+ });
1676
+ logPythonBridgeDiagnostics("connected");
1677
+ notifyPythonBridgeResourceChanges(app, pythonBridge).catch((err) => {
1678
+ log.warn("Failed to notify Python bridge resource changes", err instanceof Error ? err : new Error(String(err)));
1681
1679
  });
1682
1680
  })
1683
1681
  .catch((err) => {
1684
- log.warn(`Failed to fetch Python bridge status [${startupMs()}]`, err instanceof Error ? err : new Error(String(err)));
1685
- });
1686
- logPythonBridgeDiagnostics("connected");
1687
- notifyPythonBridgeResourceChanges(app, pythonBridge).catch((err) => {
1688
- log.warn("Failed to notify Python bridge resource changes", err instanceof Error ? err : new Error(String(err)));
1682
+ log.warn("Python bridge failed to start (Python nodes will not be available)", err instanceof Error ? err : new Error(String(err)));
1689
1683
  });
1690
- })
1691
- .catch((err) => {
1692
- log.warn("Python bridge failed to start (Python nodes will not be available)", err instanceof Error ? err : new Error(String(err)));
1693
- });
1684
+ }
1685
+ else {
1686
+ const pythonAllowedInProduction = process.env["NODETOOL_ALLOW_PYTHON_BRIDGE_IN_PRODUCTION"] === "1";
1687
+ log.info(isProduction && !pythonAllowedInProduction
1688
+ ? "Python bridge disabled in production — Python nodes will not be available; set NODETOOL_ALLOW_PYTHON_BRIDGE_IN_PRODUCTION=1 to enable (the image ships no Python worker: install nodetool-core and point NODETOOL_PYTHON at that interpreter)"
1689
+ : "Python not found — Python nodes will not be available");
1690
+ }
1691
+ return app;
1694
1692
  }
1695
- else {
1696
- const pythonAllowedInProduction = process.env["NODETOOL_ALLOW_PYTHON_BRIDGE_IN_PRODUCTION"] === "1";
1697
- log.info(isProduction && !pythonAllowedInProduction
1698
- ? "Python bridge disabled in production — Python nodes will not be available; set NODETOOL_ALLOW_PYTHON_BRIDGE_IN_PRODUCTION=1 to enable (the image ships no Python worker: install nodetool-core and point NODETOOL_PYTHON at that interpreter)"
1699
- : "Python not found — Python nodes will not be available");
1693
+ // This entry remains the CLI's standalone server. Bundled server.mjs starts
1694
+ // through @nodetool-ai/server/main, so it must not also run this main block.
1695
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) &&
1696
+ /[/\\]server\.(?:ts|js)$/.test(fileURLToPath(import.meta.url))) {
1697
+ await startServer();
1700
1698
  }
1701
1699
  //# sourceMappingURL=server.js.map