gitnexus 1.6.8-rc.2 → 1.6.8-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +20 -0
  2. package/dist/_shared/scope-resolution/parsed-file.d.ts +21 -0
  3. package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
  4. package/dist/_shared/scope-resolution/symbol-definition.d.ts +4 -0
  5. package/dist/_shared/scope-resolution/symbol-definition.d.ts.map +1 -1
  6. package/dist/cli/ai-context.js +1 -0
  7. package/dist/cli/analyze-config.js +40 -0
  8. package/dist/cli/analyze.d.ts +22 -0
  9. package/dist/cli/analyze.js +36 -6
  10. package/dist/cli/clean.d.ts +1 -0
  11. package/dist/cli/clean.js +43 -1
  12. package/dist/cli/eval-server.js +43 -0
  13. package/dist/cli/help-i18n.js +11 -0
  14. package/dist/cli/i18n/en.d.ts +13 -0
  15. package/dist/cli/i18n/en.js +13 -0
  16. package/dist/cli/i18n/resources.d.ts +26 -0
  17. package/dist/cli/i18n/zh-CN.d.ts +13 -0
  18. package/dist/cli/i18n/zh-CN.js +13 -0
  19. package/dist/cli/index.js +19 -0
  20. package/dist/cli/list.js +12 -0
  21. package/dist/cli/optional-grammars.d.ts +6 -8
  22. package/dist/cli/optional-grammars.js +8 -11
  23. package/dist/cli/skill-gen.js +1 -0
  24. package/dist/cli/status.js +26 -5
  25. package/dist/cli/tool.d.ts +12 -1
  26. package/dist/cli/tool.js +41 -1
  27. package/dist/core/embeddings/embedder.js +4 -0
  28. package/dist/core/embeddings/embedding-pipeline.js +27 -16
  29. package/dist/core/embeddings/onnxruntime-common-resolver.d.ts +6 -0
  30. package/dist/core/embeddings/onnxruntime-common-resolver.js +130 -0
  31. package/dist/core/graph/import-cycles.d.ts +10 -0
  32. package/dist/core/graph/import-cycles.js +103 -0
  33. package/dist/core/group/extractors/grpc-patterns/proto.js +10 -6
  34. package/dist/core/group/extractors/http-patterns/java.js +3 -50
  35. package/dist/core/group/extractors/http-patterns/kotlin.js +7 -7
  36. package/dist/core/group/extractors/include-extractor.js +7 -7
  37. package/dist/core/ingestion/cfg/cfg-builder.d.ts +64 -0
  38. package/dist/core/ingestion/cfg/cfg-builder.js +129 -0
  39. package/dist/core/ingestion/cfg/collect.d.ts +30 -0
  40. package/dist/core/ingestion/cfg/collect.js +34 -0
  41. package/dist/core/ingestion/cfg/control-flow-context.d.ts +97 -0
  42. package/dist/core/ingestion/cfg/control-flow-context.js +113 -0
  43. package/dist/core/ingestion/cfg/emit.d.ts +144 -0
  44. package/dist/core/ingestion/cfg/emit.js +315 -0
  45. package/dist/core/ingestion/cfg/reaching-defs.d.ts +90 -0
  46. package/dist/core/ingestion/cfg/reaching-defs.js +364 -0
  47. package/dist/core/ingestion/cfg/traversal-result.d.ts +20 -0
  48. package/dist/core/ingestion/cfg/traversal-result.js +2 -0
  49. package/dist/core/ingestion/cfg/types.d.ts +231 -0
  50. package/dist/core/ingestion/cfg/types.js +13 -0
  51. package/dist/core/ingestion/cfg/visitors/typescript-harvest.d.ts +144 -0
  52. package/dist/core/ingestion/cfg/visitors/typescript-harvest.js +1032 -0
  53. package/dist/core/ingestion/cfg/visitors/typescript.d.ts +66 -0
  54. package/dist/core/ingestion/cfg/visitors/typescript.js +584 -0
  55. package/dist/core/ingestion/language-provider.d.ts +36 -2
  56. package/dist/core/ingestion/languages/c-cpp.js +11 -4
  57. package/dist/core/ingestion/languages/cpp/arity-metadata.js +6 -2
  58. package/dist/core/ingestion/languages/cpp/captures.js +24 -1
  59. package/dist/core/ingestion/languages/cpp/query.js +23 -0
  60. package/dist/core/ingestion/languages/java.js +3 -0
  61. package/dist/core/ingestion/languages/kotlin/query.js +3 -2
  62. package/dist/core/ingestion/languages/kotlin.js +5 -1
  63. package/dist/core/ingestion/languages/typescript.js +5 -0
  64. package/dist/core/ingestion/method-extractors/configs/c-cpp.js +6 -13
  65. package/dist/core/ingestion/method-extractors/generic.js +1 -0
  66. package/dist/core/ingestion/method-types.d.ts +2 -0
  67. package/dist/core/ingestion/model/symbol-table.d.ts +1 -0
  68. package/dist/core/ingestion/model/symbol-table.js +1 -0
  69. package/dist/core/ingestion/parsing-processor.js +22 -0
  70. package/dist/core/ingestion/pipeline-phases/parse-impl.js +18 -1
  71. package/dist/core/ingestion/pipeline-phases/routes.js +64 -14
  72. package/dist/core/ingestion/pipeline.d.ts +57 -0
  73. package/dist/core/ingestion/route-extractors/spring-shared.d.ts +50 -0
  74. package/dist/core/ingestion/route-extractors/spring-shared.js +80 -0
  75. package/dist/core/ingestion/route-extractors/spring.d.ts +35 -0
  76. package/dist/core/ingestion/route-extractors/spring.js +136 -0
  77. package/dist/core/ingestion/scope-extractor.js +3 -0
  78. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +12 -0
  79. package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.js +145 -24
  80. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +6 -0
  81. package/dist/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js +43 -3
  82. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +21 -0
  83. package/dist/core/ingestion/scope-resolution/pipeline/run.js +207 -0
  84. package/dist/core/ingestion/scope-resolution/resolution-outcome.d.ts +1 -1
  85. package/dist/core/ingestion/taint/emit.d.ts +124 -0
  86. package/dist/core/ingestion/taint/emit.js +204 -0
  87. package/dist/core/ingestion/taint/match.d.ts +153 -0
  88. package/dist/core/ingestion/taint/match.js +278 -0
  89. package/dist/core/ingestion/taint/path-codec.d.ts +134 -0
  90. package/dist/core/ingestion/taint/path-codec.js +190 -0
  91. package/dist/core/ingestion/taint/propagate.d.ts +216 -0
  92. package/dist/core/ingestion/taint/propagate.js +664 -0
  93. package/dist/core/ingestion/taint/site-safety.d.ts +29 -0
  94. package/dist/core/ingestion/taint/site-safety.js +98 -0
  95. package/dist/core/ingestion/taint/source-sink-config.d.ts +94 -23
  96. package/dist/core/ingestion/taint/source-sink-config.js +11 -11
  97. package/dist/core/ingestion/taint/source-sink-registry.d.ts +6 -4
  98. package/dist/core/ingestion/taint/source-sink-registry.js +6 -4
  99. package/dist/core/ingestion/taint/typescript-model.d.ts +38 -0
  100. package/dist/core/ingestion/taint/typescript-model.js +102 -0
  101. package/dist/core/ingestion/utils/method-props.js +1 -0
  102. package/dist/core/ingestion/workers/clone-safety.d.ts +109 -0
  103. package/dist/core/ingestion/workers/clone-safety.js +465 -0
  104. package/dist/core/ingestion/workers/parse-worker.d.ts +11 -0
  105. package/dist/core/ingestion/workers/parse-worker.js +70 -53
  106. package/dist/core/ingestion/workers/post-result.d.ts +22 -0
  107. package/dist/core/ingestion/workers/post-result.js +87 -0
  108. package/dist/core/ingestion/workers/result-merge.d.ts +20 -0
  109. package/dist/core/ingestion/workers/result-merge.js +43 -0
  110. package/dist/core/ingestion/workers/worker-pool.d.ts +15 -0
  111. package/dist/core/ingestion/workers/worker-pool.js +33 -16
  112. package/dist/core/lbug/lbug-adapter.d.ts +19 -0
  113. package/dist/core/lbug/lbug-adapter.js +56 -1
  114. package/dist/core/run-analyze.d.ts +87 -0
  115. package/dist/core/run-analyze.js +280 -25
  116. package/dist/core/tree-sitter/parser-loader.js +5 -4
  117. package/dist/core/tree-sitter/vendored-grammars.d.ts +39 -0
  118. package/dist/core/tree-sitter/vendored-grammars.js +57 -0
  119. package/dist/mcp/core/embedder.js +4 -0
  120. package/dist/mcp/local/local-backend.d.ts +86 -2
  121. package/dist/mcp/local/local-backend.js +657 -27
  122. package/dist/mcp/resources.js +1 -0
  123. package/dist/mcp/tools.d.ts +9 -0
  124. package/dist/mcp/tools.js +109 -0
  125. package/dist/server/analyze-launch.d.ts +29 -0
  126. package/dist/server/analyze-launch.js +137 -0
  127. package/dist/server/analyze-upload.d.ts +33 -0
  128. package/dist/server/analyze-upload.js +123 -0
  129. package/dist/server/analyze-worker-ipc.d.ts +58 -0
  130. package/dist/server/analyze-worker-ipc.js +16 -0
  131. package/dist/server/analyze-worker.d.ts +17 -1
  132. package/dist/server/analyze-worker.js +7 -1
  133. package/dist/server/api.js +46 -140
  134. package/dist/server/git-clone.d.ts +1 -0
  135. package/dist/server/git-clone.js +1 -1
  136. package/dist/server/middleware.d.ts +11 -0
  137. package/dist/server/middleware.js +27 -0
  138. package/dist/server/upload-ingest.d.ts +56 -0
  139. package/dist/server/upload-ingest.js +276 -0
  140. package/dist/server/upload-paths.d.ts +31 -0
  141. package/dist/server/upload-paths.js +51 -0
  142. package/dist/server/upload-sweep.d.ts +20 -0
  143. package/dist/server/upload-sweep.js +57 -0
  144. package/dist/storage/branch-index.d.ts +52 -0
  145. package/dist/storage/branch-index.js +65 -0
  146. package/dist/storage/git.d.ts +11 -0
  147. package/dist/storage/git.js +28 -0
  148. package/dist/storage/parse-cache.d.ts +22 -1
  149. package/dist/storage/parse-cache.js +32 -10
  150. package/dist/storage/repo-manager.d.ts +122 -10
  151. package/dist/storage/repo-manager.js +162 -21
  152. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +42 -8
  153. package/hooks/claude/gitnexus-hook.cjs +36 -8
  154. package/hooks/claude/hook-db-lock-probe.cjs +123 -2
  155. package/package.json +4 -2
  156. package/scripts/assert-publish-grammar-coverage.cjs +31 -0
  157. package/scripts/build-tree-sitter-grammars.cjs +16 -10
  158. package/skills/gitnexus-guide.md +11 -0
  159. package/vendor/tree-sitter-c/package.json +1 -1
  160. package/vendor/tree-sitter-dart/package.json +1 -1
  161. package/vendor/tree-sitter-kotlin/package.json +1 -1
  162. package/vendor/tree-sitter-proto/package.json +1 -1
  163. package/vendor/tree-sitter-swift/package.json +1 -1
  164. package/web/assets/{agent-CKPMqImC.js → agent-ay4LD70X.js} +1 -1
  165. package/web/assets/{architectureDiagram-UL44E2DR-D1EXI0zA.js → architectureDiagram-UL44E2DR-Dc-viYhd.js} +1 -1
  166. package/web/assets/{chunk-LCXTWHL2-Dfmux4m1.js → chunk-LCXTWHL2-4rpojOyj.js} +1 -1
  167. package/web/assets/{chunk-RG4AUYOV-CAkzcoRj.js → chunk-RG4AUYOV-BnOy944n.js} +1 -1
  168. package/web/assets/{classDiagram-KGZ6W3CR-Bv93af_b.js → classDiagram-KGZ6W3CR-Bsgpy98Q.js} +1 -1
  169. package/web/assets/{classDiagram-v2-72OJOZXJ-CLfEqHUa.js → classDiagram-v2-72OJOZXJ-D5atDGjc.js} +1 -1
  170. package/web/assets/{diagram-3NCE3AQN-BqAtKUpW.js → diagram-3NCE3AQN-Cz1OEMVi.js} +1 -1
  171. package/web/assets/{diagram-GF46GFSD-CaBG6n6o.js → diagram-GF46GFSD-CWYwzfP8.js} +1 -1
  172. package/web/assets/{diagram-QXG6HAR7-CZ-O3rcV.js → diagram-QXG6HAR7-DtBObr8L.js} +1 -1
  173. package/web/assets/{diagram-WEQXMOUZ-P4lSL4GH.js → diagram-WEQXMOUZ-BUV44Ov_.js} +1 -1
  174. package/web/assets/{erDiagram-L5TCEMPS-QN2eEP1e.js → erDiagram-L5TCEMPS-Ds9s-sRF.js} +1 -1
  175. package/web/assets/{flowDiagram-H6V6AXG4-CXbXImlN.js → flowDiagram-H6V6AXG4-CupStHQb.js} +1 -1
  176. package/web/assets/index-COMMmbxW.css +2 -0
  177. package/web/assets/{index-CG6q8eTs.js → index-Do8AE5yF.js} +85 -85
  178. package/web/assets/{infoDiagram-3YFTVSEB-DmLICZx1.js → infoDiagram-3YFTVSEB-DPTXSbgu.js} +1 -1
  179. package/web/assets/{ishikawaDiagram-BNXS4ZKH-6LKOvBfp.js → ishikawaDiagram-BNXS4ZKH-CeK-Qhet.js} +1 -1
  180. package/web/assets/{kanban-definition-75IXJCU3-DgDi9oJT.js → kanban-definition-75IXJCU3-D4tx6QQ-.js} +1 -1
  181. package/web/assets/{mindmap-definition-2TDM6QVE-CwR5sBB-.js → mindmap-definition-2TDM6QVE-Bcto_kGa.js} +1 -1
  182. package/web/assets/{pieDiagram-CU6KROY3-By8g6f6B.js → pieDiagram-CU6KROY3-DPIeBTG6.js} +1 -1
  183. package/web/assets/{requirementDiagram-JXO7QTGE-7oDcJ1_J.js → requirementDiagram-JXO7QTGE-DDGXjVKO.js} +1 -1
  184. package/web/assets/{sequenceDiagram-VS2MUI6T-FySeKCUy.js → sequenceDiagram-VS2MUI6T-BzDqpcVW.js} +1 -1
  185. package/web/assets/{stateDiagram-7D4R322I-CB2nABwH.js → stateDiagram-7D4R322I-Bx__als3.js} +1 -1
  186. package/web/assets/{stateDiagram-v2-36443NZ5-COBGd2RL.js → stateDiagram-v2-36443NZ5-QqOh2yO-.js} +1 -1
  187. package/web/assets/{timeline-definition-O6YCAMPW-Ds2CnVZK.js → timeline-definition-O6YCAMPW-C1eogTOG.js} +1 -1
  188. package/web/assets/{vennDiagram-MWXL3ELB-DUIEwXWp.js → vennDiagram-MWXL3ELB-D20F4rSW.js} +1 -1
  189. package/web/assets/{wardleyDiagram-CUQ6CDDI-DEiFPQih.js → wardleyDiagram-CUQ6CDDI-BeBHg7ST.js} +1 -1
  190. package/web/assets/{xychartDiagram-N2JHSOCM-BUbayhST.js → xychartDiagram-N2JHSOCM-CdOWeoNI.js} +1 -1
  191. package/web/index.html +2 -2
  192. package/scripts/materialize-vendor-grammars.cjs +0 -97
  193. package/web/assets/index-BKWA-m7o.css +0 -2
@@ -20,11 +20,15 @@ import { searchFTSFromLbug } from '../core/search/bm25-index.js';
20
20
  import { hybridSearch } from '../core/search/hybrid-search.js';
21
21
  import { LocalBackend } from '../mcp/local/local-backend.js';
22
22
  import { mountMCPEndpoints } from './mcp-http.js';
23
- import { fork } from 'child_process';
24
- import { fileURLToPath, pathToFileURL } from 'url';
23
+ import { fileURLToPath } from 'url';
25
24
  import { JobManager } from './analyze-job.js';
26
25
  import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js';
27
26
  import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js';
27
+ import { createAnalyzeUploadHandler } from './analyze-upload.js';
28
+ import { requireLocalhostOrigin } from './middleware.js';
29
+ import { createLaunchAnalysisWorker } from './analyze-launch.js';
30
+ import { UPLOAD_ROOT } from './upload-paths.js';
31
+ import { sweepStaleUploads } from './upload-sweep.js';
28
32
  import { logger, flushLoggerSync } from '../core/logger.js';
29
33
  const _require = createRequire(import.meta.url);
30
34
  const pkg = _require('../../package.json');
@@ -640,6 +644,8 @@ export const createServer = async (port, host = '127.0.0.1') => {
640
644
  await backend.init();
641
645
  const cleanupMcp = mountMCPEndpoints(app, backend);
642
646
  const jobManager = new JobManager();
647
+ // Backstop: remove any upload staging dirs orphaned by a previous crash.
648
+ void sweepStaleUploads().catch(() => { });
643
649
  // Shared repo lock — prevents concurrent analyze + embed on the same repo path,
644
650
  // which would corrupt LadybugDB (analyze calls closeLbug + initLbug while embed has queries in flight).
645
651
  const activeRepoPaths = new Set();
@@ -653,6 +659,14 @@ export const createServer = async (port, host = '127.0.0.1') => {
653
659
  const releaseRepoLock = (repoPath) => {
654
660
  activeRepoPaths.delete(repoPath);
655
661
  };
662
+ // Launch the analyze worker for an already-resolved repo directory. Shared by
663
+ // the JSON /api/analyze route and the multipart /api/analyze/upload route.
664
+ const launchAnalysisWorker = createLaunchAnalysisWorker({
665
+ jobManager,
666
+ backend,
667
+ acquireRepoLock,
668
+ releaseRepoLock,
669
+ });
656
670
  /**
657
671
  * Maximum time the hold-queue will wait for an active analysis job to complete.
658
672
  * Must stay in sync with the frontend's `fetchRepoInfo({ awaitAnalysis: true })` timeout.
@@ -867,6 +881,13 @@ export const createServer = async (port, host = '127.0.0.1') => {
867
881
  /* clone dir may not exist */
868
882
  }
869
883
  }
884
+ // 2b. Delete the uploaded repo dir if entry.path lives under
885
+ // UPLOAD_ROOT. Drive this off entry.path (not a name-rederived dir) so
886
+ // a same-named clone is never affected.
887
+ const resolvedEntry = path.resolve(entry.path);
888
+ if (resolvedEntry === UPLOAD_ROOT || resolvedEntry.startsWith(UPLOAD_ROOT + path.sep)) {
889
+ await fs.rm(resolvedEntry, { recursive: true, force: true }).catch(() => { });
890
+ }
870
891
  // 3. Unregister from the global registry
871
892
  const { unregisterRepo } = await import('../storage/repo-manager.js');
872
893
  await unregisterRepo(entry.path);
@@ -1251,7 +1272,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
1251
1272
  });
1252
1273
  // ── Analyze API ──────────────────────────────────────────────────────
1253
1274
  // POST /api/analyze — start a new analysis job
1254
- app.post('/api/analyze', createRouteLimiter({ limit: 10 }), async (req, res) => {
1275
+ app.post('/api/analyze', createRouteLimiter({ limit: 10 }), requireLocalhostOrigin, async (req, res) => {
1255
1276
  try {
1256
1277
  const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings } = req.body;
1257
1278
  // Input type validation
@@ -1267,16 +1288,18 @@ export const createServer = async (port, host = '127.0.0.1') => {
1267
1288
  res.status(400).json({ error: 'Provide "url" (git URL) or "path" (local path)' });
1268
1289
  return;
1269
1290
  }
1270
- // Path validation: require absolute path, reject traversal (e.g. /tmp/../etc/passwd)
1271
- if (repoLocalPath) {
1272
- if (!path.isAbsolute(repoLocalPath)) {
1273
- res.status(400).json({ error: '"path" must be an absolute path' });
1274
- return;
1275
- }
1276
- if (path.normalize(repoLocalPath) !== path.resolve(repoLocalPath)) {
1277
- res.status(400).json({ error: '"path" must not contain traversal sequences' });
1278
- return;
1279
- }
1291
+ // Path validation. The previous `normalize !== resolve` guard was inert
1292
+ // (both collapse `..` identically) and only false-rejected trailing
1293
+ // slashes, so it is dropped. Analyzing a local path the operator names
1294
+ // is the tool's intended capability (same as the CLI); the dangerous
1295
+ // part was cross-origin reach, which is closed by requireLocalhostOrigin
1296
+ // on this route. We only require an absolute path here and let the
1297
+ // analyze worker surface a clear error if it does not exist. (We do NOT
1298
+ // realpath/stat the path in-route: that would be a user-controlled
1299
+ // filesystem read — CodeQL js/path-injection — for no security gain.)
1300
+ if (repoLocalPath && !path.isAbsolute(repoLocalPath)) {
1301
+ res.status(400).json({ error: '"path" must be an absolute path' });
1302
+ return;
1280
1303
  }
1281
1304
  const job = jobManager.createJob({ repoUrl, repoPath: repoLocalPath });
1282
1305
  // If job was already running (dedup), just return its id
@@ -1308,133 +1331,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
1308
1331
  if (!targetPath) {
1309
1332
  throw new Error('No target path resolved');
1310
1333
  }
1311
- // Acquire shared repo lock (keyed on storagePath to match embed handler)
1312
- const analyzeLockKey = getStoragePath(targetPath);
1313
- const lockErr = acquireRepoLock(analyzeLockKey);
1314
- if (lockErr) {
1315
- jobManager.updateJob(job.id, { status: 'failed', error: lockErr });
1316
- return;
1317
- }
1318
- jobManager.updateJob(job.id, { repoPath: targetPath, status: 'analyzing' });
1319
- // ── Worker fork with auto-retry ──────────────────────────────
1320
- //
1321
- // Forks a child process with 8GB heap. If the worker crashes
1322
- // (OOM, native addon segfault, etc.), it retries up to
1323
- // MAX_WORKER_RETRIES times with exponential backoff before
1324
- // marking the job as permanently failed.
1325
- //
1326
- // In dev mode (tsx), registers the tsx ESM hook via a file://
1327
- // URL so the child can compile TypeScript on-the-fly.
1328
- const MAX_WORKER_RETRIES = 2;
1329
- const callerPath = fileURLToPath(import.meta.url);
1330
- const isDev = callerPath.endsWith('.ts');
1331
- const workerFile = isDev ? 'analyze-worker.ts' : 'analyze-worker.js';
1332
- const workerPath = path.join(path.dirname(callerPath), workerFile);
1333
- const tsxHookArgs = isDev
1334
- ? ['--import', pathToFileURL(_require.resolve('tsx/esm')).href]
1335
- : [];
1336
- const forkWorker = () => {
1337
- const currentJob = jobManager.getJob(job.id);
1338
- if (!currentJob || currentJob.status === 'complete' || currentJob.status === 'failed')
1339
- return;
1340
- const child = fork(workerPath, [], {
1341
- execArgv: [...tsxHookArgs, '--max-old-space-size=8192'],
1342
- stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
1343
- });
1344
- // Capture stderr for crash diagnostics
1345
- let stderrChunks = '';
1346
- child.stderr?.on('data', (chunk) => {
1347
- stderrChunks += chunk.toString();
1348
- if (stderrChunks.length > 4096)
1349
- stderrChunks = stderrChunks.slice(-4096);
1350
- });
1351
- child.on('message', (msg) => {
1352
- if (msg.type === 'progress') {
1353
- jobManager.updateJob(job.id, {
1354
- status: 'analyzing',
1355
- progress: { phase: msg.phase, percent: msg.percent, message: msg.message },
1356
- });
1357
- }
1358
- else if (msg.type === 'complete') {
1359
- releaseRepoLock(analyzeLockKey);
1360
- // Reinitialize backend BEFORE marking complete — ensures the new
1361
- // repo is queryable when the client receives the SSE complete event.
1362
- backend
1363
- .init()
1364
- .then(() => {
1365
- jobManager.updateJob(job.id, {
1366
- status: 'complete',
1367
- repoName: msg.result.repoName,
1368
- });
1369
- })
1370
- .catch((err) => {
1371
- logger.error({ err }, 'backend.init() failed after analyze:');
1372
- jobManager.updateJob(job.id, {
1373
- status: 'failed',
1374
- error: 'Server failed to reload after analysis. Try again.',
1375
- });
1376
- });
1377
- }
1378
- else if (msg.type === 'error') {
1379
- releaseRepoLock(analyzeLockKey);
1380
- jobManager.updateJob(job.id, {
1381
- status: 'failed',
1382
- error: msg.message,
1383
- });
1384
- }
1385
- });
1386
- child.on('error', (err) => {
1387
- releaseRepoLock(analyzeLockKey);
1388
- jobManager.updateJob(job.id, {
1389
- status: 'failed',
1390
- error: `Worker process error: ${err.message}`,
1391
- });
1392
- });
1393
- child.on('exit', (code) => {
1394
- const j = jobManager.getJob(job.id);
1395
- if (!j || j.status === 'complete' || j.status === 'failed')
1396
- return;
1397
- // Worker crashed — attempt retry if under the limit
1398
- if (j.retryCount < MAX_WORKER_RETRIES) {
1399
- j.retryCount++;
1400
- const delay = 1000 * Math.pow(2, j.retryCount - 1); // 1s, 2s
1401
- const lastErr = stderrChunks.trim().split('\n').pop() || '';
1402
- logger.warn(`Analyze worker crashed (code ${code}), retry ${j.retryCount}/${MAX_WORKER_RETRIES} in ${delay}ms` +
1403
- (lastErr ? `: ${lastErr}` : ''));
1404
- jobManager.updateJob(job.id, {
1405
- status: 'analyzing',
1406
- progress: {
1407
- phase: 'retrying',
1408
- percent: j.progress.percent,
1409
- message: `Worker crashed, retrying (${j.retryCount}/${MAX_WORKER_RETRIES})...`,
1410
- },
1411
- });
1412
- stderrChunks = '';
1413
- setTimeout(forkWorker, delay);
1414
- }
1415
- else {
1416
- // Exhausted retries — permanent failure
1417
- releaseRepoLock(analyzeLockKey);
1418
- jobManager.updateJob(job.id, {
1419
- status: 'failed',
1420
- error: `Worker crashed ${MAX_WORKER_RETRIES + 1} times (code ${code})${stderrChunks ? ': ' + stderrChunks.trim().split('\n').pop() : ''}`,
1421
- });
1422
- }
1423
- });
1424
- // Register child for cancellation + timeout tracking
1425
- jobManager.registerChild(job.id, child);
1426
- // Send start command to child
1427
- child.send({
1428
- type: 'start',
1429
- repoPath: targetPath,
1430
- options: {
1431
- force: !!force,
1432
- embeddings: !!embeddings,
1433
- dropEmbeddings: !!dropEmbeddings,
1434
- },
1435
- });
1436
- };
1437
- forkWorker();
1334
+ launchAnalysisWorker(job, targetPath, { force, embeddings, dropEmbeddings });
1438
1335
  }
1439
1336
  catch (err) {
1440
1337
  if (targetPath)
@@ -1456,6 +1353,15 @@ export const createServer = async (port, host = '127.0.0.1') => {
1456
1353
  }
1457
1354
  }
1458
1355
  });
1356
+ // POST /api/analyze/upload — analyze a browser folder upload.
1357
+ // Securely ingests the multipart upload into a sandbox, promotes it to a
1358
+ // persistent dir, and analyzes it via the shared job/worker machinery.
1359
+ // localhost-only (no cross-origin write reach) + conservative rate limit.
1360
+ app.post('/api/analyze/upload', createRouteLimiter({ limit: 5 }), requireLocalhostOrigin, createAnalyzeUploadHandler({
1361
+ createJob: (params) => jobManager.createJob(params),
1362
+ launch: (job, targetPath, opts) => launchAnalysisWorker(job, targetPath, opts),
1363
+ failJob: (jobId, error) => jobManager.updateJob(jobId, { status: 'failed', error }),
1364
+ }));
1459
1365
  // GET /api/analyze/:jobId — poll job status
1460
1366
  app.get('/api/analyze/:jobId', (req, res) => {
1461
1367
  const job = jobManager.getJob(req.params.jobId);
@@ -4,6 +4,7 @@
4
4
  * Shallow-clones repositories into ~/.gitnexus/repos/{name}/.
5
5
  * If already cloned, does git pull instead.
6
6
  */
7
+ export declare const REPO_NAME_PATTERN: RegExp;
7
8
  /**
8
9
  * Extract the repository name from a git URL (HTTPS or SSH).
9
10
  *
@@ -17,7 +17,7 @@ const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos'));
17
17
  // Rejecting anything else (including `..`, `/`, `\`, shell metacharacters)
18
18
  // guarantees getCloneDir(repoName) cannot escape CLONE_ROOT regardless of
19
19
  // how the caller derived repoName.
20
- const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
20
+ export const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
21
21
  /**
22
22
  * Extract the repository name from a git URL (HTTPS or SSH).
23
23
  *
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared Express route guards (alongside createRouteLimiter in validation.ts).
3
+ */
4
+ import type { Request, Response } from 'express';
5
+ /**
6
+ * Restrict a route to localhost browser origins. Non-browser requests (no
7
+ * Origin header, e.g. curl / the CLI) pass through. This closes cross-origin
8
+ * reach (the allow-listed public deploy + Private Network Access) to write
9
+ * routes without affecting read routes.
10
+ */
11
+ export declare function requireLocalhostOrigin(req: Request, res: Response, next: () => void): void;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared Express route guards (alongside createRouteLimiter in validation.ts).
3
+ */
4
+ /**
5
+ * Restrict a route to localhost browser origins. Non-browser requests (no
6
+ * Origin header, e.g. curl / the CLI) pass through. This closes cross-origin
7
+ * reach (the allow-listed public deploy + Private Network Access) to write
8
+ * routes without affecting read routes.
9
+ */
10
+ export function requireLocalhostOrigin(req, res, next) {
11
+ const origin = req.headers.origin;
12
+ if (origin === undefined) {
13
+ next();
14
+ return;
15
+ }
16
+ try {
17
+ const hostname = new URL(origin).hostname;
18
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
19
+ next();
20
+ return;
21
+ }
22
+ }
23
+ catch {
24
+ /* malformed origin → reject */
25
+ }
26
+ res.status(403).json({ error: 'This endpoint is restricted to localhost origins' });
27
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Secure ingestion of a browser folder upload (multipart/form-data).
3
+ *
4
+ * Replaces the path-injection-prone GET /api/fs/list directory listing. The
5
+ * client streams the selected files plus a JSON `manifest` of their
6
+ * webkitRelativePaths; we write each into an mkdtemp staging dir under
7
+ * UPLOAD_ROOT with PROVABLE containment (resolve-then-contain), hard resource
8
+ * caps, and guaranteed cleanup on every failure/abort path. No client value
9
+ * ever reaches a filesystem READ — the server only writes into a sandbox it
10
+ * created, then hands that sandbox to the analysis pipeline.
11
+ *
12
+ * Security references: CodeQL js/path-injection (resolve + startsWith(root+sep)),
13
+ * OWASP File Upload / Path Traversal.
14
+ */
15
+ import type { IncomingMessage } from 'http';
16
+ export interface IngestLimits {
17
+ /** Aggregate bytes across all files (busboy has no aggregate limit). */
18
+ maxTotalBytes: number;
19
+ /** Per-file byte cap. */
20
+ maxFileBytes: number;
21
+ /** Maximum number of files. */
22
+ maxFiles: number;
23
+ /** Maximum multipart parts (files + fields). */
24
+ maxParts: number;
25
+ /** Maximum directories created (inode-exhaustion guard). */
26
+ maxDirs: number;
27
+ /** Maximum size of the manifest field. */
28
+ maxFieldBytes: number;
29
+ }
30
+ export declare const DEFAULT_INGEST_LIMITS: IngestLimits;
31
+ export interface IngestResult {
32
+ /** Absolute path to the populated staging directory (realpath-canonical). */
33
+ stageRoot: string;
34
+ fileCount: number;
35
+ totalBytes: number;
36
+ /** First path segment shared by the uploaded tree (the picked folder). */
37
+ topLevelName: string;
38
+ }
39
+ /**
40
+ * Resolve a client-provided relative path to an absolute destination PROVABLY
41
+ * contained within `stageRoot`. Throws BadRequestError on any unsafe input.
42
+ * This is the load-bearing path-traversal-on-write control; keep it pure and
43
+ * unit-tested.
44
+ */
45
+ export declare function resolveContainedDest(stageRoot: string, rel: unknown): string;
46
+ export interface IngestOptions {
47
+ /** Override the staging parent dir (defaults to UPLOAD_ROOT; for tests). */
48
+ root?: string;
49
+ }
50
+ /**
51
+ * Parse and securely write a multipart folder upload into a fresh staging
52
+ * directory under UPLOAD_ROOT. Resolves with the populated staging dir, or
53
+ * rejects with a BadRequestError (status 400/413) after removing the staging
54
+ * dir. The caller owns promotion + cleanup of the returned `stageRoot`.
55
+ */
56
+ export declare function ingestUpload(req: IncomingMessage, limitsOverride?: Partial<IngestLimits>, opts?: IngestOptions): Promise<IngestResult>;
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Secure ingestion of a browser folder upload (multipart/form-data).
3
+ *
4
+ * Replaces the path-injection-prone GET /api/fs/list directory listing. The
5
+ * client streams the selected files plus a JSON `manifest` of their
6
+ * webkitRelativePaths; we write each into an mkdtemp staging dir under
7
+ * UPLOAD_ROOT with PROVABLE containment (resolve-then-contain), hard resource
8
+ * caps, and guaranteed cleanup on every failure/abort path. No client value
9
+ * ever reaches a filesystem READ — the server only writes into a sandbox it
10
+ * created, then hands that sandbox to the analysis pipeline.
11
+ *
12
+ * Security references: CodeQL js/path-injection (resolve + startsWith(root+sep)),
13
+ * OWASP File Upload / Path Traversal.
14
+ */
15
+ import path from 'path';
16
+ import fs from 'fs';
17
+ import fsp from 'fs/promises';
18
+ import busboy from 'busboy';
19
+ import { UPLOAD_ROOT, STAGING_PREFIX } from './upload-paths.js';
20
+ import { BadRequestError } from './validation.js';
21
+ export const DEFAULT_INGEST_LIMITS = {
22
+ maxTotalBytes: 250 * 1024 * 1024,
23
+ maxFileBytes: 25 * 1024 * 1024,
24
+ maxFiles: 20000,
25
+ maxParts: 20100,
26
+ maxDirs: 50000,
27
+ maxFieldBytes: 2 * 1024 * 1024,
28
+ };
29
+ const MAX_PATH_DEPTH = 64;
30
+ const MAX_PATH_LENGTH = 4096;
31
+ /**
32
+ * Resolve a client-provided relative path to an absolute destination PROVABLY
33
+ * contained within `stageRoot`. Throws BadRequestError on any unsafe input.
34
+ * This is the load-bearing path-traversal-on-write control; keep it pure and
35
+ * unit-tested.
36
+ */
37
+ export function resolveContainedDest(stageRoot, rel) {
38
+ if (typeof rel !== 'string' || rel.length === 0) {
39
+ throw new BadRequestError('Invalid upload path');
40
+ }
41
+ if (rel.length > MAX_PATH_LENGTH) {
42
+ throw new BadRequestError('Upload path too long');
43
+ }
44
+ // webkitRelativePath is always relative; a leading slash is absolute/hostile.
45
+ if (rel.startsWith('/')) {
46
+ throw new BadRequestError('Invalid upload path');
47
+ }
48
+ // Browsers emit forward slashes only; a NUL byte or backslash is hostile.
49
+ if (rel.includes('\u0000') || rel.includes('\\')) {
50
+ throw new BadRequestError('Invalid upload path');
51
+ }
52
+ const rawSegments = rel.split('/').filter((s) => s.length > 0);
53
+ if (rawSegments.length === 0 || rawSegments.length > MAX_PATH_DEPTH) {
54
+ throw new BadRequestError('Invalid upload path');
55
+ }
56
+ const segments = [];
57
+ for (const seg of rawSegments) {
58
+ // Normalize so NFC/NFD variants don't collide silently on case/unicode
59
+ // -folding filesystems (macOS/Windows).
60
+ const s = seg.normalize('NFC');
61
+ if (s === '.' || s === '..') {
62
+ throw new BadRequestError('Upload path must not contain traversal segments');
63
+ }
64
+ segments.push(s);
65
+ }
66
+ const dest = path.resolve(stageRoot, segments.join(path.sep));
67
+ // Suffix path.sep so a sibling prefix (/sandbox-evil vs /sandbox) can't pass.
68
+ if (dest !== stageRoot && !dest.startsWith(stageRoot + path.sep)) {
69
+ throw new BadRequestError('Upload path escapes the sandbox');
70
+ }
71
+ return dest;
72
+ }
73
+ /**
74
+ * Create the parent directories of `destFile` one segment at a time, asserting
75
+ * after each `mkdir` that the segment is a real directory (not a symlink
76
+ * swapped in mid-stream) still inside `stageRoot`. Counts created dirs against
77
+ * the inode-exhaustion cap.
78
+ */
79
+ function mkdirContained(stageRoot, destFile, state) {
80
+ const parent = path.dirname(destFile);
81
+ const relParent = path.relative(stageRoot, parent);
82
+ if (relParent === '' || relParent === '.')
83
+ return;
84
+ const segs = relParent.split(path.sep).filter(Boolean);
85
+ let cur = stageRoot;
86
+ for (const seg of segs) {
87
+ cur = path.join(cur, seg);
88
+ let made = false;
89
+ try {
90
+ fs.mkdirSync(cur);
91
+ made = true;
92
+ }
93
+ catch (err) {
94
+ if (err.code !== 'EEXIST')
95
+ throw err;
96
+ }
97
+ const st = fs.lstatSync(cur);
98
+ if (st.isSymbolicLink() || !st.isDirectory()) {
99
+ throw new BadRequestError('Upload path escapes the sandbox');
100
+ }
101
+ if (made) {
102
+ state.dirCount++;
103
+ if (state.dirCount > state.limits.maxDirs) {
104
+ throw new BadRequestError('Too many directories in upload', 413);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ /**
110
+ * Parse and securely write a multipart folder upload into a fresh staging
111
+ * directory under UPLOAD_ROOT. Resolves with the populated staging dir, or
112
+ * rejects with a BadRequestError (status 400/413) after removing the staging
113
+ * dir. The caller owns promotion + cleanup of the returned `stageRoot`.
114
+ */
115
+ export async function ingestUpload(req, limitsOverride, opts = {}) {
116
+ const limits = { ...DEFAULT_INGEST_LIMITS, ...limitsOverride };
117
+ const uploadRoot = opts.root ?? UPLOAD_ROOT;
118
+ await fsp.mkdir(uploadRoot, { recursive: true });
119
+ // mkdtemp creates the dir mode 0o700 (owner-only); realpath canonicalizes
120
+ // the root so the containment prefix check is exact.
121
+ const stageRoot = await fsp.realpath(await fsp.mkdtemp(path.join(uploadRoot, STAGING_PREFIX)));
122
+ let cleaned = false;
123
+ const cleanup = async () => {
124
+ if (cleaned)
125
+ return;
126
+ cleaned = true;
127
+ await fsp.rm(stageRoot, { recursive: true, force: true }).catch(() => { });
128
+ };
129
+ return new Promise((resolve, reject) => {
130
+ let settled = false;
131
+ let manifest = null;
132
+ let fileIndex = 0;
133
+ let fileCount = 0;
134
+ let totalBytes = 0;
135
+ let topLevelName = '';
136
+ const dirState = { dirCount: 0, limits };
137
+ const writePromises = [];
138
+ const bb = busboy({
139
+ headers: req.headers,
140
+ limits: {
141
+ fileSize: limits.maxFileBytes,
142
+ files: limits.maxFiles,
143
+ parts: limits.maxParts,
144
+ fields: 10,
145
+ fieldNameSize: 200,
146
+ fieldSize: limits.maxFieldBytes,
147
+ headerPairs: 2000,
148
+ },
149
+ });
150
+ const fail = (err) => {
151
+ if (settled)
152
+ return;
153
+ settled = true;
154
+ try {
155
+ req.unpipe(bb);
156
+ }
157
+ catch {
158
+ /* ignore */
159
+ }
160
+ try {
161
+ req.resume(); // drain remaining body so the socket isn't left hanging
162
+ }
163
+ catch {
164
+ /* ignore */
165
+ }
166
+ void cleanup().finally(() => reject(err));
167
+ };
168
+ bb.on('field', (name, val) => {
169
+ if (name !== 'manifest')
170
+ return;
171
+ try {
172
+ const parsed = JSON.parse(val);
173
+ if (!Array.isArray(parsed) || !parsed.every((p) => typeof p === 'string')) {
174
+ return fail(new BadRequestError('Invalid manifest'));
175
+ }
176
+ manifest = parsed;
177
+ }
178
+ catch {
179
+ fail(new BadRequestError('Invalid manifest'));
180
+ }
181
+ });
182
+ bb.on('file', (_name, stream, _info) => {
183
+ if (settled) {
184
+ stream.resume();
185
+ return;
186
+ }
187
+ if (manifest === null) {
188
+ // The manifest field MUST arrive before any file part.
189
+ stream.resume();
190
+ return fail(new BadRequestError('Manifest must precede file parts'));
191
+ }
192
+ const idx = fileIndex++;
193
+ const rel = manifest[idx];
194
+ let dest;
195
+ try {
196
+ dest = resolveContainedDest(stageRoot, rel);
197
+ // A folder upload is exactly one top-level directory: every entry must
198
+ // have ≥2 segments and share the same first segment. This rejects a
199
+ // bare file at the root (which would make the promote target a file)
200
+ // and a multi-top manifest (which would silently drop all but the
201
+ // first folder). Validated here, before any job is created.
202
+ const segs = String(rel)
203
+ .split('/')
204
+ .filter((s) => s.length > 0);
205
+ const firstSeg = (segs[0] ?? '').normalize('NFC');
206
+ if (!topLevelName) {
207
+ topLevelName = firstSeg;
208
+ }
209
+ if (segs.length < 2 || firstSeg !== topLevelName) {
210
+ throw new BadRequestError('Upload must be a single folder of files');
211
+ }
212
+ mkdirContained(stageRoot, dest, dirState);
213
+ }
214
+ catch (err) {
215
+ stream.resume();
216
+ return fail(err);
217
+ }
218
+ fileCount++;
219
+ const ws = fs.createWriteStream(dest, { flags: 'wx' });
220
+ const p = new Promise((res, rej) => {
221
+ stream.on('data', (chunk) => {
222
+ totalBytes += chunk.length;
223
+ if (totalBytes > limits.maxTotalBytes) {
224
+ stream.unpipe(ws);
225
+ ws.destroy();
226
+ rej(new BadRequestError('Upload exceeds total size limit', 413));
227
+ }
228
+ });
229
+ stream.on('limit', () => {
230
+ ws.destroy();
231
+ rej(new BadRequestError('File exceeds size limit', 413));
232
+ });
233
+ stream.on('error', rej);
234
+ ws.on('error', rej);
235
+ ws.on('finish', () => res());
236
+ stream.pipe(ws);
237
+ });
238
+ writePromises.push(p);
239
+ p.catch(fail);
240
+ });
241
+ bb.on('filesLimit', () => fail(new BadRequestError('Too many files in upload', 413)));
242
+ bb.on('partsLimit', () => fail(new BadRequestError('Too many parts in upload', 413)));
243
+ bb.on('fieldsLimit', () => fail(new BadRequestError('Too many fields in upload')));
244
+ bb.on('error', (err) => fail(err instanceof Error ? err : new BadRequestError('Upload parse error')));
245
+ bb.on('close', () => {
246
+ if (settled)
247
+ return;
248
+ Promise.all(writePromises)
249
+ .then(() => {
250
+ if (settled)
251
+ return;
252
+ if (manifest === null)
253
+ return fail(new BadRequestError('Missing manifest'));
254
+ if (fileCount === 0)
255
+ return fail(new BadRequestError('Empty upload'));
256
+ if (fileCount !== manifest.length) {
257
+ return fail(new BadRequestError('Manifest/file count mismatch'));
258
+ }
259
+ if (!topLevelName) {
260
+ return fail(new BadRequestError('Could not determine upload folder name'));
261
+ }
262
+ settled = true;
263
+ resolve({ stageRoot, fileCount, totalBytes, topLevelName });
264
+ })
265
+ .catch(() => {
266
+ /* a write rejected → fail() already invoked via p.catch */
267
+ });
268
+ });
269
+ req.on('aborted', () => {
270
+ if (!settled)
271
+ fail(new BadRequestError('Upload aborted'));
272
+ });
273
+ req.on('error', (err) => fail(err instanceof Error ? err : new BadRequestError('Request error')));
274
+ req.pipe(bb);
275
+ });
276
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Upload working-directory paths.
3
+ *
4
+ * Browser folder uploads are written into ~/.gitnexus/uploads/{name}/ — a
5
+ * sibling of the clone root (git-clone.ts CLONE_ROOT) — so an uploaded repo
6
+ * persists and behaves like a cloned one (the graph UI's /api/file reads its
7
+ * files after analysis, and DELETE /api/repo removes it). Staging happens in
8
+ * an mkdtemp dir *under* UPLOAD_ROOT so the promote rename stays on one
9
+ * filesystem and remains atomic (a rename from os.tmpdir() could trip EXDEV —
10
+ * the exact Docker case this feature targets; see bridge-db.ts for the same
11
+ * anchored-staging pattern).
12
+ */
13
+ /** Root directory for all uploaded repositories. Targets must resolve inside this. */
14
+ export declare const UPLOAD_ROOT: string;
15
+ /** Prefix for per-upload staging directories created under UPLOAD_ROOT. */
16
+ export declare const STAGING_PREFIX = ".staging-";
17
+ /**
18
+ * Get the upload target directory for a repo name.
19
+ *
20
+ * Re-validates at the boundary (callers may derive the name from an untrusted
21
+ * manifest). Rejects `.`, `..`, the `'unknown'` sentinel that sanitizeRepoName
22
+ * emits for un-nameable inputs, names beginning with `.` (which would collide
23
+ * with the `.staging-` prefix), and anything outside the safe charset.
24
+ */
25
+ export declare function getUploadDir(repoName: string): string;
26
+ /**
27
+ * Derive a filesystem-safe upload directory name from the manifest's
28
+ * top-level folder. Returns null when the name is un-nameable (so the caller
29
+ * rejects with 400 rather than colliding everyone on `UPLOAD_ROOT/unknown`).
30
+ */
31
+ export declare function deriveUploadName(topLevelName: string): string | null;