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