@agent-inspect/studio 6.17.5 → 6.17.6

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/dist/index.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var promises = require('fs/promises');
4
- var path8 = require('path');
4
+ var path10 = require('path');
5
5
  var advanced = require('agent-inspect/advanced');
6
6
  var workspace = require('agent-inspect/workspace');
7
7
  var fs = require('fs');
@@ -16,7 +16,7 @@ var readers = require('agent-inspect/readers');
16
16
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
17
17
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
18
18
 
19
- var path8__default = /*#__PURE__*/_interopDefault(path8);
19
+ var path10__default = /*#__PURE__*/_interopDefault(path10);
20
20
 
21
21
  // packages/studio/src/registry.ts
22
22
  function isSafeRelativePath(p) {
@@ -26,14 +26,14 @@ function isSafeRelativePath(p) {
26
26
  return !trimmed.split(/[/\\]+/).some((seg) => seg === "..");
27
27
  }
28
28
  function resolveUnderRoot(root, ...segments) {
29
- const resolvedRoot = path8__default.default.resolve(root);
30
- const resolved = path8__default.default.resolve(resolvedRoot, ...segments);
29
+ const resolvedRoot = path10__default.default.resolve(root);
30
+ const resolved = path10__default.default.resolve(resolvedRoot, ...segments);
31
31
  assertPathUnderRoot(resolved, resolvedRoot);
32
32
  return resolved;
33
33
  }
34
34
  function assertPathUnderRoot(resolved, root) {
35
- const rel = path8__default.default.relative(path8__default.default.resolve(root), path8__default.default.resolve(resolved));
36
- if (rel.startsWith("..") || path8__default.default.isAbsolute(rel)) {
35
+ const rel = path10__default.default.relative(path10__default.default.resolve(root), path10__default.default.resolve(resolved));
36
+ if (rel.startsWith("..") || path10__default.default.isAbsolute(rel)) {
37
37
  throw new Error("path escapes allowed registry root");
38
38
  }
39
39
  }
@@ -214,7 +214,23 @@ function parseStudioRegistry(input) {
214
214
  ingestConfig.bundleUpload = bundleUpload;
215
215
  }
216
216
  }
217
- const knownIngestKeys = /* @__PURE__ */ new Set(["github", "http", "bundleUpload"]);
217
+ if (input.ingest.fileDrop !== void 0) {
218
+ if (!isPlainObject(input.ingest.fileDrop)) {
219
+ errors.push("ingest.fileDrop must be an object");
220
+ } else {
221
+ const fileDrop = {};
222
+ if (input.ingest.fileDrop.maxBytes !== void 0) {
223
+ const maxBytes = Number(input.ingest.fileDrop.maxBytes);
224
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
225
+ errors.push("ingest.fileDrop.maxBytes must be a positive integer");
226
+ } else {
227
+ fileDrop.maxBytes = maxBytes;
228
+ }
229
+ }
230
+ ingestConfig.fileDrop = fileDrop;
231
+ }
232
+ }
233
+ const knownIngestKeys = /* @__PURE__ */ new Set(["github", "http", "bundleUpload", "fileDrop"]);
218
234
  for (const key of Object.keys(input.ingest)) {
219
235
  if (!knownIngestKeys.has(key)) {
220
236
  ingestWarnings.push(`ignored unknown ingest key: ${key}`);
@@ -255,7 +271,7 @@ async function readStudioRegistryFile(filePath) {
255
271
  }
256
272
  }
257
273
  function resolveRegistryProjectPath(registryDir, projectPath) {
258
- return path8__default.default.isAbsolute(projectPath) ? path8__default.default.resolve(projectPath) : path8__default.default.resolve(registryDir, projectPath);
274
+ return path10__default.default.isAbsolute(projectPath) ? path10__default.default.resolve(projectPath) : path10__default.default.resolve(registryDir, projectPath);
259
275
  }
260
276
  var cached;
261
277
  function loadBetterSqlite3() {
@@ -320,9 +336,9 @@ function resolveStudioDbPath(options) {
320
336
  if (raw.startsWith("postgres://") || raw.startsWith("postgresql://")) {
321
337
  return raw;
322
338
  }
323
- return path8__default.default.resolve(options.cwd ?? process.cwd(), raw);
339
+ return path10__default.default.resolve(options.cwd ?? process.cwd(), raw);
324
340
  }
325
- return path8__default.default.resolve(
341
+ return path10__default.default.resolve(
326
342
  options.cwd ?? process.cwd(),
327
343
  ".agent-inspect",
328
344
  DEFAULT_STUDIO_DB_FILENAME
@@ -337,7 +353,7 @@ function openStudioDb(dbPath) {
337
353
  "Postgres studio databases are not supported; use a SQLite file path (preview only)."
338
354
  );
339
355
  }
340
- const dir = path8__default.default.dirname(dbPath);
356
+ const dir = path10__default.default.dirname(dbPath);
341
357
  try {
342
358
  fs.mkdirSync(dir, { recursive: true });
343
359
  } catch (error) {
@@ -450,14 +466,14 @@ function insertIngestFile(db, row) {
450
466
  // packages/studio/src/import.ts
451
467
  async function discoverSuiteConfigs(projectRoot, configured) {
452
468
  if (configured && configured.length > 0) {
453
- return configured.map((rel) => path8__default.default.resolve(projectRoot, rel));
469
+ return configured.map((rel) => path10__default.default.resolve(projectRoot, rel));
454
470
  }
455
471
  const found = [];
456
472
  try {
457
473
  const entries = await promises.readdir(projectRoot);
458
474
  for (const entry of entries) {
459
475
  if (entry.endsWith(".suite.json")) {
460
- found.push(path8__default.default.join(projectRoot, entry));
476
+ found.push(path10__default.default.join(projectRoot, entry));
461
477
  }
462
478
  }
463
479
  } catch {
@@ -467,7 +483,7 @@ async function discoverSuiteConfigs(projectRoot, configured) {
467
483
  async function loadProjectRuns(workspaceDir, traceDirs) {
468
484
  const runs = [];
469
485
  for (const rel of traceDirs) {
470
- const traceDir = advanced.resolveTraceDir({ dir: path8__default.default.join(workspaceDir, rel) });
486
+ const traceDir = advanced.resolveTraceDir({ dir: path10__default.default.join(workspaceDir, rel) });
471
487
  const td = new advanced.TraceDirectory({ dir: traceDir });
472
488
  const files = await td.list();
473
489
  const metas = await advanced.loadTraceMetadataList(
@@ -481,7 +497,7 @@ async function loadProjectRuns(workspaceDir, traceDirs) {
481
497
  runId: meta.runId,
482
498
  ...meta.name !== void 0 ? { name: meta.name } : {},
483
499
  status: meta.status,
484
- file: path8__default.default.basename(meta.filePath),
500
+ file: path10__default.default.basename(meta.filePath),
485
501
  ...meta.startedAt !== void 0 ? { startedAt: meta.startedAt } : {},
486
502
  ...meta.durationMs !== void 0 ? { durationMs: meta.durationMs } : {}
487
503
  });
@@ -490,7 +506,7 @@ async function loadProjectRuns(workspaceDir, traceDirs) {
490
506
  return runs.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
491
507
  }
492
508
  async function importStudioRegistry(options) {
493
- const registryDir = path8__default.default.dirname(options.registryPath);
509
+ const registryDir = path10__default.default.dirname(options.registryPath);
494
510
  const warnings = [];
495
511
  const projects = [];
496
512
  const importedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -551,21 +567,21 @@ async function importStudioProject(options) {
551
567
  }
552
568
  async function resolveStudioRegistryPath(options) {
553
569
  if (options.workspacePath && options.workspacePath.trim() !== "") {
554
- return path8__default.default.resolve(options.cwd ?? process.cwd(), options.workspacePath);
570
+ return path10__default.default.resolve(options.cwd ?? process.cwd(), options.workspacePath);
555
571
  }
556
- const cwd = path8__default.default.resolve(options.cwd ?? process.cwd());
572
+ const cwd = path10__default.default.resolve(options.cwd ?? process.cwd());
557
573
  for (const rel of STUDIO_REGISTRY_FILENAMES) {
558
- const candidate = path8__default.default.join(cwd, rel);
574
+ const candidate = path10__default.default.join(cwd, rel);
559
575
  try {
560
576
  await promises.access(candidate);
561
577
  return candidate;
562
578
  } catch {
563
579
  }
564
580
  }
565
- return path8__default.default.join(cwd, STUDIO_REGISTRY_FILENAMES[0]);
581
+ return path10__default.default.join(cwd, STUDIO_REGISTRY_FILENAMES[0]);
566
582
  }
567
583
  function resolveImportDirs(registryPath, registry) {
568
- const registryDir = path8__default.default.dirname(registryPath);
584
+ const registryDir = path10__default.default.dirname(registryPath);
569
585
  const importConfig = registry.import ?? {};
570
586
  const fileDropDir = importConfig.fileDropDir ?? "imports/drop";
571
587
  const ciArtifactsDir = importConfig.ciArtifactsDir ?? "imports/ci";
@@ -578,10 +594,10 @@ function resolveImportDirs(registryPath, registry) {
578
594
  };
579
595
  }
580
596
  function uniqueDestPath(destDir, fileName, contentHash) {
581
- const ext = path8__default.default.extname(fileName);
582
- const base = path8__default.default.basename(fileName, ext);
597
+ const ext = path10__default.default.extname(fileName);
598
+ const base = path10__default.default.basename(fileName, ext);
583
599
  const shortHash = contentHash.slice(0, 8);
584
- return path8__default.default.join(destDir, `${base}-${shortHash}${ext}`);
600
+ return path10__default.default.join(destDir, `${base}-${shortHash}${ext}`);
585
601
  }
586
602
  function sanitizeSafeErrorMessage(message, secret) {
587
603
  if (!secret || secret.length < 4) return message;
@@ -598,6 +614,136 @@ function parseGitHubRepo(repo) {
598
614
  function buildGitHubArtifactSourceKey(options) {
599
615
  return `github:${options.owner}/${options.repo}/runs/${options.runId}/${options.artifactName}`;
600
616
  }
617
+ var DEFAULT_MAX_INGEST_BYTES = 52428800;
618
+ var IngestLimitError = class extends Error {
619
+ code;
620
+ constructor(code, message) {
621
+ super(message);
622
+ this.name = "IngestLimitError";
623
+ this.code = code;
624
+ }
625
+ };
626
+ function resolveIngestMaxBytes(configured) {
627
+ if (configured === void 0) return DEFAULT_MAX_INGEST_BYTES;
628
+ if (!Number.isInteger(configured) || configured <= 0) {
629
+ throw new IngestLimitError(
630
+ "INGEST_SIZE_LIMIT",
631
+ "maxBytes must be a positive integer"
632
+ );
633
+ }
634
+ return configured;
635
+ }
636
+ async function lstatRegularFile(filePath) {
637
+ const info = await promises.lstat(filePath);
638
+ if (info.isSymbolicLink()) {
639
+ throw new IngestLimitError(
640
+ "INGEST_SYMLINK_REJECTED",
641
+ "symbolic links are not allowed for ingest"
642
+ );
643
+ }
644
+ if (!info.isFile()) {
645
+ throw new IngestLimitError("INGEST_NOT_A_FILE", "ingest path must be a regular file");
646
+ }
647
+ return info;
648
+ }
649
+ async function lstatRegularDirectory(dirPath) {
650
+ const info = await promises.lstat(dirPath);
651
+ if (info.isSymbolicLink()) {
652
+ throw new IngestLimitError(
653
+ "INGEST_SYMLINK_REJECTED",
654
+ "symbolic links are not allowed for ingest"
655
+ );
656
+ }
657
+ if (!info.isDirectory()) {
658
+ throw new IngestLimitError(
659
+ "INGEST_NOT_A_DIRECTORY",
660
+ "ingest path must be a directory"
661
+ );
662
+ }
663
+ return info;
664
+ }
665
+ async function assertFileWithinByteLimit(filePath, maxBytes) {
666
+ const info = await lstatRegularFile(filePath);
667
+ if (info.size > maxBytes) {
668
+ throw new IngestLimitError("INGEST_SIZE_LIMIT", "file exceeds size limit");
669
+ }
670
+ return info;
671
+ }
672
+ async function measureDirectoryBytes(dirPath, maxBytes) {
673
+ await lstatRegularDirectory(dirPath);
674
+ let total = 0;
675
+ const walk = async (current) => {
676
+ const entries = await promises.readdir(current, { withFileTypes: true });
677
+ for (const entry of entries) {
678
+ const abs = path10__default.default.join(current, entry.name);
679
+ const info = await promises.lstat(abs);
680
+ if (info.isSymbolicLink()) {
681
+ throw new IngestLimitError(
682
+ "INGEST_SYMLINK_REJECTED",
683
+ "symbolic links are not allowed for ingest"
684
+ );
685
+ }
686
+ if (info.isDirectory()) {
687
+ await walk(abs);
688
+ continue;
689
+ }
690
+ if (!info.isFile()) continue;
691
+ total += info.size;
692
+ if (total > maxBytes) {
693
+ throw new IngestLimitError("INGEST_SIZE_LIMIT", "directory exceeds size limit");
694
+ }
695
+ }
696
+ };
697
+ await walk(dirPath);
698
+ return total;
699
+ }
700
+ async function readBoundedResponseBody(response, maxBytes) {
701
+ const lengthHeader = response.headers.get("content-length");
702
+ if (lengthHeader) {
703
+ const length = Number(lengthHeader);
704
+ if (Number.isFinite(length) && length > maxBytes) {
705
+ throw new IngestLimitError("INGEST_SIZE_LIMIT", "response exceeds size limit");
706
+ }
707
+ }
708
+ if (!response.body) {
709
+ const arrayBuffer = await response.arrayBuffer();
710
+ if (arrayBuffer.byteLength > maxBytes) {
711
+ throw new IngestLimitError("INGEST_SIZE_LIMIT", "response exceeds size limit");
712
+ }
713
+ return Buffer.from(arrayBuffer);
714
+ }
715
+ const reader = response.body.getReader();
716
+ const chunks = [];
717
+ let total = 0;
718
+ for (; ; ) {
719
+ const { done, value } = await reader.read();
720
+ if (done) break;
721
+ if (!value || value.byteLength === 0) continue;
722
+ total += value.byteLength;
723
+ if (total > maxBytes) {
724
+ try {
725
+ await reader.cancel();
726
+ } catch {
727
+ }
728
+ throw new IngestLimitError("INGEST_SIZE_LIMIT", "response exceeds size limit");
729
+ }
730
+ chunks.push(Buffer.from(value));
731
+ }
732
+ return Buffer.concat(chunks);
733
+ }
734
+ async function withAtomicStagingDir(parentDir, work) {
735
+ await promises.mkdir(parentDir, { recursive: true });
736
+ const stagingDir = await promises.mkdtemp(path10__default.default.join(parentDir, ".ingest-staging-"));
737
+ try {
738
+ return await work(stagingDir);
739
+ } finally {
740
+ await promises.rm(stagingDir, { recursive: true, force: true });
741
+ }
742
+ }
743
+ async function promoteStagingPath(stagingPath, finalPath) {
744
+ await promises.mkdir(path10__default.default.dirname(finalPath), { recursive: true });
745
+ await promises.rename(stagingPath, finalPath);
746
+ }
601
747
 
602
748
  // packages/studio/src/ingest/file-drop.ts
603
749
  var FILE_DROP_ARCHIVE_DIR = ".imported";
@@ -610,26 +756,38 @@ function classifyFile(fileName) {
610
756
  return void 0;
611
757
  }
612
758
  async function hashFile(filePath) {
613
- const data = await promises.readFile(filePath);
614
- return crypto.createHash("sha256").update(data).digest("hex");
759
+ const hash = crypto.createHash("sha256");
760
+ const stream = fs.createReadStream(filePath);
761
+ for await (const chunk of stream) {
762
+ hash.update(chunk);
763
+ }
764
+ return hash.digest("hex");
615
765
  }
616
766
  async function importOneFile(options) {
617
767
  const destPath = uniqueDestPath(options.destDir, options.fileName, options.contentHash);
618
- assertPathUnderRoot(destPath, path8__default.default.dirname(options.destDir));
619
- await promises.mkdir(options.destDir, { recursive: true });
620
- await promises.copyFile(options.sourcePath, destPath);
621
- insertIngestFile(options.db, {
622
- sourceKey: options.sourceKey,
623
- sourceName: options.fileName,
624
- destPath,
625
- kind: options.kind,
626
- contentHash: options.contentHash,
627
- importedAt: options.importedAt
628
- });
768
+ assertPathUnderRoot(destPath, path10__default.default.dirname(options.destDir));
769
+ try {
770
+ await withAtomicStagingDir(options.destDir, async (stagingDir) => {
771
+ const stagedPath = path10__default.default.join(stagingDir, options.fileName);
772
+ await promises.copyFile(options.sourcePath, stagedPath);
773
+ await promoteStagingPath(stagedPath, destPath);
774
+ });
775
+ insertIngestFile(options.db, {
776
+ sourceKey: options.sourceKey,
777
+ sourceName: options.fileName,
778
+ destPath,
779
+ kind: options.kind,
780
+ contentHash: options.contentHash,
781
+ importedAt: options.importedAt
782
+ });
783
+ } catch (error) {
784
+ await promises.rm(destPath, { force: true }).catch(() => void 0);
785
+ throw error;
786
+ }
629
787
  let archived = false;
630
788
  if (options.archiveAfterImport) {
631
789
  await promises.mkdir(options.archiveDir, { recursive: true });
632
- const archiveTarget = path8__default.default.join(options.archiveDir, options.fileName);
790
+ const archiveTarget = path10__default.default.join(options.archiveDir, options.fileName);
633
791
  await promises.rename(options.sourcePath, archiveTarget);
634
792
  archived = true;
635
793
  }
@@ -658,10 +816,27 @@ async function importFileDrop(options) {
658
816
  files
659
817
  };
660
818
  }
819
+ let maxBytes;
820
+ try {
821
+ maxBytes = resolveIngestMaxBytes(
822
+ options.maxBytes ?? options.registry.ingest?.fileDrop?.maxBytes
823
+ );
824
+ } catch (error) {
825
+ const message = error instanceof Error ? error.message : String(error);
826
+ return {
827
+ skipped: false,
828
+ scanned: 0,
829
+ imported: 0,
830
+ skippedFiles: 0,
831
+ errors: [message],
832
+ warnings,
833
+ files
834
+ };
835
+ }
661
836
  const dirs = resolveImportDirs(options.registryPath, options.registry);
662
837
  let dropDir;
663
838
  try {
664
- dropDir = options.dropDir ? path8__default.default.isAbsolute(options.dropDir) ? (assertPathUnderRoot(options.dropDir, dirs.registryDir), options.dropDir) : resolveUnderRoot(dirs.registryDir, options.dropDir) : dirs.fileDropDir;
839
+ dropDir = options.dropDir ? path10__default.default.isAbsolute(options.dropDir) ? (assertPathUnderRoot(options.dropDir, dirs.registryDir), options.dropDir) : resolveUnderRoot(dirs.registryDir, options.dropDir) : dirs.fileDropDir;
665
840
  assertPathUnderRoot(dropDir, dirs.registryDir);
666
841
  } catch (error) {
667
842
  const message = error instanceof Error ? error.message : String(error);
@@ -691,24 +866,35 @@ async function importFileDrop(options) {
691
866
  };
692
867
  }
693
868
  const importedAt = (/* @__PURE__ */ new Date()).toISOString();
694
- const archiveDir = path8__default.default.join(dropDir, FILE_DROP_ARCHIVE_DIR);
869
+ const archiveDir = path10__default.default.join(dropDir, FILE_DROP_ARCHIVE_DIR);
695
870
  let scanned = 0;
696
871
  let imported = 0;
697
872
  let skippedFiles = 0;
698
873
  for (const entry of entries.sort()) {
699
874
  if (entry === FILE_DROP_ARCHIVE_DIR || entry.startsWith(".")) continue;
700
- const sourcePath = path8__default.default.join(dropDir, entry);
701
- let fileStat;
875
+ const sourcePath = path10__default.default.join(dropDir, entry);
876
+ const kind = classifyFile(entry);
877
+ if (!kind) continue;
878
+ scanned += 1;
702
879
  try {
703
- fileStat = await promises.stat(sourcePath);
704
- } catch {
880
+ await assertFileWithinByteLimit(sourcePath, maxBytes);
881
+ } catch (error) {
882
+ if (error instanceof IngestLimitError && error.code === "INGEST_NOT_A_FILE") {
883
+ scanned -= 1;
884
+ continue;
885
+ }
886
+ if (error instanceof IngestLimitError && error.code === "INGEST_SYMLINK_REJECTED") {
887
+ errors.push(`failed to import ${entry}: ${error.message}`);
888
+ continue;
889
+ }
890
+ if (error instanceof IngestLimitError && error.code === "INGEST_SIZE_LIMIT") {
891
+ errors.push(`failed to import ${entry}: ${error.message}`);
892
+ continue;
893
+ }
705
894
  warnings.push(`skipped unreadable entry: ${entry}`);
895
+ scanned -= 1;
706
896
  continue;
707
897
  }
708
- if (!fileStat.isFile()) continue;
709
- const kind = classifyFile(entry);
710
- if (!kind) continue;
711
- scanned += 1;
712
898
  const sourceKey = entry;
713
899
  let contentHash;
714
900
  try {
@@ -761,7 +947,8 @@ async function importFileDropFromRegistry(options) {
761
947
  registry: options.registry,
762
948
  enabled: options.enabled,
763
949
  ...options.dropDir !== void 0 ? { dropDir: options.dropDir } : {},
764
- ...options.archiveAfterImport !== void 0 ? { archiveAfterImport: options.archiveAfterImport } : {}
950
+ ...options.archiveAfterImport !== void 0 ? { archiveAfterImport: options.archiveAfterImport } : {},
951
+ ...options.maxBytes !== void 0 ? { maxBytes: options.maxBytes } : {}
765
952
  });
766
953
  }
767
954
  async function runStudioFileDropImport(options) {
@@ -878,7 +1065,6 @@ function isIngestTokenValid(provided, expected) {
878
1065
  var DEFAULT_HTTP_INGEST_BASE_PATH = "/api/ingest";
879
1066
  var HTTP_INGEST_BUNDLE_PATH = "/api/ingest/bundle";
880
1067
  var HTTP_INGEST_ARTIFACT_PATH = "/api/ingest/artifact";
881
- var DEFAULT_MAX_INGEST_BYTES = 52428800;
882
1068
  function resolveHttpIngestConfig(options, registryHttp) {
883
1069
  const http = registryHttp ?? options.context?.registry.ingest?.http;
884
1070
  const enabled = options.ingestHttp === true || http?.enabled === true;
@@ -887,7 +1073,7 @@ function resolveHttpIngestConfig(options, registryHttp) {
887
1073
  ...options.ingestTokenEnv !== void 0 ? { tokenEnv: options.ingestTokenEnv } : {},
888
1074
  ...http?.tokenEnv !== void 0 ? { registryTokenEnv: http.tokenEnv } : {}
889
1075
  });
890
- const maxBytes = http?.maxBytes ?? DEFAULT_MAX_INGEST_BYTES;
1076
+ const maxBytes = resolveIngestMaxBytes(http?.maxBytes);
891
1077
  return { enabled, basePath, tokenEnv, maxBytes };
892
1078
  }
893
1079
  function sendJson(res, status, body) {
@@ -957,22 +1143,30 @@ async function handleHttpIngestRequest(req, res, ctx, options, pathname) {
957
1143
  const destDir = isBundle ? dirs.bundlesDir : dirs.ciArtifactsDir;
958
1144
  const destPath = uniqueDestPath(destDir, fileName, contentHash);
959
1145
  assertPathUnderRoot(destPath, dirs.registryDir);
960
- await promises.mkdir(destDir, { recursive: true });
961
- await promises.writeFile(destPath, body);
962
- const sourceKey = isBundle ? `http:bundle:${contentHash}` : buildGitHubArtifactSourceKey({
963
- owner: "http",
964
- repo: "ingest",
965
- runId: importedAt,
966
- artifactName: fileName
967
- });
968
- insertIngestFile(ctx.db, {
969
- sourceKey,
970
- sourceName: fileName,
971
- destPath,
972
- kind: isBundle ? "bundle" : "ci",
973
- contentHash,
974
- importedAt
975
- });
1146
+ try {
1147
+ await withAtomicStagingDir(destDir, async (stagingDir) => {
1148
+ const stagedPath = path10__default.default.join(stagingDir, fileName);
1149
+ await promises.writeFile(stagedPath, body);
1150
+ await promoteStagingPath(stagedPath, destPath);
1151
+ });
1152
+ const sourceKey = isBundle ? `http:bundle:${contentHash}` : buildGitHubArtifactSourceKey({
1153
+ owner: "http",
1154
+ repo: "ingest",
1155
+ runId: importedAt,
1156
+ artifactName: fileName
1157
+ });
1158
+ insertIngestFile(ctx.db, {
1159
+ sourceKey,
1160
+ sourceName: fileName,
1161
+ destPath,
1162
+ kind: isBundle ? "bundle" : "ci",
1163
+ contentHash,
1164
+ importedAt
1165
+ });
1166
+ } catch (error) {
1167
+ await promises.rm(destPath, { force: true }).catch(() => void 0);
1168
+ throw error;
1169
+ }
976
1170
  const registryImport = await importStudioRegistry({
977
1171
  db: ctx.db,
978
1172
  registry: ctx.registry,
@@ -1157,7 +1351,7 @@ function getImportedProject(db, projects, projectId) {
1157
1351
  async function loadTraceDirMetas(workspaceDir, traceDirs) {
1158
1352
  const metas = [];
1159
1353
  for (const rel of traceDirs) {
1160
- const traceDir = advanced.resolveTraceDir({ dir: path8__default.default.join(workspaceDir, rel) });
1354
+ const traceDir = advanced.resolveTraceDir({ dir: path10__default.default.join(workspaceDir, rel) });
1161
1355
  const td = new advanced.TraceDirectory({ dir: traceDir });
1162
1356
  const files = await td.list();
1163
1357
  const listed = await advanced.loadTraceMetadataList(
@@ -1186,7 +1380,7 @@ async function loadProjectSuitesView(ctx) {
1186
1380
  try {
1187
1381
  const result = await advanced.runSuite({
1188
1382
  configPath,
1189
- cwd: path8__default.default.dirname(configPath)
1383
+ cwd: path10__default.default.dirname(configPath)
1190
1384
  });
1191
1385
  suites.push({
1192
1386
  suiteName: result.suiteName,
@@ -1204,7 +1398,7 @@ async function loadProjectSuitesView(ctx) {
1204
1398
  } catch (error) {
1205
1399
  const message = error instanceof Error ? error.message : String(error);
1206
1400
  suites.push({
1207
- suiteName: path8__default.default.basename(configPath),
1401
+ suiteName: path10__default.default.basename(configPath),
1208
1402
  configPath,
1209
1403
  ok: false,
1210
1404
  status: "error",
@@ -1253,7 +1447,7 @@ async function loadProjectSearchView(ctx, db, params) {
1253
1447
  }
1254
1448
  const metas = await loadTraceDirMetas(ctx.project.workspaceDir, ["runs"]);
1255
1449
  const traceDir = advanced.resolveTraceDir({
1256
- dir: path8__default.default.join(ctx.project.workspaceDir, "runs")
1450
+ dir: path10__default.default.join(ctx.project.workspaceDir, "runs")
1257
1451
  });
1258
1452
  const results = await advanced.searchTraces(metas, {
1259
1453
  traceDir,
@@ -1297,12 +1491,12 @@ async function loadProjectDiffView(ctx, params) {
1297
1491
  };
1298
1492
  }
1299
1493
  async function loadProjectReportsView(ctx) {
1300
- const reportsDir = path8__default.default.join(ctx.project.workspaceDir, "reports");
1494
+ const reportsDir = path10__default.default.join(ctx.project.workspaceDir, "reports");
1301
1495
  const reports = [];
1302
1496
  try {
1303
1497
  const files = await promises.readdir(reportsDir);
1304
1498
  for (const file of files) {
1305
- const filePath = path8__default.default.join(reportsDir, file);
1499
+ const filePath = path10__default.default.join(reportsDir, file);
1306
1500
  const info = await promises.stat(filePath);
1307
1501
  if (!info.isFile()) continue;
1308
1502
  reports.push({ name: file, path: filePath, sizeBytes: info.size });
@@ -1357,7 +1551,7 @@ async function loadBundleExportView(ctx, params) {
1357
1551
  runId,
1358
1552
  readOnly: true,
1359
1553
  redactionProfile: ctx.project.redactionProfile ?? "share",
1360
- cliHint: `npx agent-inspect bundle ${runId} --profile ${ctx.project.redactionProfile ?? "share"} --dir ${path8__default.default.join(ctx.project.workspaceDir, "runs")}`,
1554
+ cliHint: `npx agent-inspect bundle ${runId} --profile ${ctx.project.redactionProfile ?? "share"} --dir ${path10__default.default.join(ctx.project.workspaceDir, "runs")}`,
1361
1555
  note: "Studio does not mutate traces or upload bundles. Run the CLI locally to assemble a share-safe bundle."
1362
1556
  };
1363
1557
  }
@@ -1699,7 +1893,6 @@ async function startStudioServer(options = {}) {
1699
1893
  }
1700
1894
  var DEFAULT_GITHUB_TOKEN_ENV = "GITHUB_TOKEN";
1701
1895
  var GITHUB_API_BASE = "https://api.github.com";
1702
- var MAX_ARTIFACT_BYTES = 52428800;
1703
1896
  function resolveTokenEnv(registry, override) {
1704
1897
  const fromRegistry = registry.ingest?.github?.tokenEnv?.trim();
1705
1898
  const envName = override?.trim() || fromRegistry || DEFAULT_GITHUB_TOKEN_ENV;
@@ -1724,20 +1917,17 @@ function githubHeaders(token) {
1724
1917
  };
1725
1918
  }
1726
1919
  async function readResponseBody(response, maxBytes) {
1727
- const lengthHeader = response.headers.get("content-length");
1728
- if (lengthHeader) {
1729
- const length = Number(lengthHeader);
1730
- if (Number.isFinite(length) && length > maxBytes) {
1920
+ try {
1921
+ return await readBoundedResponseBody(response, maxBytes);
1922
+ } catch (error) {
1923
+ if (error instanceof IngestLimitError && error.code === "INGEST_SIZE_LIMIT") {
1731
1924
  throw new Error("artifact exceeds size limit");
1732
1925
  }
1926
+ throw error;
1733
1927
  }
1734
- const arrayBuffer = await response.arrayBuffer();
1735
- if (arrayBuffer.byteLength > maxBytes) {
1736
- throw new Error("artifact exceeds size limit");
1737
- }
1738
- return Buffer.from(arrayBuffer);
1739
1928
  }
1740
1929
  async function downloadGitHubArtifactArchive(options) {
1930
+ const maxBytes = resolveIngestMaxBytes(options.maxBytes);
1741
1931
  const fetchFn = options.fetchImpl ?? fetch;
1742
1932
  const { owner, name } = parseGitHubRepo(options.repo);
1743
1933
  const runId = options.runId.trim();
@@ -1763,7 +1953,7 @@ async function downloadGitHubArtifactArchive(options) {
1763
1953
  if (artifact.expired === true) {
1764
1954
  throw new Error(`artifact expired for run ${runId}: ${artifactName}`);
1765
1955
  }
1766
- if (typeof artifact.size_in_bytes === "number" && artifact.size_in_bytes > MAX_ARTIFACT_BYTES) {
1956
+ if (typeof artifact.size_in_bytes === "number" && artifact.size_in_bytes > maxBytes) {
1767
1957
  throw new Error("artifact exceeds size limit");
1768
1958
  }
1769
1959
  const downloadResponse = await fetchFn(artifact.archive_download_url, {
@@ -1773,7 +1963,7 @@ async function downloadGitHubArtifactArchive(options) {
1773
1963
  if (!downloadResponse.ok) {
1774
1964
  throw new Error(`GitHub artifact download failed (${downloadResponse.status})`);
1775
1965
  }
1776
- return readResponseBody(downloadResponse, MAX_ARTIFACT_BYTES);
1966
+ return readResponseBody(downloadResponse, maxBytes);
1777
1967
  }
1778
1968
  async function importGitHubArtifact(options) {
1779
1969
  const registryImportWarnings = [];
@@ -1823,17 +2013,25 @@ async function importGitHubArtifact(options) {
1823
2013
  const fileName = `${options.artifactName}.zip`;
1824
2014
  const destPath = uniqueDestPath(dirs.bundlesDir, fileName, contentHash);
1825
2015
  assertPathUnderRoot(destPath, dirs.registryDir);
1826
- await promises.mkdir(dirs.bundlesDir, { recursive: true });
1827
- await promises.writeFile(destPath, archive);
1828
- const importedAt = (/* @__PURE__ */ new Date()).toISOString();
1829
- insertIngestFile(options.db, {
1830
- sourceKey,
1831
- sourceName: fileName,
1832
- destPath,
1833
- kind: "bundle",
1834
- contentHash,
1835
- importedAt
1836
- });
2016
+ try {
2017
+ await withAtomicStagingDir(dirs.bundlesDir, async (stagingDir) => {
2018
+ const stagedPath = path10__default.default.join(stagingDir, fileName);
2019
+ await promises.writeFile(stagedPath, archive);
2020
+ await promoteStagingPath(stagedPath, destPath);
2021
+ });
2022
+ const importedAt = (/* @__PURE__ */ new Date()).toISOString();
2023
+ insertIngestFile(options.db, {
2024
+ sourceKey,
2025
+ sourceName: fileName,
2026
+ destPath,
2027
+ kind: "bundle",
2028
+ contentHash,
2029
+ importedAt
2030
+ });
2031
+ } catch (error) {
2032
+ await promises.rm(destPath, { force: true }).catch(() => void 0);
2033
+ throw error;
2034
+ }
1837
2035
  const registryImport = await importStudioRegistry({
1838
2036
  db: options.db,
1839
2037
  registry: options.registry,
@@ -1897,7 +2095,7 @@ async function pathExists(filePath) {
1897
2095
  }
1898
2096
  async function validateBundleDirectory(bundleDir) {
1899
2097
  const errors = [];
1900
- const metadataPath = path8__default.default.join(bundleDir, "metadata.json");
2098
+ const metadataPath = path10__default.default.join(bundleDir, "metadata.json");
1901
2099
  if (!await pathExists(metadataPath)) {
1902
2100
  errors.push("bundle missing metadata.json");
1903
2101
  return errors;
@@ -1923,11 +2121,18 @@ async function hashBundleContents(bundleDir) {
1923
2121
  (a, b) => a.name.localeCompare(b.name)
1924
2122
  );
1925
2123
  for (const entry of entries) {
1926
- const abs = path8__default.default.join(dir, entry.name);
2124
+ const abs = path10__default.default.join(dir, entry.name);
1927
2125
  const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
1928
- if (entry.isDirectory()) {
2126
+ const info = await promises.lstat(abs);
2127
+ if (info.isSymbolicLink()) {
2128
+ throw new IngestLimitError(
2129
+ "INGEST_SYMLINK_REJECTED",
2130
+ "symbolic links are not allowed for ingest"
2131
+ );
2132
+ }
2133
+ if (info.isDirectory()) {
1929
2134
  await walk(abs, rel);
1930
- } else if (entry.isFile()) {
2135
+ } else if (info.isFile()) {
1931
2136
  hash.update(rel);
1932
2137
  hash.update("\0");
1933
2138
  hash.update(await promises.readFile(abs));
@@ -1942,11 +2147,18 @@ async function copyDirectoryRecursive(sourceDir, destDir) {
1942
2147
  await promises.mkdir(destDir, { recursive: true });
1943
2148
  const entries = await promises.readdir(sourceDir, { withFileTypes: true });
1944
2149
  for (const entry of entries) {
1945
- const from = path8__default.default.join(sourceDir, entry.name);
1946
- const to = path8__default.default.join(destDir, entry.name);
1947
- if (entry.isDirectory()) {
2150
+ const from = path10__default.default.join(sourceDir, entry.name);
2151
+ const to = path10__default.default.join(destDir, entry.name);
2152
+ const info = await promises.lstat(from);
2153
+ if (info.isSymbolicLink()) {
2154
+ throw new IngestLimitError(
2155
+ "INGEST_SYMLINK_REJECTED",
2156
+ "symbolic links are not allowed for ingest"
2157
+ );
2158
+ }
2159
+ if (info.isDirectory()) {
1948
2160
  await copyDirectoryRecursive(from, to);
1949
- } else if (entry.isFile()) {
2161
+ } else if (info.isFile()) {
1950
2162
  await promises.copyFile(from, to);
1951
2163
  }
1952
2164
  }
@@ -1963,11 +2175,18 @@ async function importBundleUpload(options) {
1963
2175
  registryImportWarnings
1964
2176
  };
1965
2177
  }
1966
- const bundlePath = path8__default.default.resolve(options.bundlePath);
1967
- let bundleStat;
2178
+ const bundlePath = path10__default.default.resolve(options.bundlePath);
1968
2179
  try {
1969
- bundleStat = await promises.stat(bundlePath);
1970
- } catch {
2180
+ await lstatRegularDirectory(bundlePath);
2181
+ } catch (error) {
2182
+ if (error instanceof IngestLimitError) {
2183
+ return {
2184
+ skipped: false,
2185
+ imported: false,
2186
+ errors: [error.message],
2187
+ registryImportWarnings
2188
+ };
2189
+ }
1971
2190
  return {
1972
2191
  skipped: false,
1973
2192
  imported: false,
@@ -1975,24 +2194,42 @@ async function importBundleUpload(options) {
1975
2194
  registryImportWarnings
1976
2195
  };
1977
2196
  }
1978
- if (!bundleStat.isDirectory()) {
2197
+ const validationErrors = await validateBundleDirectory(bundlePath);
2198
+ if (validationErrors.length > 0) {
1979
2199
  return {
1980
2200
  skipped: false,
1981
2201
  imported: false,
1982
- errors: ["bundle path must be a directory produced by agent-inspect bundle"],
2202
+ errors: validationErrors,
1983
2203
  registryImportWarnings
1984
2204
  };
1985
2205
  }
1986
- const validationErrors = await validateBundleDirectory(bundlePath);
1987
- if (validationErrors.length > 0) {
2206
+ let maxBytes;
2207
+ try {
2208
+ maxBytes = resolveIngestMaxBytes(
2209
+ options.maxBytes ?? options.registry.ingest?.bundleUpload?.maxBytes
2210
+ );
2211
+ await measureDirectoryBytes(bundlePath, maxBytes);
2212
+ } catch (error) {
2213
+ const message = error instanceof Error ? error.message : String(error);
1988
2214
  return {
1989
2215
  skipped: false,
1990
2216
  imported: false,
1991
- errors: validationErrors,
2217
+ errors: [message],
2218
+ registryImportWarnings
2219
+ };
2220
+ }
2221
+ let contentHash;
2222
+ try {
2223
+ contentHash = await hashBundleContents(bundlePath);
2224
+ } catch (error) {
2225
+ const message = error instanceof Error ? error.message : String(error);
2226
+ return {
2227
+ skipped: false,
2228
+ imported: false,
2229
+ errors: [message],
1992
2230
  registryImportWarnings
1993
2231
  };
1994
2232
  }
1995
- const contentHash = await hashBundleContents(bundlePath);
1996
2233
  const sourceKey = `bundle:${bundlePath}`;
1997
2234
  const existing = findIngestFileBySourceKey(options.db, sourceKey);
1998
2235
  if (existing && existing.contentHash === contentHash) {
@@ -2006,11 +2243,15 @@ async function importBundleUpload(options) {
2006
2243
  };
2007
2244
  }
2008
2245
  const dirs = resolveImportDirs(options.registryPath, options.registry);
2009
- const folderName = path8__default.default.basename(bundlePath);
2246
+ const folderName = path10__default.default.basename(bundlePath);
2010
2247
  const destPath = uniqueDestPath(dirs.bundlesDir, folderName, contentHash);
2011
2248
  assertPathUnderRoot(destPath, dirs.registryDir);
2012
2249
  try {
2013
- await copyDirectoryRecursive(bundlePath, destPath);
2250
+ await withAtomicStagingDir(dirs.bundlesDir, async (stagingDir) => {
2251
+ const stagedDest = path10__default.default.join(stagingDir, folderName);
2252
+ await copyDirectoryRecursive(bundlePath, stagedDest);
2253
+ await promoteStagingPath(stagedDest, destPath);
2254
+ });
2014
2255
  insertIngestFile(options.db, {
2015
2256
  sourceKey,
2016
2257
  sourceName: folderName,
@@ -2034,6 +2275,7 @@ async function importBundleUpload(options) {
2034
2275
  registryImportWarnings
2035
2276
  };
2036
2277
  } catch (error) {
2278
+ await promises.rm(destPath, { recursive: true, force: true }).catch(() => void 0);
2037
2279
  const message = error instanceof Error ? error.message : String(error);
2038
2280
  return {
2039
2281
  skipped: false,