@agent-inspect/studio 6.17.4 → 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.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { readFile, access, readdir, stat, mkdir, writeFile, copyFile, rename } from 'fs/promises';
2
- import path8 from 'path';
1
+ import { readFile, access, readdir, writeFile, rm, copyFile, mkdir, rename, mkdtemp, lstat, stat } from 'fs/promises';
2
+ import path10 from 'path';
3
3
  import { loadSessionRunRecords, buildSessionIndex, runSuite, buildRunTimeline, extractOutcomesFromTraceEvents, resolveTraceDir, searchTraces, TraceDirectory, loadTraceMetadataList } from 'agent-inspect/advanced';
4
4
  import { resolveWorkspaceLocation, readWorkspaceManifestFile } from 'agent-inspect/workspace';
5
- import { mkdirSync } from 'fs';
5
+ import { mkdirSync, createReadStream } from 'fs';
6
6
  import { createRequire } from 'module';
7
7
  import { timingSafeEqual, createHash } from 'crypto';
8
8
  import { createServer } from 'http';
@@ -19,14 +19,14 @@ function isSafeRelativePath(p) {
19
19
  return !trimmed.split(/[/\\]+/).some((seg) => seg === "..");
20
20
  }
21
21
  function resolveUnderRoot(root, ...segments) {
22
- const resolvedRoot = path8.resolve(root);
23
- const resolved = path8.resolve(resolvedRoot, ...segments);
22
+ const resolvedRoot = path10.resolve(root);
23
+ const resolved = path10.resolve(resolvedRoot, ...segments);
24
24
  assertPathUnderRoot(resolved, resolvedRoot);
25
25
  return resolved;
26
26
  }
27
27
  function assertPathUnderRoot(resolved, root) {
28
- const rel = path8.relative(path8.resolve(root), path8.resolve(resolved));
29
- if (rel.startsWith("..") || path8.isAbsolute(rel)) {
28
+ const rel = path10.relative(path10.resolve(root), path10.resolve(resolved));
29
+ if (rel.startsWith("..") || path10.isAbsolute(rel)) {
30
30
  throw new Error("path escapes allowed registry root");
31
31
  }
32
32
  }
@@ -207,7 +207,23 @@ function parseStudioRegistry(input) {
207
207
  ingestConfig.bundleUpload = bundleUpload;
208
208
  }
209
209
  }
210
- const knownIngestKeys = /* @__PURE__ */ new Set(["github", "http", "bundleUpload"]);
210
+ if (input.ingest.fileDrop !== void 0) {
211
+ if (!isPlainObject(input.ingest.fileDrop)) {
212
+ errors.push("ingest.fileDrop must be an object");
213
+ } else {
214
+ const fileDrop = {};
215
+ if (input.ingest.fileDrop.maxBytes !== void 0) {
216
+ const maxBytes = Number(input.ingest.fileDrop.maxBytes);
217
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
218
+ errors.push("ingest.fileDrop.maxBytes must be a positive integer");
219
+ } else {
220
+ fileDrop.maxBytes = maxBytes;
221
+ }
222
+ }
223
+ ingestConfig.fileDrop = fileDrop;
224
+ }
225
+ }
226
+ const knownIngestKeys = /* @__PURE__ */ new Set(["github", "http", "bundleUpload", "fileDrop"]);
211
227
  for (const key of Object.keys(input.ingest)) {
212
228
  if (!knownIngestKeys.has(key)) {
213
229
  ingestWarnings.push(`ignored unknown ingest key: ${key}`);
@@ -248,7 +264,7 @@ async function readStudioRegistryFile(filePath) {
248
264
  }
249
265
  }
250
266
  function resolveRegistryProjectPath(registryDir, projectPath) {
251
- return path8.isAbsolute(projectPath) ? path8.resolve(projectPath) : path8.resolve(registryDir, projectPath);
267
+ return path10.isAbsolute(projectPath) ? path10.resolve(projectPath) : path10.resolve(registryDir, projectPath);
252
268
  }
253
269
  var cached;
254
270
  function loadBetterSqlite3() {
@@ -313,9 +329,9 @@ function resolveStudioDbPath(options) {
313
329
  if (raw.startsWith("postgres://") || raw.startsWith("postgresql://")) {
314
330
  return raw;
315
331
  }
316
- return path8.resolve(options.cwd ?? process.cwd(), raw);
332
+ return path10.resolve(options.cwd ?? process.cwd(), raw);
317
333
  }
318
- return path8.resolve(
334
+ return path10.resolve(
319
335
  options.cwd ?? process.cwd(),
320
336
  ".agent-inspect",
321
337
  DEFAULT_STUDIO_DB_FILENAME
@@ -330,7 +346,7 @@ function openStudioDb(dbPath) {
330
346
  "Postgres studio databases are not supported; use a SQLite file path (preview only)."
331
347
  );
332
348
  }
333
- const dir = path8.dirname(dbPath);
349
+ const dir = path10.dirname(dbPath);
334
350
  try {
335
351
  mkdirSync(dir, { recursive: true });
336
352
  } catch (error) {
@@ -443,14 +459,14 @@ function insertIngestFile(db, row) {
443
459
  // packages/studio/src/import.ts
444
460
  async function discoverSuiteConfigs(projectRoot, configured) {
445
461
  if (configured && configured.length > 0) {
446
- return configured.map((rel) => path8.resolve(projectRoot, rel));
462
+ return configured.map((rel) => path10.resolve(projectRoot, rel));
447
463
  }
448
464
  const found = [];
449
465
  try {
450
466
  const entries = await readdir(projectRoot);
451
467
  for (const entry of entries) {
452
468
  if (entry.endsWith(".suite.json")) {
453
- found.push(path8.join(projectRoot, entry));
469
+ found.push(path10.join(projectRoot, entry));
454
470
  }
455
471
  }
456
472
  } catch {
@@ -460,7 +476,7 @@ async function discoverSuiteConfigs(projectRoot, configured) {
460
476
  async function loadProjectRuns(workspaceDir, traceDirs) {
461
477
  const runs = [];
462
478
  for (const rel of traceDirs) {
463
- const traceDir = resolveTraceDir({ dir: path8.join(workspaceDir, rel) });
479
+ const traceDir = resolveTraceDir({ dir: path10.join(workspaceDir, rel) });
464
480
  const td = new TraceDirectory({ dir: traceDir });
465
481
  const files = await td.list();
466
482
  const metas = await loadTraceMetadataList(
@@ -474,7 +490,7 @@ async function loadProjectRuns(workspaceDir, traceDirs) {
474
490
  runId: meta.runId,
475
491
  ...meta.name !== void 0 ? { name: meta.name } : {},
476
492
  status: meta.status,
477
- file: path8.basename(meta.filePath),
493
+ file: path10.basename(meta.filePath),
478
494
  ...meta.startedAt !== void 0 ? { startedAt: meta.startedAt } : {},
479
495
  ...meta.durationMs !== void 0 ? { durationMs: meta.durationMs } : {}
480
496
  });
@@ -483,7 +499,7 @@ async function loadProjectRuns(workspaceDir, traceDirs) {
483
499
  return runs.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
484
500
  }
485
501
  async function importStudioRegistry(options) {
486
- const registryDir = path8.dirname(options.registryPath);
502
+ const registryDir = path10.dirname(options.registryPath);
487
503
  const warnings = [];
488
504
  const projects = [];
489
505
  const importedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -544,21 +560,21 @@ async function importStudioProject(options) {
544
560
  }
545
561
  async function resolveStudioRegistryPath(options) {
546
562
  if (options.workspacePath && options.workspacePath.trim() !== "") {
547
- return path8.resolve(options.cwd ?? process.cwd(), options.workspacePath);
563
+ return path10.resolve(options.cwd ?? process.cwd(), options.workspacePath);
548
564
  }
549
- const cwd = path8.resolve(options.cwd ?? process.cwd());
565
+ const cwd = path10.resolve(options.cwd ?? process.cwd());
550
566
  for (const rel of STUDIO_REGISTRY_FILENAMES) {
551
- const candidate = path8.join(cwd, rel);
567
+ const candidate = path10.join(cwd, rel);
552
568
  try {
553
569
  await access(candidate);
554
570
  return candidate;
555
571
  } catch {
556
572
  }
557
573
  }
558
- return path8.join(cwd, STUDIO_REGISTRY_FILENAMES[0]);
574
+ return path10.join(cwd, STUDIO_REGISTRY_FILENAMES[0]);
559
575
  }
560
576
  function resolveImportDirs(registryPath, registry) {
561
- const registryDir = path8.dirname(registryPath);
577
+ const registryDir = path10.dirname(registryPath);
562
578
  const importConfig = registry.import ?? {};
563
579
  const fileDropDir = importConfig.fileDropDir ?? "imports/drop";
564
580
  const ciArtifactsDir = importConfig.ciArtifactsDir ?? "imports/ci";
@@ -571,10 +587,10 @@ function resolveImportDirs(registryPath, registry) {
571
587
  };
572
588
  }
573
589
  function uniqueDestPath(destDir, fileName, contentHash) {
574
- const ext = path8.extname(fileName);
575
- const base = path8.basename(fileName, ext);
590
+ const ext = path10.extname(fileName);
591
+ const base = path10.basename(fileName, ext);
576
592
  const shortHash = contentHash.slice(0, 8);
577
- return path8.join(destDir, `${base}-${shortHash}${ext}`);
593
+ return path10.join(destDir, `${base}-${shortHash}${ext}`);
578
594
  }
579
595
  function sanitizeSafeErrorMessage(message, secret) {
580
596
  if (!secret || secret.length < 4) return message;
@@ -591,6 +607,136 @@ function parseGitHubRepo(repo) {
591
607
  function buildGitHubArtifactSourceKey(options) {
592
608
  return `github:${options.owner}/${options.repo}/runs/${options.runId}/${options.artifactName}`;
593
609
  }
610
+ var DEFAULT_MAX_INGEST_BYTES = 52428800;
611
+ var IngestLimitError = class extends Error {
612
+ code;
613
+ constructor(code, message) {
614
+ super(message);
615
+ this.name = "IngestLimitError";
616
+ this.code = code;
617
+ }
618
+ };
619
+ function resolveIngestMaxBytes(configured) {
620
+ if (configured === void 0) return DEFAULT_MAX_INGEST_BYTES;
621
+ if (!Number.isInteger(configured) || configured <= 0) {
622
+ throw new IngestLimitError(
623
+ "INGEST_SIZE_LIMIT",
624
+ "maxBytes must be a positive integer"
625
+ );
626
+ }
627
+ return configured;
628
+ }
629
+ async function lstatRegularFile(filePath) {
630
+ const info = await lstat(filePath);
631
+ if (info.isSymbolicLink()) {
632
+ throw new IngestLimitError(
633
+ "INGEST_SYMLINK_REJECTED",
634
+ "symbolic links are not allowed for ingest"
635
+ );
636
+ }
637
+ if (!info.isFile()) {
638
+ throw new IngestLimitError("INGEST_NOT_A_FILE", "ingest path must be a regular file");
639
+ }
640
+ return info;
641
+ }
642
+ async function lstatRegularDirectory(dirPath) {
643
+ const info = await lstat(dirPath);
644
+ if (info.isSymbolicLink()) {
645
+ throw new IngestLimitError(
646
+ "INGEST_SYMLINK_REJECTED",
647
+ "symbolic links are not allowed for ingest"
648
+ );
649
+ }
650
+ if (!info.isDirectory()) {
651
+ throw new IngestLimitError(
652
+ "INGEST_NOT_A_DIRECTORY",
653
+ "ingest path must be a directory"
654
+ );
655
+ }
656
+ return info;
657
+ }
658
+ async function assertFileWithinByteLimit(filePath, maxBytes) {
659
+ const info = await lstatRegularFile(filePath);
660
+ if (info.size > maxBytes) {
661
+ throw new IngestLimitError("INGEST_SIZE_LIMIT", "file exceeds size limit");
662
+ }
663
+ return info;
664
+ }
665
+ async function measureDirectoryBytes(dirPath, maxBytes) {
666
+ await lstatRegularDirectory(dirPath);
667
+ let total = 0;
668
+ const walk = async (current) => {
669
+ const entries = await readdir(current, { withFileTypes: true });
670
+ for (const entry of entries) {
671
+ const abs = path10.join(current, entry.name);
672
+ const info = await lstat(abs);
673
+ if (info.isSymbolicLink()) {
674
+ throw new IngestLimitError(
675
+ "INGEST_SYMLINK_REJECTED",
676
+ "symbolic links are not allowed for ingest"
677
+ );
678
+ }
679
+ if (info.isDirectory()) {
680
+ await walk(abs);
681
+ continue;
682
+ }
683
+ if (!info.isFile()) continue;
684
+ total += info.size;
685
+ if (total > maxBytes) {
686
+ throw new IngestLimitError("INGEST_SIZE_LIMIT", "directory exceeds size limit");
687
+ }
688
+ }
689
+ };
690
+ await walk(dirPath);
691
+ return total;
692
+ }
693
+ async function readBoundedResponseBody(response, maxBytes) {
694
+ const lengthHeader = response.headers.get("content-length");
695
+ if (lengthHeader) {
696
+ const length = Number(lengthHeader);
697
+ if (Number.isFinite(length) && length > maxBytes) {
698
+ throw new IngestLimitError("INGEST_SIZE_LIMIT", "response exceeds size limit");
699
+ }
700
+ }
701
+ if (!response.body) {
702
+ const arrayBuffer = await response.arrayBuffer();
703
+ if (arrayBuffer.byteLength > maxBytes) {
704
+ throw new IngestLimitError("INGEST_SIZE_LIMIT", "response exceeds size limit");
705
+ }
706
+ return Buffer.from(arrayBuffer);
707
+ }
708
+ const reader = response.body.getReader();
709
+ const chunks = [];
710
+ let total = 0;
711
+ for (; ; ) {
712
+ const { done, value } = await reader.read();
713
+ if (done) break;
714
+ if (!value || value.byteLength === 0) continue;
715
+ total += value.byteLength;
716
+ if (total > maxBytes) {
717
+ try {
718
+ await reader.cancel();
719
+ } catch {
720
+ }
721
+ throw new IngestLimitError("INGEST_SIZE_LIMIT", "response exceeds size limit");
722
+ }
723
+ chunks.push(Buffer.from(value));
724
+ }
725
+ return Buffer.concat(chunks);
726
+ }
727
+ async function withAtomicStagingDir(parentDir, work) {
728
+ await mkdir(parentDir, { recursive: true });
729
+ const stagingDir = await mkdtemp(path10.join(parentDir, ".ingest-staging-"));
730
+ try {
731
+ return await work(stagingDir);
732
+ } finally {
733
+ await rm(stagingDir, { recursive: true, force: true });
734
+ }
735
+ }
736
+ async function promoteStagingPath(stagingPath, finalPath) {
737
+ await mkdir(path10.dirname(finalPath), { recursive: true });
738
+ await rename(stagingPath, finalPath);
739
+ }
594
740
 
595
741
  // packages/studio/src/ingest/file-drop.ts
596
742
  var FILE_DROP_ARCHIVE_DIR = ".imported";
@@ -603,26 +749,38 @@ function classifyFile(fileName) {
603
749
  return void 0;
604
750
  }
605
751
  async function hashFile(filePath) {
606
- const data = await readFile(filePath);
607
- return createHash("sha256").update(data).digest("hex");
752
+ const hash = createHash("sha256");
753
+ const stream = createReadStream(filePath);
754
+ for await (const chunk of stream) {
755
+ hash.update(chunk);
756
+ }
757
+ return hash.digest("hex");
608
758
  }
609
759
  async function importOneFile(options) {
610
760
  const destPath = uniqueDestPath(options.destDir, options.fileName, options.contentHash);
611
- assertPathUnderRoot(destPath, path8.dirname(options.destDir));
612
- await mkdir(options.destDir, { recursive: true });
613
- await copyFile(options.sourcePath, destPath);
614
- insertIngestFile(options.db, {
615
- sourceKey: options.sourceKey,
616
- sourceName: options.fileName,
617
- destPath,
618
- kind: options.kind,
619
- contentHash: options.contentHash,
620
- importedAt: options.importedAt
621
- });
761
+ assertPathUnderRoot(destPath, path10.dirname(options.destDir));
762
+ try {
763
+ await withAtomicStagingDir(options.destDir, async (stagingDir) => {
764
+ const stagedPath = path10.join(stagingDir, options.fileName);
765
+ await copyFile(options.sourcePath, stagedPath);
766
+ await promoteStagingPath(stagedPath, destPath);
767
+ });
768
+ insertIngestFile(options.db, {
769
+ sourceKey: options.sourceKey,
770
+ sourceName: options.fileName,
771
+ destPath,
772
+ kind: options.kind,
773
+ contentHash: options.contentHash,
774
+ importedAt: options.importedAt
775
+ });
776
+ } catch (error) {
777
+ await rm(destPath, { force: true }).catch(() => void 0);
778
+ throw error;
779
+ }
622
780
  let archived = false;
623
781
  if (options.archiveAfterImport) {
624
782
  await mkdir(options.archiveDir, { recursive: true });
625
- const archiveTarget = path8.join(options.archiveDir, options.fileName);
783
+ const archiveTarget = path10.join(options.archiveDir, options.fileName);
626
784
  await rename(options.sourcePath, archiveTarget);
627
785
  archived = true;
628
786
  }
@@ -651,10 +809,27 @@ async function importFileDrop(options) {
651
809
  files
652
810
  };
653
811
  }
812
+ let maxBytes;
813
+ try {
814
+ maxBytes = resolveIngestMaxBytes(
815
+ options.maxBytes ?? options.registry.ingest?.fileDrop?.maxBytes
816
+ );
817
+ } catch (error) {
818
+ const message = error instanceof Error ? error.message : String(error);
819
+ return {
820
+ skipped: false,
821
+ scanned: 0,
822
+ imported: 0,
823
+ skippedFiles: 0,
824
+ errors: [message],
825
+ warnings,
826
+ files
827
+ };
828
+ }
654
829
  const dirs = resolveImportDirs(options.registryPath, options.registry);
655
830
  let dropDir;
656
831
  try {
657
- dropDir = options.dropDir ? path8.isAbsolute(options.dropDir) ? (assertPathUnderRoot(options.dropDir, dirs.registryDir), options.dropDir) : resolveUnderRoot(dirs.registryDir, options.dropDir) : dirs.fileDropDir;
832
+ dropDir = options.dropDir ? path10.isAbsolute(options.dropDir) ? (assertPathUnderRoot(options.dropDir, dirs.registryDir), options.dropDir) : resolveUnderRoot(dirs.registryDir, options.dropDir) : dirs.fileDropDir;
658
833
  assertPathUnderRoot(dropDir, dirs.registryDir);
659
834
  } catch (error) {
660
835
  const message = error instanceof Error ? error.message : String(error);
@@ -684,24 +859,35 @@ async function importFileDrop(options) {
684
859
  };
685
860
  }
686
861
  const importedAt = (/* @__PURE__ */ new Date()).toISOString();
687
- const archiveDir = path8.join(dropDir, FILE_DROP_ARCHIVE_DIR);
862
+ const archiveDir = path10.join(dropDir, FILE_DROP_ARCHIVE_DIR);
688
863
  let scanned = 0;
689
864
  let imported = 0;
690
865
  let skippedFiles = 0;
691
866
  for (const entry of entries.sort()) {
692
867
  if (entry === FILE_DROP_ARCHIVE_DIR || entry.startsWith(".")) continue;
693
- const sourcePath = path8.join(dropDir, entry);
694
- let fileStat;
868
+ const sourcePath = path10.join(dropDir, entry);
869
+ const kind = classifyFile(entry);
870
+ if (!kind) continue;
871
+ scanned += 1;
695
872
  try {
696
- fileStat = await stat(sourcePath);
697
- } catch {
873
+ await assertFileWithinByteLimit(sourcePath, maxBytes);
874
+ } catch (error) {
875
+ if (error instanceof IngestLimitError && error.code === "INGEST_NOT_A_FILE") {
876
+ scanned -= 1;
877
+ continue;
878
+ }
879
+ if (error instanceof IngestLimitError && error.code === "INGEST_SYMLINK_REJECTED") {
880
+ errors.push(`failed to import ${entry}: ${error.message}`);
881
+ continue;
882
+ }
883
+ if (error instanceof IngestLimitError && error.code === "INGEST_SIZE_LIMIT") {
884
+ errors.push(`failed to import ${entry}: ${error.message}`);
885
+ continue;
886
+ }
698
887
  warnings.push(`skipped unreadable entry: ${entry}`);
888
+ scanned -= 1;
699
889
  continue;
700
890
  }
701
- if (!fileStat.isFile()) continue;
702
- const kind = classifyFile(entry);
703
- if (!kind) continue;
704
- scanned += 1;
705
891
  const sourceKey = entry;
706
892
  let contentHash;
707
893
  try {
@@ -754,7 +940,8 @@ async function importFileDropFromRegistry(options) {
754
940
  registry: options.registry,
755
941
  enabled: options.enabled,
756
942
  ...options.dropDir !== void 0 ? { dropDir: options.dropDir } : {},
757
- ...options.archiveAfterImport !== void 0 ? { archiveAfterImport: options.archiveAfterImport } : {}
943
+ ...options.archiveAfterImport !== void 0 ? { archiveAfterImport: options.archiveAfterImport } : {},
944
+ ...options.maxBytes !== void 0 ? { maxBytes: options.maxBytes } : {}
758
945
  });
759
946
  }
760
947
  async function runStudioFileDropImport(options) {
@@ -871,7 +1058,6 @@ function isIngestTokenValid(provided, expected) {
871
1058
  var DEFAULT_HTTP_INGEST_BASE_PATH = "/api/ingest";
872
1059
  var HTTP_INGEST_BUNDLE_PATH = "/api/ingest/bundle";
873
1060
  var HTTP_INGEST_ARTIFACT_PATH = "/api/ingest/artifact";
874
- var DEFAULT_MAX_INGEST_BYTES = 52428800;
875
1061
  function resolveHttpIngestConfig(options, registryHttp) {
876
1062
  const http = registryHttp ?? options.context?.registry.ingest?.http;
877
1063
  const enabled = options.ingestHttp === true || http?.enabled === true;
@@ -880,7 +1066,7 @@ function resolveHttpIngestConfig(options, registryHttp) {
880
1066
  ...options.ingestTokenEnv !== void 0 ? { tokenEnv: options.ingestTokenEnv } : {},
881
1067
  ...http?.tokenEnv !== void 0 ? { registryTokenEnv: http.tokenEnv } : {}
882
1068
  });
883
- const maxBytes = http?.maxBytes ?? DEFAULT_MAX_INGEST_BYTES;
1069
+ const maxBytes = resolveIngestMaxBytes(http?.maxBytes);
884
1070
  return { enabled, basePath, tokenEnv, maxBytes };
885
1071
  }
886
1072
  function sendJson(res, status, body) {
@@ -950,22 +1136,30 @@ async function handleHttpIngestRequest(req, res, ctx, options, pathname) {
950
1136
  const destDir = isBundle ? dirs.bundlesDir : dirs.ciArtifactsDir;
951
1137
  const destPath = uniqueDestPath(destDir, fileName, contentHash);
952
1138
  assertPathUnderRoot(destPath, dirs.registryDir);
953
- await mkdir(destDir, { recursive: true });
954
- await writeFile(destPath, body);
955
- const sourceKey = isBundle ? `http:bundle:${contentHash}` : buildGitHubArtifactSourceKey({
956
- owner: "http",
957
- repo: "ingest",
958
- runId: importedAt,
959
- artifactName: fileName
960
- });
961
- insertIngestFile(ctx.db, {
962
- sourceKey,
963
- sourceName: fileName,
964
- destPath,
965
- kind: isBundle ? "bundle" : "ci",
966
- contentHash,
967
- importedAt
968
- });
1139
+ try {
1140
+ await withAtomicStagingDir(destDir, async (stagingDir) => {
1141
+ const stagedPath = path10.join(stagingDir, fileName);
1142
+ await writeFile(stagedPath, body);
1143
+ await promoteStagingPath(stagedPath, destPath);
1144
+ });
1145
+ const sourceKey = isBundle ? `http:bundle:${contentHash}` : buildGitHubArtifactSourceKey({
1146
+ owner: "http",
1147
+ repo: "ingest",
1148
+ runId: importedAt,
1149
+ artifactName: fileName
1150
+ });
1151
+ insertIngestFile(ctx.db, {
1152
+ sourceKey,
1153
+ sourceName: fileName,
1154
+ destPath,
1155
+ kind: isBundle ? "bundle" : "ci",
1156
+ contentHash,
1157
+ importedAt
1158
+ });
1159
+ } catch (error) {
1160
+ await rm(destPath, { force: true }).catch(() => void 0);
1161
+ throw error;
1162
+ }
969
1163
  const registryImport = await importStudioRegistry({
970
1164
  db: ctx.db,
971
1165
  registry: ctx.registry,
@@ -1150,7 +1344,7 @@ function getImportedProject(db, projects, projectId) {
1150
1344
  async function loadTraceDirMetas(workspaceDir, traceDirs) {
1151
1345
  const metas = [];
1152
1346
  for (const rel of traceDirs) {
1153
- const traceDir = resolveTraceDir({ dir: path8.join(workspaceDir, rel) });
1347
+ const traceDir = resolveTraceDir({ dir: path10.join(workspaceDir, rel) });
1154
1348
  const td = new TraceDirectory({ dir: traceDir });
1155
1349
  const files = await td.list();
1156
1350
  const listed = await loadTraceMetadataList(
@@ -1179,7 +1373,7 @@ async function loadProjectSuitesView(ctx) {
1179
1373
  try {
1180
1374
  const result = await runSuite({
1181
1375
  configPath,
1182
- cwd: path8.dirname(configPath)
1376
+ cwd: path10.dirname(configPath)
1183
1377
  });
1184
1378
  suites.push({
1185
1379
  suiteName: result.suiteName,
@@ -1197,7 +1391,7 @@ async function loadProjectSuitesView(ctx) {
1197
1391
  } catch (error) {
1198
1392
  const message = error instanceof Error ? error.message : String(error);
1199
1393
  suites.push({
1200
- suiteName: path8.basename(configPath),
1394
+ suiteName: path10.basename(configPath),
1201
1395
  configPath,
1202
1396
  ok: false,
1203
1397
  status: "error",
@@ -1246,7 +1440,7 @@ async function loadProjectSearchView(ctx, db, params) {
1246
1440
  }
1247
1441
  const metas = await loadTraceDirMetas(ctx.project.workspaceDir, ["runs"]);
1248
1442
  const traceDir = resolveTraceDir({
1249
- dir: path8.join(ctx.project.workspaceDir, "runs")
1443
+ dir: path10.join(ctx.project.workspaceDir, "runs")
1250
1444
  });
1251
1445
  const results = await searchTraces(metas, {
1252
1446
  traceDir,
@@ -1290,12 +1484,12 @@ async function loadProjectDiffView(ctx, params) {
1290
1484
  };
1291
1485
  }
1292
1486
  async function loadProjectReportsView(ctx) {
1293
- const reportsDir = path8.join(ctx.project.workspaceDir, "reports");
1487
+ const reportsDir = path10.join(ctx.project.workspaceDir, "reports");
1294
1488
  const reports = [];
1295
1489
  try {
1296
1490
  const files = await readdir(reportsDir);
1297
1491
  for (const file of files) {
1298
- const filePath = path8.join(reportsDir, file);
1492
+ const filePath = path10.join(reportsDir, file);
1299
1493
  const info = await stat(filePath);
1300
1494
  if (!info.isFile()) continue;
1301
1495
  reports.push({ name: file, path: filePath, sizeBytes: info.size });
@@ -1350,7 +1544,7 @@ async function loadBundleExportView(ctx, params) {
1350
1544
  runId,
1351
1545
  readOnly: true,
1352
1546
  redactionProfile: ctx.project.redactionProfile ?? "share",
1353
- cliHint: `npx agent-inspect bundle ${runId} --profile ${ctx.project.redactionProfile ?? "share"} --dir ${path8.join(ctx.project.workspaceDir, "runs")}`,
1547
+ cliHint: `npx agent-inspect bundle ${runId} --profile ${ctx.project.redactionProfile ?? "share"} --dir ${path10.join(ctx.project.workspaceDir, "runs")}`,
1354
1548
  note: "Studio does not mutate traces or upload bundles. Run the CLI locally to assemble a share-safe bundle."
1355
1549
  };
1356
1550
  }
@@ -1692,7 +1886,6 @@ async function startStudioServer(options = {}) {
1692
1886
  }
1693
1887
  var DEFAULT_GITHUB_TOKEN_ENV = "GITHUB_TOKEN";
1694
1888
  var GITHUB_API_BASE = "https://api.github.com";
1695
- var MAX_ARTIFACT_BYTES = 52428800;
1696
1889
  function resolveTokenEnv(registry, override) {
1697
1890
  const fromRegistry = registry.ingest?.github?.tokenEnv?.trim();
1698
1891
  const envName = override?.trim() || fromRegistry || DEFAULT_GITHUB_TOKEN_ENV;
@@ -1717,20 +1910,17 @@ function githubHeaders(token) {
1717
1910
  };
1718
1911
  }
1719
1912
  async function readResponseBody(response, maxBytes) {
1720
- const lengthHeader = response.headers.get("content-length");
1721
- if (lengthHeader) {
1722
- const length = Number(lengthHeader);
1723
- if (Number.isFinite(length) && length > maxBytes) {
1913
+ try {
1914
+ return await readBoundedResponseBody(response, maxBytes);
1915
+ } catch (error) {
1916
+ if (error instanceof IngestLimitError && error.code === "INGEST_SIZE_LIMIT") {
1724
1917
  throw new Error("artifact exceeds size limit");
1725
1918
  }
1919
+ throw error;
1726
1920
  }
1727
- const arrayBuffer = await response.arrayBuffer();
1728
- if (arrayBuffer.byteLength > maxBytes) {
1729
- throw new Error("artifact exceeds size limit");
1730
- }
1731
- return Buffer.from(arrayBuffer);
1732
1921
  }
1733
1922
  async function downloadGitHubArtifactArchive(options) {
1923
+ const maxBytes = resolveIngestMaxBytes(options.maxBytes);
1734
1924
  const fetchFn = options.fetchImpl ?? fetch;
1735
1925
  const { owner, name } = parseGitHubRepo(options.repo);
1736
1926
  const runId = options.runId.trim();
@@ -1756,7 +1946,7 @@ async function downloadGitHubArtifactArchive(options) {
1756
1946
  if (artifact.expired === true) {
1757
1947
  throw new Error(`artifact expired for run ${runId}: ${artifactName}`);
1758
1948
  }
1759
- if (typeof artifact.size_in_bytes === "number" && artifact.size_in_bytes > MAX_ARTIFACT_BYTES) {
1949
+ if (typeof artifact.size_in_bytes === "number" && artifact.size_in_bytes > maxBytes) {
1760
1950
  throw new Error("artifact exceeds size limit");
1761
1951
  }
1762
1952
  const downloadResponse = await fetchFn(artifact.archive_download_url, {
@@ -1766,7 +1956,7 @@ async function downloadGitHubArtifactArchive(options) {
1766
1956
  if (!downloadResponse.ok) {
1767
1957
  throw new Error(`GitHub artifact download failed (${downloadResponse.status})`);
1768
1958
  }
1769
- return readResponseBody(downloadResponse, MAX_ARTIFACT_BYTES);
1959
+ return readResponseBody(downloadResponse, maxBytes);
1770
1960
  }
1771
1961
  async function importGitHubArtifact(options) {
1772
1962
  const registryImportWarnings = [];
@@ -1816,17 +2006,25 @@ async function importGitHubArtifact(options) {
1816
2006
  const fileName = `${options.artifactName}.zip`;
1817
2007
  const destPath = uniqueDestPath(dirs.bundlesDir, fileName, contentHash);
1818
2008
  assertPathUnderRoot(destPath, dirs.registryDir);
1819
- await mkdir(dirs.bundlesDir, { recursive: true });
1820
- await writeFile(destPath, archive);
1821
- const importedAt = (/* @__PURE__ */ new Date()).toISOString();
1822
- insertIngestFile(options.db, {
1823
- sourceKey,
1824
- sourceName: fileName,
1825
- destPath,
1826
- kind: "bundle",
1827
- contentHash,
1828
- importedAt
1829
- });
2009
+ try {
2010
+ await withAtomicStagingDir(dirs.bundlesDir, async (stagingDir) => {
2011
+ const stagedPath = path10.join(stagingDir, fileName);
2012
+ await writeFile(stagedPath, archive);
2013
+ await promoteStagingPath(stagedPath, destPath);
2014
+ });
2015
+ const importedAt = (/* @__PURE__ */ new Date()).toISOString();
2016
+ insertIngestFile(options.db, {
2017
+ sourceKey,
2018
+ sourceName: fileName,
2019
+ destPath,
2020
+ kind: "bundle",
2021
+ contentHash,
2022
+ importedAt
2023
+ });
2024
+ } catch (error) {
2025
+ await rm(destPath, { force: true }).catch(() => void 0);
2026
+ throw error;
2027
+ }
1830
2028
  const registryImport = await importStudioRegistry({
1831
2029
  db: options.db,
1832
2030
  registry: options.registry,
@@ -1890,7 +2088,7 @@ async function pathExists(filePath) {
1890
2088
  }
1891
2089
  async function validateBundleDirectory(bundleDir) {
1892
2090
  const errors = [];
1893
- const metadataPath = path8.join(bundleDir, "metadata.json");
2091
+ const metadataPath = path10.join(bundleDir, "metadata.json");
1894
2092
  if (!await pathExists(metadataPath)) {
1895
2093
  errors.push("bundle missing metadata.json");
1896
2094
  return errors;
@@ -1916,11 +2114,18 @@ async function hashBundleContents(bundleDir) {
1916
2114
  (a, b) => a.name.localeCompare(b.name)
1917
2115
  );
1918
2116
  for (const entry of entries) {
1919
- const abs = path8.join(dir, entry.name);
2117
+ const abs = path10.join(dir, entry.name);
1920
2118
  const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
1921
- if (entry.isDirectory()) {
2119
+ const info = await lstat(abs);
2120
+ if (info.isSymbolicLink()) {
2121
+ throw new IngestLimitError(
2122
+ "INGEST_SYMLINK_REJECTED",
2123
+ "symbolic links are not allowed for ingest"
2124
+ );
2125
+ }
2126
+ if (info.isDirectory()) {
1922
2127
  await walk(abs, rel);
1923
- } else if (entry.isFile()) {
2128
+ } else if (info.isFile()) {
1924
2129
  hash.update(rel);
1925
2130
  hash.update("\0");
1926
2131
  hash.update(await readFile(abs));
@@ -1935,11 +2140,18 @@ async function copyDirectoryRecursive(sourceDir, destDir) {
1935
2140
  await mkdir(destDir, { recursive: true });
1936
2141
  const entries = await readdir(sourceDir, { withFileTypes: true });
1937
2142
  for (const entry of entries) {
1938
- const from = path8.join(sourceDir, entry.name);
1939
- const to = path8.join(destDir, entry.name);
1940
- if (entry.isDirectory()) {
2143
+ const from = path10.join(sourceDir, entry.name);
2144
+ const to = path10.join(destDir, entry.name);
2145
+ const info = await lstat(from);
2146
+ if (info.isSymbolicLink()) {
2147
+ throw new IngestLimitError(
2148
+ "INGEST_SYMLINK_REJECTED",
2149
+ "symbolic links are not allowed for ingest"
2150
+ );
2151
+ }
2152
+ if (info.isDirectory()) {
1941
2153
  await copyDirectoryRecursive(from, to);
1942
- } else if (entry.isFile()) {
2154
+ } else if (info.isFile()) {
1943
2155
  await copyFile(from, to);
1944
2156
  }
1945
2157
  }
@@ -1956,11 +2168,18 @@ async function importBundleUpload(options) {
1956
2168
  registryImportWarnings
1957
2169
  };
1958
2170
  }
1959
- const bundlePath = path8.resolve(options.bundlePath);
1960
- let bundleStat;
2171
+ const bundlePath = path10.resolve(options.bundlePath);
1961
2172
  try {
1962
- bundleStat = await stat(bundlePath);
1963
- } catch {
2173
+ await lstatRegularDirectory(bundlePath);
2174
+ } catch (error) {
2175
+ if (error instanceof IngestLimitError) {
2176
+ return {
2177
+ skipped: false,
2178
+ imported: false,
2179
+ errors: [error.message],
2180
+ registryImportWarnings
2181
+ };
2182
+ }
1964
2183
  return {
1965
2184
  skipped: false,
1966
2185
  imported: false,
@@ -1968,24 +2187,42 @@ async function importBundleUpload(options) {
1968
2187
  registryImportWarnings
1969
2188
  };
1970
2189
  }
1971
- if (!bundleStat.isDirectory()) {
2190
+ const validationErrors = await validateBundleDirectory(bundlePath);
2191
+ if (validationErrors.length > 0) {
1972
2192
  return {
1973
2193
  skipped: false,
1974
2194
  imported: false,
1975
- errors: ["bundle path must be a directory produced by agent-inspect bundle"],
2195
+ errors: validationErrors,
1976
2196
  registryImportWarnings
1977
2197
  };
1978
2198
  }
1979
- const validationErrors = await validateBundleDirectory(bundlePath);
1980
- if (validationErrors.length > 0) {
2199
+ let maxBytes;
2200
+ try {
2201
+ maxBytes = resolveIngestMaxBytes(
2202
+ options.maxBytes ?? options.registry.ingest?.bundleUpload?.maxBytes
2203
+ );
2204
+ await measureDirectoryBytes(bundlePath, maxBytes);
2205
+ } catch (error) {
2206
+ const message = error instanceof Error ? error.message : String(error);
1981
2207
  return {
1982
2208
  skipped: false,
1983
2209
  imported: false,
1984
- errors: validationErrors,
2210
+ errors: [message],
2211
+ registryImportWarnings
2212
+ };
2213
+ }
2214
+ let contentHash;
2215
+ try {
2216
+ contentHash = await hashBundleContents(bundlePath);
2217
+ } catch (error) {
2218
+ const message = error instanceof Error ? error.message : String(error);
2219
+ return {
2220
+ skipped: false,
2221
+ imported: false,
2222
+ errors: [message],
1985
2223
  registryImportWarnings
1986
2224
  };
1987
2225
  }
1988
- const contentHash = await hashBundleContents(bundlePath);
1989
2226
  const sourceKey = `bundle:${bundlePath}`;
1990
2227
  const existing = findIngestFileBySourceKey(options.db, sourceKey);
1991
2228
  if (existing && existing.contentHash === contentHash) {
@@ -1999,11 +2236,15 @@ async function importBundleUpload(options) {
1999
2236
  };
2000
2237
  }
2001
2238
  const dirs = resolveImportDirs(options.registryPath, options.registry);
2002
- const folderName = path8.basename(bundlePath);
2239
+ const folderName = path10.basename(bundlePath);
2003
2240
  const destPath = uniqueDestPath(dirs.bundlesDir, folderName, contentHash);
2004
2241
  assertPathUnderRoot(destPath, dirs.registryDir);
2005
2242
  try {
2006
- await copyDirectoryRecursive(bundlePath, destPath);
2243
+ await withAtomicStagingDir(dirs.bundlesDir, async (stagingDir) => {
2244
+ const stagedDest = path10.join(stagingDir, folderName);
2245
+ await copyDirectoryRecursive(bundlePath, stagedDest);
2246
+ await promoteStagingPath(stagedDest, destPath);
2247
+ });
2007
2248
  insertIngestFile(options.db, {
2008
2249
  sourceKey,
2009
2250
  sourceName: folderName,
@@ -2027,6 +2268,7 @@ async function importBundleUpload(options) {
2027
2268
  registryImportWarnings
2028
2269
  };
2029
2270
  } catch (error) {
2271
+ await rm(destPath, { recursive: true, force: true }).catch(() => void 0);
2030
2272
  const message = error instanceof Error ? error.message : String(error);
2031
2273
  return {
2032
2274
  skipped: false,