@agent-inspect/studio 6.0.0 → 6.3.0

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,14 +1,35 @@
1
- import { readFile, access, mkdir, readdir, stat } from 'fs/promises';
2
- import path5 from 'path';
1
+ import { readFile, mkdir, access, readdir, stat, writeFile, copyFile, rename } from 'fs/promises';
2
+ import path8 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
5
  import Database from 'better-sqlite3';
6
+ import { timingSafeEqual, createHash } from 'crypto';
6
7
  import { createServer } from 'http';
7
8
  import { runTraceChecks, createRunStatusRule } from 'agent-inspect/checks';
8
9
  import { diffRuns, manualTraceEventsToComparableRun } from 'agent-inspect/diff';
9
10
  import { persistedInspectEventsToTraceEvents } from 'agent-inspect/persisted';
10
11
  import { openTrace } from 'agent-inspect/readers';
11
12
 
13
+ // packages/studio/src/registry.ts
14
+ function isSafeRelativePath(p) {
15
+ const trimmed = p.trim();
16
+ if (trimmed === "" || trimmed.startsWith("/") || trimmed.startsWith("\\")) return false;
17
+ if (/^[a-zA-Z]:/.test(trimmed)) return false;
18
+ return !trimmed.split(/[/\\]+/).some((seg) => seg === "..");
19
+ }
20
+ function resolveUnderRoot(root, ...segments) {
21
+ const resolvedRoot = path8.resolve(root);
22
+ const resolved = path8.resolve(resolvedRoot, ...segments);
23
+ assertPathUnderRoot(resolved, resolvedRoot);
24
+ return resolved;
25
+ }
26
+ function assertPathUnderRoot(resolved, root) {
27
+ const rel = path8.relative(path8.resolve(root), path8.resolve(resolved));
28
+ if (rel.startsWith("..") || path8.isAbsolute(rel)) {
29
+ throw new Error("path escapes allowed registry root");
30
+ }
31
+ }
32
+
12
33
  // packages/studio/src/registry.ts
13
34
  var STUDIO_REGISTRY_SCHEMA_VERSION = "1.0";
14
35
  var STUDIO_REGISTRY_FILENAMES = [
@@ -19,16 +40,10 @@ var MAX_REGISTRY_BYTES = 256 * 1024;
19
40
  function isPlainObject(value) {
20
41
  return typeof value === "object" && value !== null && !Array.isArray(value);
21
42
  }
22
- function isSafeRelativePath(p) {
23
- const trimmed = p.trim();
24
- if (trimmed === "" || trimmed.startsWith("/") || trimmed.startsWith("\\")) return false;
25
- if (/^[a-zA-Z]:/.test(trimmed)) return false;
26
- return !trimmed.split(/[/\\]+/).some((seg) => seg === "..");
27
- }
28
43
  function parseStudioRegistry(input) {
29
44
  const errors = [];
30
45
  if (!isPlainObject(input)) {
31
- return { ok: false, errors: ["registry must be a JSON object"] };
46
+ return { ok: false, errors: ["registry must be a JSON object"], warnings: [] };
32
47
  }
33
48
  if (input.schemaVersion !== STUDIO_REGISTRY_SCHEMA_VERSION) {
34
49
  errors.push(`schemaVersion must be "${STUDIO_REGISTRY_SCHEMA_VERSION}"`);
@@ -82,35 +97,157 @@ function parseStudioRegistry(input) {
82
97
  importConfig.bundlesDir = dir;
83
98
  }
84
99
  }
100
+ if (input.import.fileDropDir !== void 0) {
101
+ const dir = String(input.import.fileDropDir).trim();
102
+ if (!isSafeRelativePath(dir)) {
103
+ errors.push("import.fileDropDir must be a safe relative path");
104
+ } else {
105
+ importConfig.fileDropDir = dir;
106
+ }
107
+ }
108
+ if (input.import.enabled !== void 0) {
109
+ if (typeof input.import.enabled !== "boolean") {
110
+ errors.push("import.enabled must be a boolean");
111
+ } else {
112
+ importConfig.enabled = input.import.enabled;
113
+ }
114
+ }
85
115
  }
86
116
  }
87
- if (errors.length > 0) return { ok: false, errors };
117
+ let ingestConfig;
118
+ const ingestWarnings = [];
119
+ if (input.ingest !== void 0) {
120
+ if (!isPlainObject(input.ingest)) {
121
+ errors.push("ingest must be an object");
122
+ } else {
123
+ ingestConfig = {};
124
+ if (input.ingest.github !== void 0) {
125
+ if (!isPlainObject(input.ingest.github)) {
126
+ errors.push("ingest.github must be an object");
127
+ } else {
128
+ const github = {};
129
+ if (input.ingest.github.enabled !== void 0) {
130
+ if (typeof input.ingest.github.enabled !== "boolean") {
131
+ errors.push("ingest.github.enabled must be a boolean");
132
+ } else {
133
+ github.enabled = input.ingest.github.enabled;
134
+ }
135
+ }
136
+ if (input.ingest.github.tokenEnv !== void 0) {
137
+ const tokenEnv = String(input.ingest.github.tokenEnv).trim();
138
+ if (!/^[A-Z][A-Z0-9_]*$/.test(tokenEnv)) {
139
+ errors.push("ingest.github.tokenEnv must be an uppercase env var name");
140
+ } else {
141
+ github.tokenEnv = tokenEnv;
142
+ }
143
+ }
144
+ ingestConfig.github = github;
145
+ }
146
+ }
147
+ if (input.ingest.http !== void 0) {
148
+ if (!isPlainObject(input.ingest.http)) {
149
+ errors.push("ingest.http must be an object");
150
+ } else {
151
+ const http = {};
152
+ if (input.ingest.http.enabled !== void 0) {
153
+ if (typeof input.ingest.http.enabled !== "boolean") {
154
+ errors.push("ingest.http.enabled must be a boolean");
155
+ } else {
156
+ http.enabled = input.ingest.http.enabled;
157
+ }
158
+ }
159
+ if (input.ingest.http.path !== void 0) {
160
+ const ingestPath = String(input.ingest.http.path).trim();
161
+ if (!ingestPath.startsWith("/") || ingestPath.includes("..")) {
162
+ errors.push("ingest.http.path must be an absolute safe path");
163
+ } else {
164
+ http.path = ingestPath;
165
+ }
166
+ }
167
+ if (input.ingest.http.tokenEnv !== void 0) {
168
+ const tokenEnv = String(input.ingest.http.tokenEnv).trim();
169
+ if (!/^[A-Z][A-Z0-9_]*$/.test(tokenEnv)) {
170
+ errors.push("ingest.http.tokenEnv must be an uppercase env var name");
171
+ } else {
172
+ http.tokenEnv = tokenEnv;
173
+ }
174
+ }
175
+ if (input.ingest.http.maxBytes !== void 0) {
176
+ const maxBytes = Number(input.ingest.http.maxBytes);
177
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
178
+ errors.push("ingest.http.maxBytes must be a positive integer");
179
+ } else {
180
+ http.maxBytes = maxBytes;
181
+ }
182
+ }
183
+ ingestConfig.http = http;
184
+ }
185
+ }
186
+ if (input.ingest.bundleUpload !== void 0) {
187
+ if (!isPlainObject(input.ingest.bundleUpload)) {
188
+ errors.push("ingest.bundleUpload must be an object");
189
+ } else {
190
+ const bundleUpload = {};
191
+ if (input.ingest.bundleUpload.enabled !== void 0) {
192
+ if (typeof input.ingest.bundleUpload.enabled !== "boolean") {
193
+ errors.push("ingest.bundleUpload.enabled must be a boolean");
194
+ } else {
195
+ bundleUpload.enabled = input.ingest.bundleUpload.enabled;
196
+ }
197
+ }
198
+ if (input.ingest.bundleUpload.maxBytes !== void 0) {
199
+ const maxBytes = Number(input.ingest.bundleUpload.maxBytes);
200
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
201
+ errors.push("ingest.bundleUpload.maxBytes must be a positive integer");
202
+ } else {
203
+ bundleUpload.maxBytes = maxBytes;
204
+ }
205
+ }
206
+ ingestConfig.bundleUpload = bundleUpload;
207
+ }
208
+ }
209
+ const knownIngestKeys = /* @__PURE__ */ new Set(["github", "http", "bundleUpload"]);
210
+ for (const key of Object.keys(input.ingest)) {
211
+ if (!knownIngestKeys.has(key)) {
212
+ ingestWarnings.push(`ignored unknown ingest key: ${key}`);
213
+ }
214
+ }
215
+ }
216
+ }
217
+ if (errors.length > 0) return { ok: false, errors, warnings: ingestWarnings };
88
218
  return {
89
219
  ok: true,
90
220
  registry: {
91
221
  schemaVersion: STUDIO_REGISTRY_SCHEMA_VERSION,
92
222
  name: String(input.name).trim(),
93
223
  projects,
94
- ...importConfig ? { import: importConfig } : {}
224
+ ...importConfig ? { import: importConfig } : {},
225
+ ...ingestConfig ? { ingest: ingestConfig } : {}
95
226
  },
96
- errors: []
227
+ errors: [],
228
+ warnings: ingestWarnings
97
229
  };
98
230
  }
99
231
  async function readStudioRegistryFile(filePath) {
100
232
  try {
101
233
  const raw = await readFile(filePath, "utf8");
102
234
  if (raw.length > MAX_REGISTRY_BYTES) {
103
- return { ok: false, path: filePath, errors: ["registry file exceeds size limit"] };
235
+ return {
236
+ ok: false,
237
+ path: filePath,
238
+ errors: ["registry file exceeds size limit"],
239
+ warnings: []
240
+ };
104
241
  }
105
242
  const parsed = parseStudioRegistry(JSON.parse(raw));
106
243
  return { ...parsed, path: filePath };
107
244
  } catch (error) {
108
245
  const message = error instanceof Error ? error.message : String(error);
109
- return { ok: false, path: filePath, errors: [message] };
246
+ return { ok: false, path: filePath, errors: [message], warnings: [] };
110
247
  }
111
248
  }
112
249
  function resolveRegistryProjectPath(registryDir, projectPath) {
113
- return path5.isAbsolute(projectPath) ? path5.resolve(projectPath) : path5.resolve(registryDir, projectPath);
250
+ return path8.isAbsolute(projectPath) ? path8.resolve(projectPath) : path8.resolve(registryDir, projectPath);
114
251
  }
115
252
  var STUDIO_DB_SCHEMA_VERSION = "1.0";
116
253
  var DEFAULT_STUDIO_DB_FILENAME = "studio.db";
@@ -143,6 +280,15 @@ CREATE TABLE IF NOT EXISTS runs (
143
280
  );
144
281
  CREATE INDEX IF NOT EXISTS idx_runs_project ON runs(project_id);
145
282
  CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
283
+ CREATE TABLE IF NOT EXISTS ingest_files (
284
+ source_key TEXT PRIMARY KEY,
285
+ source_name TEXT NOT NULL,
286
+ dest_path TEXT NOT NULL,
287
+ kind TEXT NOT NULL CHECK(kind IN ('ci', 'bundle')),
288
+ content_hash TEXT NOT NULL,
289
+ imported_at TEXT NOT NULL
290
+ );
291
+ CREATE INDEX IF NOT EXISTS idx_ingest_files_kind ON ingest_files(kind);
146
292
  `;
147
293
  function resolveStudioDbPath(options) {
148
294
  if (options.dbPath && options.dbPath.trim() !== "") {
@@ -150,9 +296,9 @@ function resolveStudioDbPath(options) {
150
296
  if (raw.startsWith("postgres://") || raw.startsWith("postgresql://")) {
151
297
  return raw;
152
298
  }
153
- return path5.resolve(options.cwd ?? process.cwd(), raw);
299
+ return path8.resolve(options.cwd ?? process.cwd(), raw);
154
300
  }
155
- return path5.resolve(
301
+ return path8.resolve(
156
302
  options.cwd ?? process.cwd(),
157
303
  ".agent-inspect",
158
304
  DEFAULT_STUDIO_DB_FILENAME
@@ -167,7 +313,7 @@ function openStudioDb(dbPath) {
167
313
  "Postgres studio databases are not implemented in v6.0.0; use a SQLite file path."
168
314
  );
169
315
  }
170
- const dir = path5.dirname(dbPath);
316
+ const dir = path8.dirname(dbPath);
171
317
  void mkdir(dir, { recursive: true });
172
318
  const db = new Database(dbPath);
173
319
  db.pragma("journal_mode = WAL");
@@ -251,18 +397,37 @@ function searchProjectRuns(db, projectId, query, limit = 50) {
251
397
  LIMIT ?`
252
398
  ).all(projectId, pattern, pattern, pattern, limit);
253
399
  }
400
+ function findIngestFileBySourceKey(db, sourceKey) {
401
+ return db.prepare(
402
+ `SELECT source_key AS sourceKey, source_name AS sourceName, dest_path AS destPath,
403
+ kind, content_hash AS contentHash, imported_at AS importedAt
404
+ FROM ingest_files WHERE source_key = ?`
405
+ ).get(sourceKey);
406
+ }
407
+ function insertIngestFile(db, row) {
408
+ db.prepare(
409
+ `INSERT INTO ingest_files(source_key, source_name, dest_path, kind, content_hash, imported_at)
410
+ VALUES (@sourceKey, @sourceName, @destPath, @kind, @contentHash, @importedAt)
411
+ ON CONFLICT(source_key) DO UPDATE SET
412
+ source_name = excluded.source_name,
413
+ dest_path = excluded.dest_path,
414
+ kind = excluded.kind,
415
+ content_hash = excluded.content_hash,
416
+ imported_at = excluded.imported_at`
417
+ ).run(row);
418
+ }
254
419
 
255
420
  // packages/studio/src/import.ts
256
421
  async function discoverSuiteConfigs(projectRoot, configured) {
257
422
  if (configured && configured.length > 0) {
258
- return configured.map((rel) => path5.resolve(projectRoot, rel));
423
+ return configured.map((rel) => path8.resolve(projectRoot, rel));
259
424
  }
260
425
  const found = [];
261
426
  try {
262
427
  const entries = await readdir(projectRoot);
263
428
  for (const entry of entries) {
264
429
  if (entry.endsWith(".suite.json")) {
265
- found.push(path5.join(projectRoot, entry));
430
+ found.push(path8.join(projectRoot, entry));
266
431
  }
267
432
  }
268
433
  } catch {
@@ -272,7 +437,7 @@ async function discoverSuiteConfigs(projectRoot, configured) {
272
437
  async function loadProjectRuns(workspaceDir, traceDirs) {
273
438
  const runs = [];
274
439
  for (const rel of traceDirs) {
275
- const traceDir = resolveTraceDir({ dir: path5.join(workspaceDir, rel) });
440
+ const traceDir = resolveTraceDir({ dir: path8.join(workspaceDir, rel) });
276
441
  const td = new TraceDirectory({ dir: traceDir });
277
442
  const files = await td.list();
278
443
  const metas = await loadTraceMetadataList(
@@ -286,7 +451,7 @@ async function loadProjectRuns(workspaceDir, traceDirs) {
286
451
  runId: meta.runId,
287
452
  ...meta.name !== void 0 ? { name: meta.name } : {},
288
453
  status: meta.status,
289
- file: path5.basename(meta.filePath),
454
+ file: path8.basename(meta.filePath),
290
455
  ...meta.startedAt !== void 0 ? { startedAt: meta.startedAt } : {},
291
456
  ...meta.durationMs !== void 0 ? { durationMs: meta.durationMs } : {}
292
457
  });
@@ -295,7 +460,7 @@ async function loadProjectRuns(workspaceDir, traceDirs) {
295
460
  return runs.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
296
461
  }
297
462
  async function importStudioRegistry(options) {
298
- const registryDir = path5.dirname(options.registryPath);
463
+ const registryDir = path8.dirname(options.registryPath);
299
464
  const warnings = [];
300
465
  const projects = [];
301
466
  const importedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -356,18 +521,242 @@ async function importStudioProject(options) {
356
521
  }
357
522
  async function resolveStudioRegistryPath(options) {
358
523
  if (options.workspacePath && options.workspacePath.trim() !== "") {
359
- return path5.resolve(options.cwd ?? process.cwd(), options.workspacePath);
524
+ return path8.resolve(options.cwd ?? process.cwd(), options.workspacePath);
360
525
  }
361
- const cwd = path5.resolve(options.cwd ?? process.cwd());
526
+ const cwd = path8.resolve(options.cwd ?? process.cwd());
362
527
  for (const rel of STUDIO_REGISTRY_FILENAMES) {
363
- const candidate = path5.join(cwd, rel);
528
+ const candidate = path8.join(cwd, rel);
364
529
  try {
365
530
  await access(candidate);
366
531
  return candidate;
367
532
  } catch {
368
533
  }
369
534
  }
370
- return path5.join(cwd, STUDIO_REGISTRY_FILENAMES[0]);
535
+ return path8.join(cwd, STUDIO_REGISTRY_FILENAMES[0]);
536
+ }
537
+ function resolveImportDirs(registryPath, registry) {
538
+ const registryDir = path8.dirname(registryPath);
539
+ const importConfig = registry.import ?? {};
540
+ const fileDropDir = importConfig.fileDropDir ?? "imports/drop";
541
+ const ciArtifactsDir = importConfig.ciArtifactsDir ?? "imports/ci";
542
+ const bundlesDir = importConfig.bundlesDir ?? "imports/bundles";
543
+ return {
544
+ registryDir,
545
+ fileDropDir: resolveUnderRoot(registryDir, fileDropDir),
546
+ ciArtifactsDir: resolveUnderRoot(registryDir, ciArtifactsDir),
547
+ bundlesDir: resolveUnderRoot(registryDir, bundlesDir)
548
+ };
549
+ }
550
+ function uniqueDestPath(destDir, fileName, contentHash) {
551
+ const ext = path8.extname(fileName);
552
+ const base = path8.basename(fileName, ext);
553
+ const shortHash = contentHash.slice(0, 8);
554
+ return path8.join(destDir, `${base}-${shortHash}${ext}`);
555
+ }
556
+ function sanitizeSafeErrorMessage(message, secret) {
557
+ if (!secret || secret.length < 4) return message;
558
+ return message.split(secret).join("[redacted]");
559
+ }
560
+ function parseGitHubRepo(repo) {
561
+ const trimmed = repo.trim();
562
+ const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed);
563
+ if (!match) {
564
+ throw new Error("repo must be in owner/name format");
565
+ }
566
+ return { owner: match[1], name: match[2] };
567
+ }
568
+ function buildGitHubArtifactSourceKey(options) {
569
+ return `github:${options.owner}/${options.repo}/runs/${options.runId}/${options.artifactName}`;
570
+ }
571
+
572
+ // packages/studio/src/ingest/file-drop.ts
573
+ var FILE_DROP_ARCHIVE_DIR = ".imported";
574
+ var CI_EXTENSIONS = [".jsonl", ".suite.json"];
575
+ var BUNDLE_EXTENSIONS = [".tgz", ".zip"];
576
+ function classifyFile(fileName) {
577
+ const lower = fileName.toLowerCase();
578
+ if (CI_EXTENSIONS.some((ext) => lower.endsWith(ext))) return "ci";
579
+ if (BUNDLE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return "bundle";
580
+ return void 0;
581
+ }
582
+ async function hashFile(filePath) {
583
+ const data = await readFile(filePath);
584
+ return createHash("sha256").update(data).digest("hex");
585
+ }
586
+ async function importOneFile(options) {
587
+ const destPath = uniqueDestPath(options.destDir, options.fileName, options.contentHash);
588
+ assertPathUnderRoot(destPath, path8.dirname(options.destDir));
589
+ await mkdir(options.destDir, { recursive: true });
590
+ await copyFile(options.sourcePath, destPath);
591
+ insertIngestFile(options.db, {
592
+ sourceKey: options.sourceKey,
593
+ sourceName: options.fileName,
594
+ destPath,
595
+ kind: options.kind,
596
+ contentHash: options.contentHash,
597
+ importedAt: options.importedAt
598
+ });
599
+ let archived = false;
600
+ if (options.archiveAfterImport) {
601
+ await mkdir(options.archiveDir, { recursive: true });
602
+ const archiveTarget = path8.join(options.archiveDir, options.fileName);
603
+ await rename(options.sourcePath, archiveTarget);
604
+ archived = true;
605
+ }
606
+ return {
607
+ sourceKey: options.sourceKey,
608
+ sourceName: options.fileName,
609
+ destPath,
610
+ kind: options.kind,
611
+ contentHash: options.contentHash,
612
+ archived
613
+ };
614
+ }
615
+ async function importFileDrop(options) {
616
+ const warnings = [];
617
+ const errors = [];
618
+ const files = [];
619
+ if (!options.enabled) {
620
+ return {
621
+ skipped: true,
622
+ reason: "file-drop ingest is disabled; pass --ingest file-drop or use studio import drop",
623
+ scanned: 0,
624
+ imported: 0,
625
+ skippedFiles: 0,
626
+ errors,
627
+ warnings,
628
+ files
629
+ };
630
+ }
631
+ const dirs = resolveImportDirs(options.registryPath, options.registry);
632
+ let dropDir;
633
+ try {
634
+ dropDir = options.dropDir ? path8.isAbsolute(options.dropDir) ? (assertPathUnderRoot(options.dropDir, dirs.registryDir), options.dropDir) : resolveUnderRoot(dirs.registryDir, options.dropDir) : dirs.fileDropDir;
635
+ assertPathUnderRoot(dropDir, dirs.registryDir);
636
+ } catch (error) {
637
+ const message = error instanceof Error ? error.message : String(error);
638
+ return {
639
+ skipped: false,
640
+ scanned: 0,
641
+ imported: 0,
642
+ skippedFiles: 0,
643
+ errors: [message],
644
+ warnings,
645
+ files
646
+ };
647
+ }
648
+ let entries;
649
+ try {
650
+ entries = await readdir(dropDir);
651
+ } catch (error) {
652
+ const message = error instanceof Error ? error.message : String(error);
653
+ return {
654
+ skipped: false,
655
+ scanned: 0,
656
+ imported: 0,
657
+ skippedFiles: 0,
658
+ errors: [`unable to read file-drop directory: ${message}`],
659
+ warnings,
660
+ files
661
+ };
662
+ }
663
+ const importedAt = (/* @__PURE__ */ new Date()).toISOString();
664
+ const archiveDir = path8.join(dropDir, FILE_DROP_ARCHIVE_DIR);
665
+ let scanned = 0;
666
+ let imported = 0;
667
+ let skippedFiles = 0;
668
+ for (const entry of entries.sort()) {
669
+ if (entry === FILE_DROP_ARCHIVE_DIR || entry.startsWith(".")) continue;
670
+ const sourcePath = path8.join(dropDir, entry);
671
+ let fileStat;
672
+ try {
673
+ fileStat = await stat(sourcePath);
674
+ } catch {
675
+ warnings.push(`skipped unreadable entry: ${entry}`);
676
+ continue;
677
+ }
678
+ if (!fileStat.isFile()) continue;
679
+ const kind = classifyFile(entry);
680
+ if (!kind) continue;
681
+ scanned += 1;
682
+ const sourceKey = entry;
683
+ let contentHash;
684
+ try {
685
+ contentHash = await hashFile(sourcePath);
686
+ } catch (error) {
687
+ const message = error instanceof Error ? error.message : String(error);
688
+ errors.push(`failed to read ${entry}: ${message}`);
689
+ continue;
690
+ }
691
+ const existing = findIngestFileBySourceKey(options.db, sourceKey);
692
+ if (existing && existing.contentHash === contentHash) {
693
+ skippedFiles += 1;
694
+ continue;
695
+ }
696
+ const destDir = kind === "ci" ? dirs.ciArtifactsDir : dirs.bundlesDir;
697
+ try {
698
+ const importedFile = await importOneFile({
699
+ db: options.db,
700
+ sourcePath,
701
+ sourceKey,
702
+ fileName: entry,
703
+ kind,
704
+ destDir,
705
+ archiveAfterImport: options.archiveAfterImport === true,
706
+ archiveDir,
707
+ importedAt,
708
+ contentHash
709
+ });
710
+ files.push(importedFile);
711
+ imported += 1;
712
+ } catch (error) {
713
+ const message = error instanceof Error ? error.message : String(error);
714
+ errors.push(`failed to import ${entry}: ${message}`);
715
+ }
716
+ }
717
+ return {
718
+ skipped: false,
719
+ scanned,
720
+ imported,
721
+ skippedFiles,
722
+ errors,
723
+ warnings,
724
+ files
725
+ };
726
+ }
727
+ async function importFileDropFromRegistry(options) {
728
+ return importFileDrop({
729
+ db: options.db,
730
+ registryPath: options.registryPath,
731
+ registry: options.registry,
732
+ enabled: options.enabled,
733
+ ...options.dropDir !== void 0 ? { dropDir: options.dropDir } : {},
734
+ ...options.archiveAfterImport !== void 0 ? { archiveAfterImport: options.archiveAfterImport } : {}
735
+ });
736
+ }
737
+ async function runStudioFileDropImport(options) {
738
+ const cwd = options.cwd ?? process.cwd();
739
+ const registryPath = await resolveStudioRegistryPath({
740
+ ...options.workspacePath !== void 0 ? { workspacePath: options.workspacePath } : {},
741
+ cwd
742
+ });
743
+ const registryRead = await readStudioRegistryFile(registryPath);
744
+ if (!registryRead.ok || registryRead.registry === void 0) {
745
+ throw new Error(registryRead.errors.join("; ") || "invalid studio registry");
746
+ }
747
+ const dbPath = resolveStudioDbPath({
748
+ ...options.dbPath !== void 0 ? { dbPath: options.dbPath } : {},
749
+ cwd
750
+ });
751
+ const db = openStudioDb(dbPath);
752
+ return importFileDropFromRegistry({
753
+ db,
754
+ registryPath,
755
+ registry: registryRead.registry,
756
+ enabled: true,
757
+ ...options.dropDir !== void 0 ? { dropDir: options.dropDir } : {},
758
+ ...options.archiveAfterImport !== void 0 ? { archiveAfterImport: options.archiveAfterImport } : {}
759
+ });
371
760
  }
372
761
 
373
762
  // packages/studio/src/context.ts
@@ -391,12 +780,23 @@ async function createStudioContext(options = {}) {
391
780
  registry: registryRead.registry,
392
781
  registryPath
393
782
  });
783
+ let fileDropResult;
784
+ if (options.ingestFileDrop === true) {
785
+ fileDropResult = await importFileDropFromRegistry({
786
+ db,
787
+ registryPath,
788
+ registry: registryRead.registry,
789
+ enabled: true,
790
+ ...options.archiveFileDrop === true ? { archiveAfterImport: true } : {}
791
+ });
792
+ }
394
793
  return {
395
794
  db,
396
795
  dbPath,
397
796
  registryPath,
398
797
  registry: registryRead.registry,
399
798
  importResult,
799
+ ...fileDropResult !== void 0 ? { fileDropResult } : {},
400
800
  projects: importResult.projects
401
801
  };
402
802
  }
@@ -410,6 +810,158 @@ function summarizeProjects(projects) {
410
810
  importedAt: project.importedAt
411
811
  }));
412
812
  }
813
+ var DEFAULT_INGEST_TOKEN_ENV = "STUDIO_INGEST_TOKEN";
814
+ function resolveIngestTokenEnv(options) {
815
+ const envName = options.tokenEnv?.trim() || options.registryTokenEnv?.trim() || DEFAULT_INGEST_TOKEN_ENV;
816
+ if (!/^[A-Z][A-Z0-9_]*$/.test(envName)) {
817
+ throw new Error("ingest token env name must be an uppercase identifier");
818
+ }
819
+ return envName;
820
+ }
821
+ function resolveIngestToken(envName) {
822
+ const token = process.env[envName]?.trim();
823
+ return token && token.length > 0 ? token : void 0;
824
+ }
825
+ function extractIngestTokenFromRequest(headers) {
826
+ const headerToken = firstHeader(headers["x-agentinspect-token"]) ?? firstHeader(headers["x-agent-inspect-token"]);
827
+ if (headerToken) return headerToken;
828
+ const auth = firstHeader(headers.authorization);
829
+ if (auth?.startsWith("Bearer ")) {
830
+ const token = auth.slice("Bearer ".length).trim();
831
+ return token.length > 0 ? token : void 0;
832
+ }
833
+ return void 0;
834
+ }
835
+ function firstHeader(value) {
836
+ if (Array.isArray(value)) return value[0]?.trim();
837
+ return value?.trim();
838
+ }
839
+ function isIngestTokenValid(provided, expected) {
840
+ if (!provided || !expected) return false;
841
+ const providedBuf = Buffer.from(provided);
842
+ const expectedBuf = Buffer.from(expected);
843
+ if (providedBuf.length !== expectedBuf.length) return false;
844
+ return timingSafeEqual(providedBuf, expectedBuf);
845
+ }
846
+
847
+ // packages/studio/src/ingest/http.ts
848
+ var DEFAULT_HTTP_INGEST_BASE_PATH = "/api/ingest";
849
+ var HTTP_INGEST_BUNDLE_PATH = "/api/ingest/bundle";
850
+ var HTTP_INGEST_ARTIFACT_PATH = "/api/ingest/artifact";
851
+ var DEFAULT_MAX_INGEST_BYTES = 52428800;
852
+ function resolveHttpIngestConfig(options, registryEnabled) {
853
+ const http = options.context?.registry.ingest?.http;
854
+ const enabled = options.ingestHttp === true || registryEnabled === true || http?.enabled === true;
855
+ const basePath = (http?.path ?? DEFAULT_HTTP_INGEST_BASE_PATH).trim() || DEFAULT_HTTP_INGEST_BASE_PATH;
856
+ const tokenEnv = resolveIngestTokenEnv({
857
+ ...options.ingestTokenEnv !== void 0 ? { tokenEnv: options.ingestTokenEnv } : {},
858
+ ...http?.tokenEnv !== void 0 ? { registryTokenEnv: http.tokenEnv } : {}
859
+ });
860
+ const maxBytes = http?.maxBytes ?? DEFAULT_MAX_INGEST_BYTES;
861
+ return { enabled, basePath, tokenEnv, maxBytes };
862
+ }
863
+ function sendJson(res, status, body) {
864
+ res.writeHead(status, {
865
+ "content-type": "application/json; charset=utf-8",
866
+ "cache-control": "no-store"
867
+ });
868
+ res.end(JSON.stringify(body));
869
+ }
870
+ async function readBoundedRequestBody(req, maxBytes) {
871
+ const chunks = [];
872
+ let total = 0;
873
+ for await (const chunk of req) {
874
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
875
+ total += buf.length;
876
+ if (total > maxBytes) {
877
+ throw new Error("request body exceeds size limit");
878
+ }
879
+ chunks.push(buf);
880
+ }
881
+ return Buffer.concat(chunks);
882
+ }
883
+ function isHttpIngestRoute(pathname, config) {
884
+ return pathname === HTTP_INGEST_BUNDLE_PATH || pathname === HTTP_INGEST_ARTIFACT_PATH || pathname === `${config.basePath}/bundle` || pathname === `${config.basePath}/artifact`;
885
+ }
886
+ async function handleHttpIngestRequest(req, res, ctx, options, pathname) {
887
+ const config = resolveHttpIngestConfig(options, ctx.registry.ingest?.http?.enabled);
888
+ if (!isHttpIngestRoute(pathname, config)) return false;
889
+ if (!config.enabled) {
890
+ sendJson(res, 404, { error: "HTTP ingest is disabled" });
891
+ return true;
892
+ }
893
+ if (req.method !== "POST") {
894
+ sendJson(res, 405, { error: "Method not allowed" });
895
+ return true;
896
+ }
897
+ const expectedToken = resolveIngestToken(config.tokenEnv);
898
+ const providedToken = extractIngestTokenFromRequest(req.headers);
899
+ if (!isIngestTokenValid(providedToken, expectedToken)) {
900
+ sendJson(res, 403, { error: "Invalid or missing ingest token" });
901
+ return true;
902
+ }
903
+ let body;
904
+ try {
905
+ body = await readBoundedRequestBody(req, config.maxBytes);
906
+ } catch (error) {
907
+ const message = error instanceof Error ? error.message : String(error);
908
+ const status = message.includes("size limit") ? 413 : 400;
909
+ sendJson(res, status, { error: sanitizeSafeErrorMessage(message, expectedToken) });
910
+ return true;
911
+ }
912
+ if (body.length === 0) {
913
+ sendJson(res, 400, { error: "Empty request body" });
914
+ return true;
915
+ }
916
+ try {
917
+ const isBundle = pathname === HTTP_INGEST_BUNDLE_PATH || pathname === `${config.basePath}/bundle`;
918
+ const isArtifact = pathname === HTTP_INGEST_ARTIFACT_PATH || pathname === `${config.basePath}/artifact`;
919
+ if (!isBundle && !isArtifact) {
920
+ sendJson(res, 404, { error: "Unknown ingest route" });
921
+ return true;
922
+ }
923
+ const dirs = resolveImportDirs(ctx.registryPath, ctx.registry);
924
+ const contentHash = createHash("sha256").update(body).digest("hex");
925
+ const importedAt = (/* @__PURE__ */ new Date()).toISOString();
926
+ const fileName = isBundle ? `http-bundle-${contentHash.slice(0, 8)}.bin` : `http-artifact-${contentHash.slice(0, 8)}.zip`;
927
+ const destDir = isBundle ? dirs.bundlesDir : dirs.ciArtifactsDir;
928
+ const destPath = uniqueDestPath(destDir, fileName, contentHash);
929
+ assertPathUnderRoot(destPath, dirs.registryDir);
930
+ await mkdir(destDir, { recursive: true });
931
+ await writeFile(destPath, body);
932
+ const sourceKey = isBundle ? `http:bundle:${contentHash}` : buildGitHubArtifactSourceKey({
933
+ owner: "http",
934
+ repo: "ingest",
935
+ runId: importedAt,
936
+ artifactName: fileName
937
+ });
938
+ insertIngestFile(ctx.db, {
939
+ sourceKey,
940
+ sourceName: fileName,
941
+ destPath,
942
+ kind: isBundle ? "bundle" : "ci",
943
+ contentHash,
944
+ importedAt
945
+ });
946
+ const registryImport = await importStudioRegistry({
947
+ db: ctx.db,
948
+ registry: ctx.registry,
949
+ registryPath: ctx.registryPath
950
+ });
951
+ sendJson(res, 200, {
952
+ ok: true,
953
+ imported: true,
954
+ kind: isBundle ? "bundle" : "artifact",
955
+ destPath,
956
+ warnings: registryImport.warnings
957
+ });
958
+ return true;
959
+ } catch (error) {
960
+ const message = error instanceof Error ? error.message : String(error);
961
+ sendJson(res, 500, { error: sanitizeSafeErrorMessage(message, expectedToken) });
962
+ return true;
963
+ }
964
+ }
413
965
 
414
966
  // packages/studio/src/html.ts
415
967
  var studioIndexHtml = `<!DOCTYPE html>
@@ -455,7 +1007,7 @@ function getImportedProject(db, projects, projectId) {
455
1007
  async function loadTraceDirMetas(workspaceDir, traceDirs) {
456
1008
  const metas = [];
457
1009
  for (const rel of traceDirs) {
458
- const traceDir = resolveTraceDir({ dir: path5.join(workspaceDir, rel) });
1010
+ const traceDir = resolveTraceDir({ dir: path8.join(workspaceDir, rel) });
459
1011
  const td = new TraceDirectory({ dir: traceDir });
460
1012
  const files = await td.list();
461
1013
  const listed = await loadTraceMetadataList(
@@ -484,7 +1036,7 @@ async function loadProjectSuitesView(ctx) {
484
1036
  try {
485
1037
  const result = await runSuite({
486
1038
  configPath,
487
- cwd: path5.dirname(configPath)
1039
+ cwd: path8.dirname(configPath)
488
1040
  });
489
1041
  suites.push({
490
1042
  suiteName: result.suiteName,
@@ -502,7 +1054,7 @@ async function loadProjectSuitesView(ctx) {
502
1054
  } catch (error) {
503
1055
  const message = error instanceof Error ? error.message : String(error);
504
1056
  suites.push({
505
- suiteName: path5.basename(configPath),
1057
+ suiteName: path8.basename(configPath),
506
1058
  configPath,
507
1059
  ok: false,
508
1060
  status: "error",
@@ -551,7 +1103,7 @@ async function loadProjectSearchView(ctx, db, params) {
551
1103
  }
552
1104
  const metas = await loadTraceDirMetas(ctx.project.workspaceDir, ["runs"]);
553
1105
  const traceDir = resolveTraceDir({
554
- dir: path5.join(ctx.project.workspaceDir, "runs")
1106
+ dir: path8.join(ctx.project.workspaceDir, "runs")
555
1107
  });
556
1108
  const results = await searchTraces(metas, {
557
1109
  traceDir,
@@ -595,12 +1147,12 @@ async function loadProjectDiffView(ctx, params) {
595
1147
  };
596
1148
  }
597
1149
  async function loadProjectReportsView(ctx) {
598
- const reportsDir = path5.join(ctx.project.workspaceDir, "reports");
1150
+ const reportsDir = path8.join(ctx.project.workspaceDir, "reports");
599
1151
  const reports = [];
600
1152
  try {
601
1153
  const files = await readdir(reportsDir);
602
1154
  for (const file of files) {
603
- const filePath = path5.join(reportsDir, file);
1155
+ const filePath = path8.join(reportsDir, file);
604
1156
  const info = await stat(filePath);
605
1157
  if (!info.isFile()) continue;
606
1158
  reports.push({ name: file, path: filePath, sizeBytes: info.size });
@@ -655,7 +1207,7 @@ async function loadBundleExportView(ctx, params) {
655
1207
  runId,
656
1208
  readOnly: true,
657
1209
  redactionProfile: ctx.project.redactionProfile ?? "share",
658
- cliHint: `npx agent-inspect bundle ${runId} --profile ${ctx.project.redactionProfile ?? "share"} --dir ${path5.join(ctx.project.workspaceDir, "runs")}`,
1210
+ cliHint: `npx agent-inspect bundle ${runId} --profile ${ctx.project.redactionProfile ?? "share"} --dir ${path8.join(ctx.project.workspaceDir, "runs")}`,
659
1211
  note: "Studio does not mutate traces or upload bundles. Run the CLI locally to assemble a share-safe bundle."
660
1212
  };
661
1213
  }
@@ -699,7 +1251,7 @@ function studioAuthRequiredResponse() {
699
1251
  }
700
1252
 
701
1253
  // packages/studio/src/routes.ts
702
- function sendJson(res, status, body, headers = {}) {
1254
+ function sendJson2(res, status, body, headers = {}) {
703
1255
  const payload = JSON.stringify(body);
704
1256
  res.writeHead(status, {
705
1257
  "content-type": "application/json; charset=utf-8",
@@ -709,7 +1261,7 @@ function sendJson(res, status, body, headers = {}) {
709
1261
  res.end(payload);
710
1262
  }
711
1263
  function notFound(res, message) {
712
- sendJson(res, 404, { error: message });
1264
+ sendJson2(res, 404, { error: message });
713
1265
  }
714
1266
  function decodeSegment(segment) {
715
1267
  if (!segment) return "";
@@ -722,11 +1274,11 @@ function decodeSegment(segment) {
722
1274
  async function handleStudioRoute(req, res, ctx, options, pathname, url) {
723
1275
  if (!isStudioRequestAuthorized(req, options)) {
724
1276
  const auth = studioAuthRequiredResponse();
725
- sendJson(res, auth.status, auth.body, auth.headers);
1277
+ sendJson2(res, auth.status, auth.body, auth.headers);
726
1278
  return true;
727
1279
  }
728
1280
  if (pathname === "/api/health") {
729
- sendJson(res, 200, {
1281
+ sendJson2(res, 200, {
730
1282
  ok: true,
731
1283
  readOnly: true,
732
1284
  mode: "studio",
@@ -739,7 +1291,7 @@ async function handleStudioRoute(req, res, ctx, options, pathname, url) {
739
1291
  return true;
740
1292
  }
741
1293
  if (pathname === "/api/projects") {
742
- sendJson(res, 200, {
1294
+ sendJson2(res, 200, {
743
1295
  registryName: ctx.registry.name,
744
1296
  projects: summarizeProjects(ctx.projects),
745
1297
  warnings: ctx.importResult.warnings
@@ -756,56 +1308,56 @@ async function handleStudioRoute(req, res, ctx, options, pathname, url) {
756
1308
  return true;
757
1309
  }
758
1310
  if (subpath === "runs" || subpath === "") {
759
- sendJson(res, 200, {
1311
+ sendJson2(res, 200, {
760
1312
  projectId,
761
1313
  runs: await loadProjectRunsView(projectCtx, ctx.db)
762
1314
  });
763
1315
  return true;
764
1316
  }
765
1317
  if (subpath === "sessions") {
766
- sendJson(res, 200, {
1318
+ sendJson2(res, 200, {
767
1319
  projectId,
768
1320
  ...await loadProjectSessionsView(projectCtx)
769
1321
  });
770
1322
  return true;
771
1323
  }
772
1324
  if (subpath === "suites") {
773
- sendJson(res, 200, {
1325
+ sendJson2(res, 200, {
774
1326
  projectId,
775
1327
  ...await loadProjectSuitesView(projectCtx)
776
1328
  });
777
1329
  return true;
778
1330
  }
779
1331
  if (subpath === "checks") {
780
- sendJson(res, 200, {
1332
+ sendJson2(res, 200, {
781
1333
  projectId,
782
1334
  ...await loadProjectChecksView(projectCtx)
783
1335
  });
784
1336
  return true;
785
1337
  }
786
1338
  if (subpath === "observations") {
787
- sendJson(res, 200, {
1339
+ sendJson2(res, 200, {
788
1340
  projectId,
789
1341
  ...await loadProjectObservationsView(projectCtx)
790
1342
  });
791
1343
  return true;
792
1344
  }
793
1345
  if (subpath === "guardrails") {
794
- sendJson(res, 200, {
1346
+ sendJson2(res, 200, {
795
1347
  projectId,
796
1348
  ...await loadProjectGuardrailsView(projectCtx)
797
1349
  });
798
1350
  return true;
799
1351
  }
800
1352
  if (subpath === "redaction") {
801
- sendJson(res, 200, {
1353
+ sendJson2(res, 200, {
802
1354
  projectId,
803
1355
  ...await loadProjectRedactionView(projectCtx)
804
1356
  });
805
1357
  return true;
806
1358
  }
807
1359
  if (subpath === "reports") {
808
- sendJson(res, 200, {
1360
+ sendJson2(res, 200, {
809
1361
  projectId,
810
1362
  ...await loadProjectReportsView(projectCtx)
811
1363
  });
@@ -817,7 +1369,7 @@ async function handleStudioRoute(req, res, ctx, options, pathname, url) {
817
1369
  if (pathname === "/api/search") {
818
1370
  const projectId = url.searchParams.get("projectId");
819
1371
  if (!projectId) {
820
- sendJson(res, 400, { error: "projectId query parameter is required." });
1372
+ sendJson2(res, 400, { error: "projectId query parameter is required." });
821
1373
  return true;
822
1374
  }
823
1375
  const projectCtx = getImportedProject(ctx.db, ctx.projects, projectId);
@@ -825,7 +1377,7 @@ async function handleStudioRoute(req, res, ctx, options, pathname, url) {
825
1377
  notFound(res, `Project not found: ${projectId}`);
826
1378
  return true;
827
1379
  }
828
- sendJson(res, 200, {
1380
+ sendJson2(res, 200, {
829
1381
  projectId,
830
1382
  ...await loadProjectSearchView(projectCtx, ctx.db, url.searchParams)
831
1383
  });
@@ -834,7 +1386,7 @@ async function handleStudioRoute(req, res, ctx, options, pathname, url) {
834
1386
  if (pathname === "/api/diff") {
835
1387
  const projectId = url.searchParams.get("projectId");
836
1388
  if (!projectId) {
837
- sendJson(res, 400, { error: "projectId query parameter is required." });
1389
+ sendJson2(res, 400, { error: "projectId query parameter is required." });
838
1390
  return true;
839
1391
  }
840
1392
  const projectCtx = getImportedProject(ctx.db, ctx.projects, projectId);
@@ -843,20 +1395,20 @@ async function handleStudioRoute(req, res, ctx, options, pathname, url) {
843
1395
  return true;
844
1396
  }
845
1397
  try {
846
- sendJson(res, 200, {
1398
+ sendJson2(res, 200, {
847
1399
  projectId,
848
1400
  ...await loadProjectDiffView(projectCtx, url.searchParams)
849
1401
  });
850
1402
  } catch (error) {
851
1403
  const message = error instanceof Error ? error.message : String(error);
852
- sendJson(res, 400, { error: message });
1404
+ sendJson2(res, 400, { error: message });
853
1405
  }
854
1406
  return true;
855
1407
  }
856
1408
  if (pathname === "/api/reports") {
857
1409
  const projectId = url.searchParams.get("projectId");
858
1410
  if (!projectId) {
859
- sendJson(res, 400, { error: "projectId query parameter is required." });
1411
+ sendJson2(res, 400, { error: "projectId query parameter is required." });
860
1412
  return true;
861
1413
  }
862
1414
  const projectCtx = getImportedProject(ctx.db, ctx.projects, projectId);
@@ -864,7 +1416,7 @@ async function handleStudioRoute(req, res, ctx, options, pathname, url) {
864
1416
  notFound(res, `Project not found: ${projectId}`);
865
1417
  return true;
866
1418
  }
867
- sendJson(res, 200, {
1419
+ sendJson2(res, 200, {
868
1420
  projectId,
869
1421
  ...await loadProjectReportsView(projectCtx)
870
1422
  });
@@ -873,7 +1425,7 @@ async function handleStudioRoute(req, res, ctx, options, pathname, url) {
873
1425
  if (pathname === "/api/bundles/export") {
874
1426
  const projectId = url.searchParams.get("projectId");
875
1427
  if (!projectId) {
876
- sendJson(res, 400, { error: "projectId query parameter is required." });
1428
+ sendJson2(res, 400, { error: "projectId query parameter is required." });
877
1429
  return true;
878
1430
  }
879
1431
  const projectCtx = getImportedProject(ctx.db, ctx.projects, projectId);
@@ -882,10 +1434,10 @@ async function handleStudioRoute(req, res, ctx, options, pathname, url) {
882
1434
  return true;
883
1435
  }
884
1436
  try {
885
- sendJson(res, 200, await loadBundleExportView(projectCtx, url.searchParams));
1437
+ sendJson2(res, 200, await loadBundleExportView(projectCtx, url.searchParams));
886
1438
  } catch (error) {
887
1439
  const message = error instanceof Error ? error.message : String(error);
888
- sendJson(res, 400, { error: message });
1440
+ sendJson2(res, 400, { error: message });
889
1441
  }
890
1442
  return true;
891
1443
  }
@@ -924,15 +1476,21 @@ function createStudioServer(options = {}) {
924
1476
  }
925
1477
  const server = createServer(async (req, res) => {
926
1478
  try {
927
- if (req.method !== "GET" && req.method !== "HEAD") {
928
- return badRequest(res, "Only GET is supported.");
929
- }
1479
+ const method = req.method ?? "GET";
1480
+ const url = new URL(req.url ?? "/", `http://${host}:${port}`);
1481
+ const pathname = url.pathname;
930
1482
  if (!contextPromise) {
931
1483
  contextPromise = createStudioContext(options);
932
1484
  }
933
1485
  const ctx = await contextPromise;
934
- const url = new URL(req.url ?? "/", `http://${host}:${port}`);
935
- const pathname = url.pathname;
1486
+ const httpConfig = resolveHttpIngestConfig(options, ctx.registry.ingest?.http?.enabled);
1487
+ if (isHttpIngestRoute(pathname, httpConfig)) {
1488
+ const handled2 = await handleHttpIngestRequest(req, res, ctx, options, pathname);
1489
+ if (handled2) return;
1490
+ }
1491
+ if (method !== "GET" && method !== "HEAD") {
1492
+ return badRequest(res, "Only GET is supported.");
1493
+ }
936
1494
  if (pathname === "/" || pathname === "/index.html") {
937
1495
  if (req.method === "HEAD") {
938
1496
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
@@ -985,7 +1543,355 @@ async function startStudioServer(options = {}) {
985
1543
  });
986
1544
  });
987
1545
  }
1546
+ var DEFAULT_GITHUB_TOKEN_ENV = "GITHUB_TOKEN";
1547
+ var GITHUB_API_BASE = "https://api.github.com";
1548
+ var MAX_ARTIFACT_BYTES = 52428800;
1549
+ function resolveTokenEnv(registry, override) {
1550
+ const fromRegistry = registry.ingest?.github?.tokenEnv?.trim();
1551
+ const envName = override?.trim() || fromRegistry || DEFAULT_GITHUB_TOKEN_ENV;
1552
+ if (!/^[A-Z][A-Z0-9_]*$/.test(envName)) {
1553
+ throw new Error("token env name must be an uppercase identifier");
1554
+ }
1555
+ return envName;
1556
+ }
1557
+ function resolveToken(envName) {
1558
+ const token = process.env[envName]?.trim();
1559
+ if (!token) {
1560
+ throw new Error(`missing GitHub token in environment variable ${envName}`);
1561
+ }
1562
+ return token;
1563
+ }
1564
+ function githubHeaders(token) {
1565
+ return {
1566
+ Authorization: `Bearer ${token}`,
1567
+ Accept: "application/vnd.github+json",
1568
+ "X-GitHub-Api-Version": "2022-11-28",
1569
+ "User-Agent": "agent-inspect-studio-ingest"
1570
+ };
1571
+ }
1572
+ async function readResponseBody(response, maxBytes) {
1573
+ const lengthHeader = response.headers.get("content-length");
1574
+ if (lengthHeader) {
1575
+ const length = Number(lengthHeader);
1576
+ if (Number.isFinite(length) && length > maxBytes) {
1577
+ throw new Error("artifact exceeds size limit");
1578
+ }
1579
+ }
1580
+ const arrayBuffer = await response.arrayBuffer();
1581
+ if (arrayBuffer.byteLength > maxBytes) {
1582
+ throw new Error("artifact exceeds size limit");
1583
+ }
1584
+ return Buffer.from(arrayBuffer);
1585
+ }
1586
+ async function downloadGitHubArtifactArchive(options) {
1587
+ const fetchFn = options.fetchImpl ?? fetch;
1588
+ const { owner, name } = parseGitHubRepo(options.repo);
1589
+ const runId = options.runId.trim();
1590
+ const artifactName = options.artifactName.trim();
1591
+ if (!/^\d+$/.test(runId)) {
1592
+ throw new Error("run-id must be a numeric workflow run id");
1593
+ }
1594
+ if (artifactName === "") {
1595
+ throw new Error("artifact name must be non-empty");
1596
+ }
1597
+ const listUrl = `${GITHUB_API_BASE}/repos/${owner}/${name}/actions/runs/${runId}/artifacts`;
1598
+ const listResponse = await fetchFn(listUrl, {
1599
+ headers: githubHeaders(options.token)
1600
+ });
1601
+ if (!listResponse.ok) {
1602
+ throw new Error(`GitHub artifact lookup failed (${listResponse.status})`);
1603
+ }
1604
+ const payload = await listResponse.json();
1605
+ const artifact = payload.artifacts?.find((item) => item.name === artifactName);
1606
+ if (!artifact?.archive_download_url) {
1607
+ throw new Error(`artifact not found for run ${runId}: ${artifactName}`);
1608
+ }
1609
+ if (artifact.expired === true) {
1610
+ throw new Error(`artifact expired for run ${runId}: ${artifactName}`);
1611
+ }
1612
+ if (typeof artifact.size_in_bytes === "number" && artifact.size_in_bytes > MAX_ARTIFACT_BYTES) {
1613
+ throw new Error("artifact exceeds size limit");
1614
+ }
1615
+ const downloadResponse = await fetchFn(artifact.archive_download_url, {
1616
+ headers: githubHeaders(options.token),
1617
+ redirect: "follow"
1618
+ });
1619
+ if (!downloadResponse.ok) {
1620
+ throw new Error(`GitHub artifact download failed (${downloadResponse.status})`);
1621
+ }
1622
+ return readResponseBody(downloadResponse, MAX_ARTIFACT_BYTES);
1623
+ }
1624
+ async function importGitHubArtifact(options) {
1625
+ const registryImportWarnings = [];
1626
+ const errors = [];
1627
+ if (!options.enabled) {
1628
+ return {
1629
+ skipped: true,
1630
+ reason: "GitHub artifact ingest is disabled; use studio import github explicitly",
1631
+ imported: false,
1632
+ registryImportWarnings,
1633
+ errors
1634
+ };
1635
+ }
1636
+ let token;
1637
+ let tokenEnv;
1638
+ try {
1639
+ const { owner, name } = parseGitHubRepo(options.repo);
1640
+ tokenEnv = resolveTokenEnv(options.registry, options.tokenEnv);
1641
+ token = resolveToken(tokenEnv);
1642
+ const sourceKey = buildGitHubArtifactSourceKey({
1643
+ owner,
1644
+ repo: name,
1645
+ runId: options.runId,
1646
+ artifactName: options.artifactName
1647
+ });
1648
+ const archive = await downloadGitHubArtifactArchive({
1649
+ repo: options.repo,
1650
+ runId: options.runId,
1651
+ artifactName: options.artifactName,
1652
+ token,
1653
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {}
1654
+ });
1655
+ const contentHash = createHash("sha256").update(archive).digest("hex");
1656
+ const existing = findIngestFileBySourceKey(options.db, sourceKey);
1657
+ if (existing && existing.contentHash === contentHash) {
1658
+ return {
1659
+ skipped: false,
1660
+ imported: false,
1661
+ sourceKey,
1662
+ contentHash,
1663
+ destPath: existing.destPath,
1664
+ registryImportWarnings,
1665
+ errors
1666
+ };
1667
+ }
1668
+ const dirs = resolveImportDirs(options.registryPath, options.registry);
1669
+ const fileName = `${options.artifactName}.zip`;
1670
+ const destPath = uniqueDestPath(dirs.bundlesDir, fileName, contentHash);
1671
+ assertPathUnderRoot(destPath, dirs.registryDir);
1672
+ await mkdir(dirs.bundlesDir, { recursive: true });
1673
+ await writeFile(destPath, archive);
1674
+ const importedAt = (/* @__PURE__ */ new Date()).toISOString();
1675
+ insertIngestFile(options.db, {
1676
+ sourceKey,
1677
+ sourceName: fileName,
1678
+ destPath,
1679
+ kind: "bundle",
1680
+ contentHash,
1681
+ importedAt
1682
+ });
1683
+ const registryImport = await importStudioRegistry({
1684
+ db: options.db,
1685
+ registry: options.registry,
1686
+ registryPath: options.registryPath
1687
+ });
1688
+ registryImportWarnings.push(...registryImport.warnings);
1689
+ return {
1690
+ skipped: false,
1691
+ imported: true,
1692
+ sourceKey,
1693
+ contentHash,
1694
+ destPath,
1695
+ registryImportWarnings,
1696
+ errors
1697
+ };
1698
+ } catch (error) {
1699
+ const message = error instanceof Error ? error.message : String(error);
1700
+ errors.push(sanitizeSafeErrorMessage(message, token));
1701
+ return {
1702
+ skipped: false,
1703
+ imported: false,
1704
+ registryImportWarnings,
1705
+ errors
1706
+ };
1707
+ }
1708
+ }
1709
+ async function runStudioGitHubArtifactImport(options) {
1710
+ const cwd = options.cwd ?? process.cwd();
1711
+ const registryPath = await resolveStudioRegistryPath({
1712
+ ...options.workspacePath !== void 0 ? { workspacePath: options.workspacePath } : {},
1713
+ cwd
1714
+ });
1715
+ const registryRead = await readStudioRegistryFile(registryPath);
1716
+ if (!registryRead.ok || registryRead.registry === void 0) {
1717
+ throw new Error(registryRead.errors.join("; ") || "invalid studio registry");
1718
+ }
1719
+ const dbPath = resolveStudioDbPath({
1720
+ ...options.dbPath !== void 0 ? { dbPath: options.dbPath } : {},
1721
+ cwd
1722
+ });
1723
+ const db = openStudioDb(dbPath);
1724
+ return importGitHubArtifact({
1725
+ db,
1726
+ registryPath,
1727
+ registry: registryRead.registry,
1728
+ repo: options.repo,
1729
+ runId: options.runId,
1730
+ artifactName: options.artifact,
1731
+ enabled: true,
1732
+ ...options.tokenEnv !== void 0 ? { tokenEnv: options.tokenEnv } : {},
1733
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {}
1734
+ });
1735
+ }
1736
+ async function pathExists(filePath) {
1737
+ try {
1738
+ await access(filePath);
1739
+ return true;
1740
+ } catch {
1741
+ return false;
1742
+ }
1743
+ }
1744
+ async function validateBundleDirectory(bundleDir) {
1745
+ const errors = [];
1746
+ const metadataPath = path8.join(bundleDir, "metadata.json");
1747
+ if (!await pathExists(metadataPath)) {
1748
+ errors.push("bundle missing metadata.json");
1749
+ return errors;
1750
+ }
1751
+ try {
1752
+ const raw = await readFile(metadataPath, "utf8");
1753
+ const parsed = JSON.parse(raw);
1754
+ if (typeof parsed.agentInspectVersion !== "string") {
1755
+ errors.push("bundle metadata.json missing agentInspectVersion");
1756
+ }
1757
+ if (!Array.isArray(parsed.runIds) || parsed.runIds.length === 0) {
1758
+ errors.push("bundle metadata.json missing runIds");
1759
+ }
1760
+ } catch {
1761
+ errors.push("bundle metadata.json is invalid JSON");
1762
+ }
1763
+ return errors;
1764
+ }
1765
+ async function copyDirectoryRecursive(sourceDir, destDir) {
1766
+ await mkdir(destDir, { recursive: true });
1767
+ const entries = await readdir(sourceDir, { withFileTypes: true });
1768
+ for (const entry of entries) {
1769
+ const from = path8.join(sourceDir, entry.name);
1770
+ const to = path8.join(destDir, entry.name);
1771
+ if (entry.isDirectory()) {
1772
+ await copyDirectoryRecursive(from, to);
1773
+ } else if (entry.isFile()) {
1774
+ await copyFile(from, to);
1775
+ }
1776
+ }
1777
+ }
1778
+ async function importBundleUpload(options) {
1779
+ const registryImportWarnings = [];
1780
+ const errors = [];
1781
+ if (!options.enabled) {
1782
+ return {
1783
+ skipped: true,
1784
+ reason: "bundle upload ingest is disabled; use studio import bundle explicitly",
1785
+ imported: false,
1786
+ errors,
1787
+ registryImportWarnings
1788
+ };
1789
+ }
1790
+ const bundlePath = path8.resolve(options.bundlePath);
1791
+ let bundleStat;
1792
+ try {
1793
+ bundleStat = await stat(bundlePath);
1794
+ } catch {
1795
+ return {
1796
+ skipped: false,
1797
+ imported: false,
1798
+ errors: ["bundle path does not exist"],
1799
+ registryImportWarnings
1800
+ };
1801
+ }
1802
+ if (!bundleStat.isDirectory()) {
1803
+ return {
1804
+ skipped: false,
1805
+ imported: false,
1806
+ errors: ["bundle path must be a directory produced by agent-inspect bundle"],
1807
+ registryImportWarnings
1808
+ };
1809
+ }
1810
+ const validationErrors = await validateBundleDirectory(bundlePath);
1811
+ if (validationErrors.length > 0) {
1812
+ return {
1813
+ skipped: false,
1814
+ imported: false,
1815
+ errors: validationErrors,
1816
+ registryImportWarnings
1817
+ };
1818
+ }
1819
+ const metadataRaw = await readFile(path8.join(bundlePath, "metadata.json"), "utf8");
1820
+ const contentHash = createHash("sha256").update(metadataRaw).digest("hex");
1821
+ const sourceKey = `bundle:${bundlePath}`;
1822
+ const existing = findIngestFileBySourceKey(options.db, sourceKey);
1823
+ if (existing && existing.contentHash === contentHash) {
1824
+ return {
1825
+ skipped: false,
1826
+ imported: false,
1827
+ destPath: existing.destPath,
1828
+ sourceKey,
1829
+ errors,
1830
+ registryImportWarnings
1831
+ };
1832
+ }
1833
+ const dirs = resolveImportDirs(options.registryPath, options.registry);
1834
+ const folderName = path8.basename(bundlePath);
1835
+ const destPath = uniqueDestPath(dirs.bundlesDir, folderName, contentHash);
1836
+ assertPathUnderRoot(destPath, dirs.registryDir);
1837
+ try {
1838
+ await copyDirectoryRecursive(bundlePath, destPath);
1839
+ insertIngestFile(options.db, {
1840
+ sourceKey,
1841
+ sourceName: folderName,
1842
+ destPath,
1843
+ kind: "bundle",
1844
+ contentHash,
1845
+ importedAt: (/* @__PURE__ */ new Date()).toISOString()
1846
+ });
1847
+ const registryImport = await importStudioRegistry({
1848
+ db: options.db,
1849
+ registry: options.registry,
1850
+ registryPath: options.registryPath
1851
+ });
1852
+ registryImportWarnings.push(...registryImport.warnings);
1853
+ return {
1854
+ skipped: false,
1855
+ imported: true,
1856
+ destPath,
1857
+ sourceKey,
1858
+ errors,
1859
+ registryImportWarnings
1860
+ };
1861
+ } catch (error) {
1862
+ const message = error instanceof Error ? error.message : String(error);
1863
+ return {
1864
+ skipped: false,
1865
+ imported: false,
1866
+ errors: [message],
1867
+ registryImportWarnings
1868
+ };
1869
+ }
1870
+ }
1871
+ async function runStudioBundleUploadImport(options) {
1872
+ const cwd = options.cwd ?? process.cwd();
1873
+ const registryPath = await resolveStudioRegistryPath({
1874
+ ...options.workspacePath !== void 0 ? { workspacePath: options.workspacePath } : {},
1875
+ cwd
1876
+ });
1877
+ const registryRead = await readStudioRegistryFile(registryPath);
1878
+ if (!registryRead.ok || registryRead.registry === void 0) {
1879
+ throw new Error(registryRead.errors.join("; ") || "invalid studio registry");
1880
+ }
1881
+ const dbPath = resolveStudioDbPath({
1882
+ ...options.dbPath !== void 0 ? { dbPath: options.dbPath } : {},
1883
+ cwd
1884
+ });
1885
+ const db = openStudioDb(dbPath);
1886
+ return importBundleUpload({
1887
+ db,
1888
+ registryPath,
1889
+ registry: registryRead.registry,
1890
+ bundlePath: options.bundlePath,
1891
+ enabled: true
1892
+ });
1893
+ }
988
1894
 
989
- export { createStudioContext, createStudioServer, parseStudioRegistry, readStudioRegistryFile, startStudioServer, studioIndexHtml, summarizeProjects };
1895
+ export { HTTP_INGEST_ARTIFACT_PATH, HTTP_INGEST_BUNDLE_PATH, createStudioContext, createStudioServer, downloadGitHubArtifactArchive, extractIngestTokenFromRequest, handleHttpIngestRequest, importBundleUpload, importFileDrop, importFileDropFromRegistry, importGitHubArtifact, isHttpIngestRoute, isIngestTokenValid, openStudioDb, parseStudioRegistry, readStudioRegistryFile, resolveHttpIngestConfig, resolveIngestToken, resolveIngestTokenEnv, resolveStudioDbPath, resolveStudioRegistryPath, runStudioBundleUploadImport, runStudioFileDropImport, runStudioGitHubArtifactImport, startStudioServer, studioIndexHtml, summarizeProjects, validateBundleDirectory };
990
1896
  //# sourceMappingURL=index.mjs.map
991
1897
  //# sourceMappingURL=index.mjs.map