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