@axlsdk/studio 0.21.1 → 0.22.1

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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createServer,
3
3
  handleWsMessage
4
- } from "./chunk-YGLWYDQ3.js";
4
+ } from "./chunk-Z374DI7D.js";
5
5
 
6
6
  // src/middleware.ts
7
7
  import { resolve as resolve2, dirname } from "path";
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/middleware.ts","../src/eval-loader.ts"],"sourcesContent":["import { resolve, dirname } from 'node:path';\nimport { existsSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport { getRequestListener } from '@hono/node-server';\nimport { WebSocketServer } from 'ws';\nimport { createServer } from './server/index.js';\nimport { handleWsMessage } from './server/ws/protocol.js';\nimport { createEvalLoader } from './eval-loader.js';\nimport type { EvalLoaderConfig } from './eval-loader.js';\nimport type { BufferCaps } from './server/ws/connection-manager.js';\nimport type { AxlRuntime } from '@axlsdk/axl';\nimport type { IncomingMessage, ServerResponse, Server } from 'node:http';\n\nexport type { EvalLoaderConfig } from './eval-loader.js';\nexport type { BufferCaps } from './server/ws/connection-manager.js';\n\nexport type StudioMiddlewareOptions = {\n /** The AxlRuntime instance to observe and control. */\n runtime: AxlRuntime;\n\n /**\n * URL path prefix where Studio is mounted.\n * Must match the mount path in your framework (Express `app.use()`,\n * Fastify `register()`, Hono `app.route()`, etc.). The framework is\n * expected to strip the prefix from `req.url` before calling the handler.\n *\n * Do not set basePath when using a raw `http.Server` as the root handler —\n * leave it empty and mount at root instead.\n *\n * Must start with '/' when non-empty. Trailing slashes are stripped.\n * Only URL-safe characters allowed: [a-zA-Z0-9/_-]\n *\n * @example '/studio'\n * @example '/admin/studio'\n * @example '' — mounted at root (default)\n */\n basePath?: string;\n\n /**\n * Serve the pre-built Studio SPA.\n * Set to false if serving the client from a CDN or separate build.\n * @default true\n */\n serveClient?: boolean;\n\n /**\n * Verify a WebSocket upgrade request before completing the handshake.\n *\n * Return either a boolean (`true` allow, `false` reject) or an object\n * `{ allowed: boolean, metadata?: unknown }` to ALSO attach per-connection\n * metadata that `filterTraceEvent` can later read — typically the auth\n * subject (userId, tenantId, role). Throw to reject with an error.\n *\n * IMPORTANT: WebSocket upgrades bypass Express/Fastify/Koa middleware.\n * If your HTTP routes are behind auth middleware, WS connections are NOT\n * automatically protected. Use this callback to enforce authentication\n * on WebSocket connections.\n *\n * @example Multi-tenant: extract tenant from JWT, attach as metadata\n * verifyUpgrade: async (req) => {\n * const token = req.headers.authorization?.slice(7);\n * const claims = await verifyJwt(token);\n * if (!claims) return false;\n * return { allowed: true, metadata: { userId: claims.sub, tenantId: claims.tid } };\n * }\n */\n verifyUpgrade?: (\n req: IncomingMessage,\n ) =>\n | boolean\n | { allowed: boolean; metadata?: unknown }\n | Promise<boolean | { allowed: boolean; metadata?: unknown }>;\n\n /**\n * Permit `upgradeWebSocket()` without `verifyUpgrade` in production.\n * This exposes Studio's administrative event channels without SDK-level\n * authentication and should be used only when an outer upgrade gate is\n * independently guaranteed.\n * @default false\n */\n dangerouslyAllowUnauthenticatedWebSockets?: boolean;\n\n /**\n * Optional per-event filter used by multi-tenant deployments to scope the\n * trace firehose. Called on every outbound WebSocket broadcast; return\n * `true` to deliver to this connection, `false` to drop.\n *\n * `event` is the parsed event payload — for the `trace:*` channel this is\n * an `AxlEvent` from `@axlsdk/axl` (narrow via the discriminated union);\n * for `costs` it's a `CostData`; for `execution:*` / `eval:*` it's the\n * legacy stream-event shape (until PR 3 collapses the wire to `AxlEvent`).\n * Typed `unknown` because the filter runs for every channel.\n * `metadata` is the per-connection metadata attached by `verifyUpgrade`,\n * or `undefined` if `verifyUpgrade` returned a bare boolean.\n *\n * Filter errors are treated as `drop` (fail-closed) so a buggy predicate\n * cannot accidentally leak events cross-tenant.\n *\n * @example Scope trace events by tenant id stored on agent metadata\n * import type { AxlEvent } from '@axlsdk/axl';\n *\n * filterTraceEvent: (event, meta) => {\n * const m = meta as { tenantId?: string } | undefined;\n * if (!m?.tenantId) return false;\n * const e = event as AxlEvent;\n * // Only narrow event.type === 'agent_call_end' events; pass structural\n * // events (workflow_start/end, cost updates) through unconditionally.\n * if (e.type !== 'agent_call_end') return true;\n * return e.workflow?.startsWith(`${m.tenantId}:`) ?? false;\n * }\n */\n filterTraceEvent?: (event: unknown, metadata: unknown) => boolean;\n\n /**\n * Disable all mutating endpoints (execute, test, send, delete, resolve).\n * When true, Studio is observation-only.\n * @default false\n */\n readOnly?: boolean;\n\n /**\n * Lazy-load eval files for the Eval Runner panel.\n *\n * Eval files are dynamically imported on first access to eval endpoints,\n * not at middleware construction time. This means:\n * - Zero cost during normal API operation\n * - Eval files can import from any module without creating circular deps\n * in the static module graph (they're loaded as standalone entry points)\n * - `@axlsdk/eval` can remain a devDependency — eval files never enter\n * the production bundle since bundlers can't see dynamic imports\n *\n * Accepts glob patterns, explicit file paths, or an object with\n * `conditions` for monorepo source export resolution.\n *\n * Eval files are loaded once and cached for the middleware's lifetime.\n * Changes to eval files require a server restart.\n *\n * @example\n * // Single glob pattern\n * evals: 'evals/*.eval.ts'\n *\n * @example\n * // Multiple patterns\n * evals: ['libs/api/evals/*.eval.ts', 'libs/ai/evals/*.eval.ts']\n *\n * @example\n * // With import conditions for monorepo source exports\n * evals: {\n * files: 'libs/api/evals/*.eval.ts',\n * conditions: ['development'],\n * }\n */\n evals?: EvalLoaderConfig;\n\n /**\n * Override the default WebSocket replay-buffer resource caps for\n * production deployments under sustained execution churn.\n *\n * Defaults: `maxEventsPerBuffer: 1000`, `maxBytesPerBuffer: 4 MiB`,\n * `maxActiveBuffers: 256`. Worst-case memory is roughly\n * `maxActiveBuffers × maxBytesPerBuffer` (≈1 GiB at defaults). Tighten\n * either dimension to lower the ceiling at the cost of late-subscriber\n * replay coverage; raise them if you need more event headroom on long\n * verbose-mode streams.\n *\n * Terminal `done` / `error` events are always buffered regardless of\n * caps, so a late subscriber to a completed stream still sees the\n * outcome — only intermediate non-terminal events are dropped.\n *\n * All fields are optional; passing `{}` is a no-op.\n *\n * @example\n * // Halve memory ceiling on a high-churn deployment\n * bufferCaps: { maxActiveBuffers: 128, maxBytesPerBuffer: 2 * 1024 * 1024 }\n */\n bufferCaps?: BufferCaps;\n};\n\n/**\n * Minimal contract a WebSocket connection must satisfy.\n * Matches the `ws` library API (de facto standard in Node.js).\n */\nexport interface StudioWebSocket {\n send(data: string): void;\n close(): void;\n on(event: 'message', fn: (data: string | Buffer) => void): void;\n on(event: 'close', fn: () => void): void;\n on(event: 'error', fn: (err: Error) => void): void;\n}\n\n// Re-export for Hono-in-Hono consumers\nexport { handleWsMessage } from './server/ws/protocol.js';\n\nexport type StudioMiddleware = ReturnType<typeof createStudioMiddleware>;\n\nexport function createStudioMiddleware(options: StudioMiddlewareOptions) {\n const {\n runtime,\n serveClient = true,\n verifyUpgrade,\n dangerouslyAllowUnauthenticatedWebSockets = false,\n readOnly = false,\n filterTraceEvent,\n } = options;\n\n // Normalize basePath: strip trailing slashes, validate format\n const basePath = normalizeBasePath(options.basePath);\n\n // Resolve pre-built SPA assets from this package's dist/\n const staticRoot = serveClient ? resolveClientDist() : undefined;\n\n if (serveClient && !staticRoot) {\n const dir =\n import.meta.dirname ??\n (typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url)));\n console.warn(\n '[axl-studio] serveClient is true but no pre-built client found at ' +\n `${resolve(dir, 'client')}. Studio UI will not be available. ` +\n 'Set serveClient: false to suppress this warning.',\n );\n }\n\n // Create lazy eval loader if eval files are configured\n const evalLoader = options.evals ? createEvalLoader(options.evals, runtime) : undefined;\n\n const { app, connMgr, traceListener, closeActiveRuns, closeAggregators } = createServer({\n runtime,\n staticRoot,\n basePath,\n readOnly,\n cors: false, // Host framework owns CORS policy\n evalLoader,\n bufferCaps: options.bufferCaps,\n });\n\n // Install the broadcast filter once at construction time. The connMgr\n // applies it on every outbound event; `metadata` is attached to each\n // connection after a successful `verifyUpgrade`.\n if (filterTraceEvent) {\n connMgr.setFilter(filterTraceEvent);\n }\n\n // Warn early; upgradeWebSocket() also fails closed unless explicitly acknowledged.\n if (\n process.env.NODE_ENV === 'production' &&\n !verifyUpgrade &&\n !dangerouslyAllowUnauthenticatedWebSockets\n ) {\n console.warn(\n '[axl-studio] WARNING: Studio middleware mounted in production without verifyUpgrade. ' +\n 'upgradeWebSocket() will refuse to attach. Provide verifyUpgrade or explicitly set ' +\n 'dangerouslyAllowUnauthenticatedWebSockets: true only when an outer upgrade gate is guaranteed.',\n );\n }\n\n // Convert Hono app → Node.js (req, res) handler via @hono/node-server.\n // overrideGlobalObjects: false prevents replacing global.Request/Response,\n // which could break the host application.\n const listener = getRequestListener(app.fetch, {\n overrideGlobalObjects: false,\n });\n\n let closed = false;\n\n function handler(req: IncomingMessage, res: ServerResponse) {\n if (closed) {\n res.writeHead(503, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n ok: false,\n error: { code: 'CLOSED', message: 'Studio middleware has been shut down' },\n }),\n );\n return;\n }\n\n // Express/NestJS/Koa body parsers consume the raw IncomingMessage stream\n // and store the parsed result on req.body. When that happens,\n // @hono/node-server's getRequestListener reads an empty stream and Hono\n // sees no body. To fix this, we re-serialize req.body as req.rawBody —\n // a Buffer that getRequestListener checks before falling back to the\n // stream (see newRequestFromIncoming in @hono/node-server/dist/listener.js).\n //\n // Verified against @hono/node-server@1.19.9. If this breaks after an\n // upgrade, check whether the rawBody instanceof Buffer check still exists\n // in newRequestFromIncoming.\n const reqAny = req as unknown as Record<string, unknown>;\n if (reqAny.body != null && !reqAny.rawBody) {\n try {\n reqAny.rawBody = Buffer.from(JSON.stringify(reqAny.body));\n } catch {\n // Non-serializable body — Hono will see an empty body\n }\n }\n\n listener(req, res).catch((err) => {\n console.error('[axl-studio] Unhandled error in request handler:', err);\n if (!res.headersSent) {\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n ok: false,\n error: { code: 'INTERNAL_ERROR', message: 'Internal server error' },\n }),\n );\n }\n });\n }\n\n // Handle an individual WebSocket using the Studio protocol.\n // Adapts any StudioWebSocket to ConnectionManager's internal BroadcastTarget.\n // `metadata` is whatever `verifyUpgrade` attached — passed through so the\n // `filterTraceEvent` callback can read it on every outbound event.\n function handleWebSocket(ws: StudioWebSocket, metadata?: unknown) {\n if (closed) {\n ws.close();\n return;\n }\n const socket = {\n send: (data: string) => ws.send(data),\n close: () => ws.close(),\n };\n connMgr.add(socket);\n if (metadata !== undefined) {\n connMgr.setMetadata(socket, metadata);\n }\n\n ws.on('message', (raw) => {\n const reply = handleWsMessage(String(raw), socket, connMgr);\n if (reply) ws.send(reply);\n });\n\n ws.on('close', () => connMgr.remove(socket));\n ws.on('error', () => connMgr.remove(socket));\n }\n\n // Internal WebSocketServer — created lazily by upgradeWebSocket()\n let wss: InstanceType<typeof WebSocketServer> | undefined;\n // References for cleanup: the upgrade handler and server it's attached to\n let upgradeHandler: ((...args: any[]) => void) | undefined;\n let serverRef: Server | undefined;\n\n // Convenience: attach WS handling to an http.Server.\n function upgradeWebSocket(server: Server, path?: string) {\n if (\n process.env.NODE_ENV === 'production' &&\n !verifyUpgrade &&\n !dangerouslyAllowUnauthenticatedWebSockets\n ) {\n throw new Error(\n '[axl-studio] Refusing unauthenticated WebSocket upgrades in production. ' +\n 'Provide verifyUpgrade or explicitly set dangerouslyAllowUnauthenticatedWebSockets: true.',\n );\n }\n\n if (wss) {\n throw new Error(\n '[axl-studio] upgradeWebSocket() has already been called. ' +\n 'Call close() first if you need to re-attach.',\n );\n }\n\n const wsPath = path ?? (basePath ? `${basePath}/ws` : '/ws');\n\n wss = new WebSocketServer({ noServer: true });\n serverRef = server;\n\n upgradeHandler = async (req: IncomingMessage, socket: any, head: Buffer) => {\n // Match path, ignoring query string\n const pathname = new URL(req.url!, `http://${req.headers.host}`).pathname;\n if (pathname !== wsPath) return; // Let other upgrade handlers run\n\n // Early-reject if close() already started. This is a cheap pre-check —\n // we repeat it AFTER the async verifyUpgrade resolves to close the race\n // where close() runs while verifyUpgrade is still in flight.\n if (closed) {\n socket.destroy();\n return;\n }\n\n // Apply auth verification and capture optional per-connection metadata\n // (e.g. { userId, tenantId }) that filterTraceEvent can later read.\n let connectionMetadata: unknown;\n if (verifyUpgrade) {\n try {\n const result = await verifyUpgrade(req);\n // Normalize both return shapes: plain boolean or { allowed, metadata }\n const allowed = typeof result === 'boolean' ? result : result.allowed;\n if (!allowed) {\n socket.write('HTTP/1.1 401 Unauthorized\\r\\n\\r\\n');\n socket.destroy();\n return;\n }\n if (typeof result === 'object' && result !== null) {\n connectionMetadata = result.metadata;\n }\n } catch {\n socket.write('HTTP/1.1 403 Forbidden\\r\\n\\r\\n');\n socket.destroy();\n return;\n }\n }\n\n // Guard against close() having run during async verifyUpgrade. We check\n // BOTH `closed` (set at the top of `close()`) and `!wss` (set at the\n // end). `closed` is the authoritative signal — checking `!wss` alone\n // wouldn't catch the window where `close()` has marked the middleware\n // as shut down but hasn't yet torn down the WebSocketServer. Failing\n // open here would leak a connection past the middleware's lifetime\n // and leave it holding references the host expected to be released.\n if (closed || !wss) {\n socket.destroy();\n return;\n }\n\n wss.handleUpgrade(req, socket, head, (ws) => {\n handleWebSocket(ws, connectionMetadata);\n });\n };\n\n server.on('upgrade', upgradeHandler);\n }\n\n // Cleanup function for lifecycle management.\n function close() {\n closed = true;\n\n // Abort active streaming eval runs before closing connections\n closeActiveRuns();\n\n // Close all WebSocket connections\n connMgr.closeAll();\n\n // Remove the upgrade listener from the server before closing WSS\n if (upgradeHandler && serverRef) {\n serverRef.removeListener('upgrade', upgradeHandler);\n upgradeHandler = undefined;\n serverRef = undefined;\n }\n\n // Shut down the internal WebSocketServer if one was created\n if (wss) {\n wss.close();\n wss = undefined;\n }\n\n // Remove only our trace event listener from the runtime\n if (traceListener) {\n runtime.removeListener('trace', traceListener);\n }\n\n // Close all aggregators (clear intervals and unsubscribe listeners)\n closeAggregators();\n }\n\n return {\n handler,\n handleWebSocket,\n upgradeWebSocket,\n app,\n connectionManager: connMgr,\n close,\n };\n}\n\n/**\n * Normalize and validate basePath.\n * - Empty string and undefined → ''\n * - Strip trailing slashes\n * - Validate leading slash when non-empty\n * - Reject unsafe characters\n */\nfunction normalizeBasePath(raw?: string): string {\n if (!raw) return '';\n\n // Strip trailing slashes\n const normalized = raw.replace(/\\/+$/, '');\n if (!normalized) return '';\n\n // Must start with /\n if (!normalized.startsWith('/')) {\n throw new Error(`basePath must start with '/' (got '${raw}'). Example: '/studio'`);\n }\n\n // Reject path traversal, consecutive slashes, and unsafe characters\n if (normalized.includes('..')) {\n throw new Error(`basePath must not contain '..' segments (got '${raw}')`);\n }\n if (normalized.includes('//')) {\n throw new Error(`basePath must not contain consecutive slashes (got '${raw}')`);\n }\n if (!/^\\/[a-zA-Z0-9/_-]*$/.test(normalized)) {\n throw new Error(\n `basePath contains invalid characters (got '${raw}'). ` +\n 'Only alphanumeric characters, /, _, and - are allowed.',\n );\n }\n\n return normalized;\n}\n\nfunction resolveClientDist(): string | undefined {\n // Resolve the directory of this file (dist/ in published package).\n const dir =\n import.meta.dirname ??\n (typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url)));\n const candidate = resolve(dir, 'client');\n return existsSync(resolve(candidate, 'index.html')) ? candidate : undefined;\n}\n","import { resolve, relative, basename } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport type { AxlRuntime, EvalExecuteWorkflow } from '@axlsdk/axl';\nimport { importModule, expandGlob, registerConditions, pickDefault, pickExport } from '@axlsdk/axl';\n\n// In the CJS bundle, tsup stubs import.meta as {} so import.meta.url is\n// undefined. Fall back to __filename (which CJS defines) converted to a\n// file:// URL so tsImport() gets a valid parentURL.\nconst parentURL: string =\n import.meta.url ?? pathToFileURL(typeof __filename !== 'undefined' ? __filename : __dirname).href;\n\n/**\n * Configuration for lazy eval file discovery.\n *\n * - `string` — a glob pattern or explicit file path\n * - `string[]` — multiple patterns/paths\n * - `object` — patterns with optional import conditions\n */\nexport type EvalLoaderConfig =\n | string\n | string[]\n | {\n files: string | string[];\n\n /**\n * Custom Node.js import conditions (e.g., `['development']`).\n *\n * In monorepos, package.json `exports` often use the `development` condition\n * to point at source (`.ts`) instead of built dist. Without this, eval files\n * that import workspace packages resolve to dist files, which may not exist.\n *\n * **WARNING**: Conditions are registered process-wide via `module.register()`.\n * They affect all subsequent imports in the process, not just eval files.\n */\n conditions?: string[];\n };\n\n/**\n * Create a lazy eval loader that resolves file patterns and dynamically imports\n * eval files on first call, registering them with the runtime.\n *\n * The loader is idempotent — subsequent calls return the same promise.\n * Concurrent callers all await the same loading work.\n *\n * Eval files should export a default config with `{ workflow, dataset, scorers }`\n * (the result of `defineEval()` from `@axlsdk/eval`). An optional named export\n * `executeWorkflow` overrides the default `runtime.execute()` behavior.\n *\n * Eval names are the file's path relative to `cwd` (project root), minus the\n * `.eval.*` suffix. This makes names completely stable — a file's name never\n * changes regardless of what other files or patterns exist.\n *\n * @param config Glob patterns, file paths, or object with conditions\n * @param runtime The AxlRuntime to register discovered evals on\n * @param cwd Base directory for resolving patterns and deriving names (default: `process.cwd()`)\n */\nexport function createEvalLoader(\n config: EvalLoaderConfig,\n runtime: AxlRuntime,\n cwd?: string,\n): () => Promise<void> {\n let loadPromise: Promise<void> | undefined;\n const { patterns, conditions } = normalizeConfig(config);\n const baseCwd = cwd ?? process.cwd();\n\n return () => {\n if (!loadPromise) {\n loadPromise = loadEvalFiles(patterns, conditions, baseCwd, runtime).catch((err) => {\n loadPromise = undefined; // Allow retry on next request\n throw err;\n });\n }\n return loadPromise;\n };\n}\n\n// ── Core loading logic ─────────────────────────────────────────────\n\nasync function loadEvalFiles(\n patterns: string[],\n conditions: string[],\n cwd: string,\n runtime: AxlRuntime,\n): Promise<void> {\n if (conditions.length > 0) {\n await registerConditions(conditions);\n }\n\n const files = resolvePatterns(patterns, cwd);\n\n if (files.length === 0) {\n console.warn(`[axl-studio] No eval files found matching: ${patterns.join(', ')}`);\n return;\n }\n\n for (const file of files) {\n try {\n const mod = await importModule(file, parentURL);\n const evalConfig = pickDefault<{\n workflow?: string;\n dataset?: unknown;\n scorers?: unknown;\n }>(mod);\n\n if (!evalConfig || typeof evalConfig !== 'object') {\n console.warn(\n `[axl-studio] Skipping ${file}: default export is ` +\n `${evalConfig === null ? 'null' : typeof evalConfig}, expected an eval config object.`,\n );\n continue;\n }\n if (Array.isArray(evalConfig.scorers) && evalConfig.scorers.length === 0) {\n console.warn(\n `[axl-studio] Skipping ${file}: scorers array is empty — at least one scorer is required.`,\n );\n continue;\n }\n if (!evalConfig.workflow || !evalConfig.dataset || !evalConfig.scorers) {\n const missing = [\n !evalConfig.workflow && 'workflow',\n !evalConfig.dataset && 'dataset',\n !evalConfig.scorers && 'scorers',\n ]\n .filter(Boolean)\n .join(', ');\n const keys = Object.keys(evalConfig).slice(0, 10);\n const got = keys.length > 0 ? ` Got: { ${keys.join(', ')} }.` : '';\n console.warn(\n `[axl-studio] Skipping ${file}: not a valid eval config (missing ${missing}).${got}`,\n );\n continue;\n }\n\n const name = deriveEvalName(file, cwd);\n\n if (runtime.getRegisteredEval(name)) {\n console.warn(\n `[axl-studio] Eval name \"${name}\" from ${file} collides with an ` +\n `already-registered eval — overwriting`,\n );\n }\n\n // Walk the ESM/CJS interop chain symmetrically with the default export\n // so `executeWorkflow` stays visible when tsx loads the eval module as CJS\n // (e.g. a `.ts` file in a package without `\"type\": \"module\"`). Validate\n // the export is callable — a non-function `executeWorkflow` is silently\n // ignored so registration falls back to `runtime.execute()`, with a\n // warning so the user knows their export was rejected.\n const exported = pickExport<unknown>(mod, 'executeWorkflow');\n let customExecute: EvalExecuteWorkflow | undefined;\n if (typeof exported === 'function') {\n customExecute = exported as EvalExecuteWorkflow;\n } else if (exported !== undefined) {\n console.warn(\n `[axl-studio] ${file} exports executeWorkflow but it is ${typeof exported}, ` +\n `not a function — ignoring and falling back to runtime.execute()`,\n );\n }\n runtime.registerEval(name, evalConfig, customExecute);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n console.warn(`[axl-studio] Failed to load eval ${file}: ${msg}`);\n }\n }\n}\n\n// ── Internal helpers ───────────────────────────────────────────────\n\nfunction normalizeConfig(config: EvalLoaderConfig): {\n patterns: string[];\n conditions: string[];\n} {\n if (typeof config === 'string') {\n return { patterns: [config], conditions: [] };\n }\n if (Array.isArray(config)) {\n return { patterns: config, conditions: [] };\n }\n const files = typeof config.files === 'string' ? [config.files] : config.files;\n return { patterns: files, conditions: config.conditions ?? [] };\n}\n\n/**\n * Derive eval name from file path relative to cwd.\n *\n * Examples (cwd = `/project`):\n * - `/project/evals/suggestions.eval.ts` → `\"evals/suggestions\"`\n * - `/project/evals/api/accuracy.eval.ts` → `\"evals/api/accuracy\"`\n */\nfunction deriveEvalName(filePath: string, cwd: string): string {\n const rel = relative(cwd, filePath);\n // Normalize to forward slashes for cross-platform consistency\n const normalized = rel.replace(/\\\\/g, '/');\n // Guard: file outside cwd (symlink, absolute path) — fall back to basename\n if (normalized.startsWith('../')) {\n const base = basename(filePath);\n const stripped = base.replace(/\\.eval\\.[mc]?[jt]sx?$/, '');\n return stripped !== base ? stripped : base.replace(/\\.[mc]?[jt]sx?$/, '') || base;\n }\n // Strip .eval.ts, .eval.mjs, .eval.js, etc.\n const withoutEval = normalized.replace(/\\.eval\\.[mc]?[jt]sx?$/, '');\n if (withoutEval !== normalized) return withoutEval;\n // Fallback: strip extension\n const withoutExt = normalized.replace(/\\.[mc]?[jt]sx?$/, '');\n return withoutExt || normalized;\n}\n\n/**\n * Resolve patterns to absolute file paths.\n *\n * Supports:\n * - Explicit file paths (no wildcards)\n * - Single-directory globs: `dir/*.eval.ts`\n * - Recursive globs: `dir/**\\/*.eval.ts` or `**\\/*.eval.ts`\n *\n * Multi-segment `**` (e.g., `a/**\\/b/**\\/*.ts`) is not supported.\n */\nfunction resolvePatterns(patterns: string[], cwd: string): string[] {\n const files: string[] = [];\n const seen = new Set<string>();\n for (const pattern of patterns) {\n const resolved = pattern.includes('*') ? expandGlob(pattern, cwd) : [resolve(cwd, pattern)];\n for (const file of resolved) {\n if (!seen.has(file)) {\n seen.add(file);\n files.push(file);\n }\n }\n }\n return files;\n}\n\n// `expandGlob` and `registerConditions` are imported from @axlsdk/axl above.\n// The studio-specific glob helpers and conditions registration that used to\n// live here are now shared with @axlsdk/eval to prevent drift.\n"],"mappings":"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","names":["resolve","resolve"]}
1
+ {"version":3,"sources":["../src/middleware.ts","../src/eval-loader.ts"],"sourcesContent":["import { resolve, dirname } from 'node:path';\nimport { existsSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport { getRequestListener } from '@hono/node-server';\nimport { WebSocketServer } from 'ws';\nimport { createServer } from './server/index.js';\nimport { handleWsMessage } from './server/ws/protocol.js';\nimport { createEvalLoader } from './eval-loader.js';\nimport type { EvalLoaderConfig } from './eval-loader.js';\nimport type { BufferCaps } from './server/ws/connection-manager.js';\nimport type { AxlRuntime } from '@axlsdk/axl';\nimport type { IncomingMessage, ServerResponse, Server } from 'node:http';\nimport type { Duplex } from 'node:stream';\n\nexport type { EvalLoaderConfig } from './eval-loader.js';\nexport type { BufferCaps } from './server/ws/connection-manager.js';\n\nexport type StudioMiddlewareOptions = {\n /** The AxlRuntime instance to observe and control. */\n runtime: AxlRuntime;\n\n /**\n * URL path prefix where Studio is mounted.\n * Must match the mount path in your framework (Express `app.use()`,\n * Fastify `register()`, Hono `app.route()`, etc.). The framework is\n * expected to strip the prefix from `req.url` before calling the handler.\n *\n * Do not set basePath when using a raw `http.Server` as the root handler —\n * leave it empty and mount at root instead.\n *\n * Must start with '/' when non-empty. Trailing slashes are stripped.\n * Only URL-safe characters allowed: [a-zA-Z0-9/_-]\n *\n * @example '/studio'\n * @example '/admin/studio'\n * @example '' — mounted at root (default)\n */\n basePath?: string;\n\n /**\n * Serve the pre-built Studio SPA.\n * Set to false if serving the client from a CDN or separate build.\n * @default true\n */\n serveClient?: boolean;\n\n /**\n * Verify a WebSocket upgrade request before completing the handshake.\n *\n * Return either a boolean (`true` allow, `false` reject) or an object\n * `{ allowed: boolean, metadata?: unknown }` to ALSO attach per-connection\n * metadata that `filterTraceEvent` can later read — typically the auth\n * subject (userId, tenantId, role). Throw to reject with an error.\n *\n * IMPORTANT: WebSocket upgrades bypass Express/Fastify/Koa middleware.\n * If your HTTP routes are behind auth middleware, WS connections are NOT\n * automatically protected. Use this callback to enforce authentication\n * on WebSocket connections.\n *\n * @example Multi-tenant: extract tenant from JWT, attach as metadata\n * verifyUpgrade: async (req) => {\n * const token = req.headers.authorization?.slice(7);\n * const claims = await verifyJwt(token);\n * if (!claims) return false;\n * return { allowed: true, metadata: { userId: claims.sub, tenantId: claims.tid } };\n * }\n */\n verifyUpgrade?: (\n req: IncomingMessage,\n ) =>\n | boolean\n | { allowed: boolean; metadata?: unknown }\n | Promise<boolean | { allowed: boolean; metadata?: unknown }>;\n\n /**\n * Permit `upgradeWebSocket()` without `verifyUpgrade` in production.\n * This exposes Studio's administrative event channels without SDK-level\n * authentication and should be used only when an outer upgrade gate is\n * independently guaranteed.\n * @default false\n */\n dangerouslyAllowUnauthenticatedWebSockets?: boolean;\n\n /**\n * Optional per-event filter used by multi-tenant deployments to scope the\n * trace firehose. Called on every outbound WebSocket broadcast; return\n * `true` to deliver to this connection, `false` to drop.\n *\n * `event` is the parsed event payload — for the `trace:*` channel this is\n * an `AxlEvent` from `@axlsdk/axl` (narrow via the discriminated union);\n * for `costs` it's a `CostData`; for `execution:*` / `eval:*` it's the\n * legacy stream-event shape (until PR 3 collapses the wire to `AxlEvent`).\n * Typed `unknown` because the filter runs for every channel.\n * `metadata` is the per-connection metadata attached by `verifyUpgrade`,\n * or `undefined` if `verifyUpgrade` returned a bare boolean.\n *\n * Filter errors are treated as `drop` (fail-closed) so a buggy predicate\n * cannot accidentally leak events cross-tenant.\n *\n * @example Scope trace events by tenant id stored on agent metadata\n * import type { AxlEvent } from '@axlsdk/axl';\n *\n * filterTraceEvent: (event, meta) => {\n * const m = meta as { tenantId?: string } | undefined;\n * if (!m?.tenantId) return false;\n * const e = event as AxlEvent;\n * // Only narrow event.type === 'agent_call_end' events; pass structural\n * // events (workflow_start/end, cost updates) through unconditionally.\n * if (e.type !== 'agent_call_end') return true;\n * return e.workflow?.startsWith(`${m.tenantId}:`) ?? false;\n * }\n */\n filterTraceEvent?: (event: unknown, metadata: unknown) => boolean;\n\n /**\n * Disable all mutating endpoints (execute, test, send, delete, resolve).\n * When true, Studio is observation-only.\n * @default false\n */\n readOnly?: boolean;\n\n /**\n * Lazy-load eval files for the Eval Runner panel.\n *\n * Eval files are dynamically imported on first access to eval endpoints,\n * not at middleware construction time. This means:\n * - Zero cost during normal API operation\n * - Eval files can import from any module without creating circular deps\n * in the static module graph (they're loaded as standalone entry points)\n * - `@axlsdk/eval` can remain a devDependency — eval files never enter\n * the production bundle since bundlers can't see dynamic imports\n *\n * Accepts glob patterns, explicit file paths, or an object with\n * `conditions` for monorepo source export resolution.\n *\n * Eval files are loaded once and cached for the middleware's lifetime.\n * Changes to eval files require a server restart.\n *\n * @example\n * // Single glob pattern\n * evals: 'evals/*.eval.ts'\n *\n * @example\n * // Multiple patterns\n * evals: ['libs/api/evals/*.eval.ts', 'libs/ai/evals/*.eval.ts']\n *\n * @example\n * // With import conditions for monorepo source exports\n * evals: {\n * files: 'libs/api/evals/*.eval.ts',\n * conditions: ['development'],\n * }\n */\n evals?: EvalLoaderConfig;\n\n /**\n * Override the default WebSocket replay-buffer resource caps for\n * production deployments under sustained execution churn.\n *\n * Defaults: `maxEventsPerBuffer: 1000`, `maxBytesPerBuffer: 4 MiB`,\n * `maxActiveBuffers: 256`. Worst-case memory is roughly\n * `maxActiveBuffers × maxBytesPerBuffer` (≈1 GiB at defaults). Tighten\n * either dimension to lower the ceiling at the cost of late-subscriber\n * replay coverage; raise them if you need more event headroom on long\n * verbose-mode streams.\n *\n * Terminal `done` / `error` events are always buffered regardless of\n * caps, so a late subscriber to a completed stream still sees the\n * outcome — only intermediate non-terminal events are dropped.\n *\n * All fields are optional; passing `{}` is a no-op.\n *\n * @example\n * // Halve memory ceiling on a high-churn deployment\n * bufferCaps: { maxActiveBuffers: 128, maxBytesPerBuffer: 2 * 1024 * 1024 }\n */\n bufferCaps?: BufferCaps;\n};\n\n/**\n * Minimal contract a WebSocket connection must satisfy.\n * Matches the `ws` library API (de facto standard in Node.js).\n */\nexport interface StudioWebSocket {\n send(data: string): void;\n close(): void;\n on(event: 'message', fn: (data: string | Buffer) => void): void;\n on(event: 'close', fn: () => void): void;\n on(event: 'error', fn: (err: Error) => void): void;\n}\n\n// Re-export for Hono-in-Hono consumers\nexport { handleWsMessage } from './server/ws/protocol.js';\n\nexport type StudioMiddleware = ReturnType<typeof createStudioMiddleware>;\n\nexport function createStudioMiddleware(options: StudioMiddlewareOptions) {\n const {\n runtime,\n serveClient = true,\n verifyUpgrade,\n dangerouslyAllowUnauthenticatedWebSockets = false,\n readOnly = false,\n filterTraceEvent,\n } = options;\n\n // Normalize basePath: strip trailing slashes, validate format\n const basePath = normalizeBasePath(options.basePath);\n\n // Resolve pre-built SPA assets from this package's dist/\n const staticRoot = serveClient ? resolveClientDist() : undefined;\n\n if (serveClient && !staticRoot) {\n const dir =\n import.meta.dirname ??\n (typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url)));\n console.warn(\n '[axl-studio] serveClient is true but no pre-built client found at ' +\n `${resolve(dir, 'client')}. Studio UI will not be available. ` +\n 'Set serveClient: false to suppress this warning.',\n );\n }\n\n // Create lazy eval loader if eval files are configured\n const evalLoader = options.evals ? createEvalLoader(options.evals, runtime) : undefined;\n\n const { app, connMgr, traceListener, closeActiveRuns, closeAggregators } = createServer({\n runtime,\n staticRoot,\n basePath,\n readOnly,\n cors: false, // Host framework owns CORS policy\n evalLoader,\n bufferCaps: options.bufferCaps,\n });\n\n // Install the broadcast filter once at construction time. The connMgr\n // applies it on every outbound event; `metadata` is attached to each\n // connection after a successful `verifyUpgrade`.\n if (filterTraceEvent) {\n connMgr.setFilter(filterTraceEvent);\n }\n\n // Warn early; upgradeWebSocket() also fails closed unless explicitly acknowledged.\n if (\n process.env.NODE_ENV === 'production' &&\n !verifyUpgrade &&\n !dangerouslyAllowUnauthenticatedWebSockets\n ) {\n console.warn(\n '[axl-studio] WARNING: Studio middleware mounted in production without verifyUpgrade. ' +\n 'upgradeWebSocket() will refuse to attach. Provide verifyUpgrade or explicitly set ' +\n 'dangerouslyAllowUnauthenticatedWebSockets: true only when an outer upgrade gate is guaranteed.',\n );\n }\n\n // Convert Hono app → Node.js (req, res) handler via @hono/node-server.\n // overrideGlobalObjects: false prevents replacing global.Request/Response,\n // which could break the host application.\n const listener = getRequestListener(app.fetch, {\n overrideGlobalObjects: false,\n });\n\n let closed = false;\n\n function handler(req: IncomingMessage, res: ServerResponse) {\n if (closed) {\n res.writeHead(503, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n ok: false,\n error: { code: 'CLOSED', message: 'Studio middleware has been shut down' },\n }),\n );\n return;\n }\n\n // Express/NestJS/Koa body parsers consume the raw IncomingMessage stream\n // and store the parsed result on req.body. When that happens,\n // @hono/node-server's getRequestListener reads an empty stream and Hono\n // sees no body. To fix this, we re-serialize req.body as req.rawBody —\n // a Buffer that getRequestListener checks before falling back to the\n // stream (see newRequestFromIncoming in @hono/node-server/dist/listener.js).\n //\n // Verified against @hono/node-server@1.19.9. If this breaks after an\n // upgrade, check whether the rawBody instanceof Buffer check still exists\n // in newRequestFromIncoming.\n const reqAny = req as unknown as Record<string, unknown>;\n if (reqAny.body != null && !reqAny.rawBody) {\n try {\n reqAny.rawBody = Buffer.from(JSON.stringify(reqAny.body));\n } catch {\n // Non-serializable body — Hono will see an empty body\n }\n }\n\n listener(req, res).catch((err) => {\n console.error('[axl-studio] Unhandled error in request handler:', err);\n if (!res.headersSent) {\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(\n JSON.stringify({\n ok: false,\n error: { code: 'INTERNAL_ERROR', message: 'Internal server error' },\n }),\n );\n }\n });\n }\n\n // Handle an individual WebSocket using the Studio protocol.\n // Adapts any StudioWebSocket to ConnectionManager's internal BroadcastTarget.\n // `metadata` is whatever `verifyUpgrade` attached — passed through so the\n // `filterTraceEvent` callback can read it on every outbound event.\n function handleWebSocket(ws: StudioWebSocket, metadata?: unknown) {\n if (closed) {\n ws.close();\n return;\n }\n const socket = {\n send: (data: string) => ws.send(data),\n close: () => ws.close(),\n };\n connMgr.add(socket);\n if (metadata !== undefined) {\n connMgr.setMetadata(socket, metadata);\n }\n\n ws.on('message', (raw) => {\n const reply = handleWsMessage(String(raw), socket, connMgr);\n if (reply) ws.send(reply);\n });\n\n ws.on('close', () => connMgr.remove(socket));\n ws.on('error', () => connMgr.remove(socket));\n }\n\n // Internal WebSocketServer — created lazily by upgradeWebSocket()\n let wss: InstanceType<typeof WebSocketServer> | undefined;\n // References for cleanup: the upgrade handler and server it's attached to\n let upgradeHandler:\n | ((request: IncomingMessage, socket: Duplex, head: Buffer) => void)\n | undefined;\n let serverRef: Server | undefined;\n\n // Convenience: attach WS handling to an http.Server.\n function upgradeWebSocket(server: Server, path?: string) {\n if (\n process.env.NODE_ENV === 'production' &&\n !verifyUpgrade &&\n !dangerouslyAllowUnauthenticatedWebSockets\n ) {\n throw new Error(\n '[axl-studio] Refusing unauthenticated WebSocket upgrades in production. ' +\n 'Provide verifyUpgrade or explicitly set dangerouslyAllowUnauthenticatedWebSockets: true.',\n );\n }\n\n if (wss) {\n throw new Error(\n '[axl-studio] upgradeWebSocket() has already been called. ' +\n 'Call close() first if you need to re-attach.',\n );\n }\n\n const wsPath = path ?? (basePath ? `${basePath}/ws` : '/ws');\n\n wss = new WebSocketServer({ noServer: true });\n serverRef = server;\n\n upgradeHandler = async (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Match path, ignoring query string\n const pathname = new URL(req.url!, `http://${req.headers.host}`).pathname;\n if (pathname !== wsPath) return; // Let other upgrade handlers run\n\n // Early-reject if close() already started. This is a cheap pre-check —\n // we repeat it AFTER the async verifyUpgrade resolves to close the race\n // where close() runs while verifyUpgrade is still in flight.\n if (closed) {\n socket.destroy();\n return;\n }\n\n // Apply auth verification and capture optional per-connection metadata\n // (e.g. { userId, tenantId }) that filterTraceEvent can later read.\n let connectionMetadata: unknown;\n if (verifyUpgrade) {\n try {\n const result = await verifyUpgrade(req);\n // Normalize both return shapes: plain boolean or { allowed, metadata }\n const allowed = typeof result === 'boolean' ? result : result.allowed;\n if (!allowed) {\n socket.write('HTTP/1.1 401 Unauthorized\\r\\n\\r\\n');\n socket.destroy();\n return;\n }\n if (typeof result === 'object' && result !== null) {\n connectionMetadata = result.metadata;\n }\n } catch {\n socket.write('HTTP/1.1 403 Forbidden\\r\\n\\r\\n');\n socket.destroy();\n return;\n }\n }\n\n // Guard against close() having run during async verifyUpgrade. We check\n // BOTH `closed` (set at the top of `close()`) and `!wss` (set at the\n // end). `closed` is the authoritative signal — checking `!wss` alone\n // wouldn't catch the window where `close()` has marked the middleware\n // as shut down but hasn't yet torn down the WebSocketServer. Failing\n // open here would leak a connection past the middleware's lifetime\n // and leave it holding references the host expected to be released.\n if (closed || !wss) {\n socket.destroy();\n return;\n }\n\n wss.handleUpgrade(req, socket, head, (ws) => {\n handleWebSocket(ws, connectionMetadata);\n });\n };\n\n server.on('upgrade', upgradeHandler);\n }\n\n // Cleanup function for lifecycle management.\n function close() {\n closed = true;\n\n // Abort active streaming eval runs before closing connections\n closeActiveRuns();\n\n // Close all WebSocket connections\n connMgr.closeAll();\n\n // Remove the upgrade listener from the server before closing WSS\n if (upgradeHandler && serverRef) {\n serverRef.removeListener('upgrade', upgradeHandler);\n upgradeHandler = undefined;\n serverRef = undefined;\n }\n\n // Shut down the internal WebSocketServer if one was created\n if (wss) {\n wss.close();\n wss = undefined;\n }\n\n // Remove only our trace event listener from the runtime\n if (traceListener) {\n runtime.removeListener('trace', traceListener);\n }\n\n // Close all aggregators (clear intervals and unsubscribe listeners)\n closeAggregators();\n }\n\n return {\n handler,\n handleWebSocket,\n upgradeWebSocket,\n app,\n connectionManager: connMgr,\n close,\n };\n}\n\n/**\n * Normalize and validate basePath.\n * - Empty string and undefined → ''\n * - Strip trailing slashes\n * - Validate leading slash when non-empty\n * - Reject unsafe characters\n */\nfunction normalizeBasePath(raw?: string): string {\n if (!raw) return '';\n\n // Strip trailing slashes\n const normalized = raw.replace(/\\/+$/, '');\n if (!normalized) return '';\n\n // Must start with /\n if (!normalized.startsWith('/')) {\n throw new Error(`basePath must start with '/' (got '${raw}'). Example: '/studio'`);\n }\n\n // Reject path traversal, consecutive slashes, and unsafe characters\n if (normalized.includes('..')) {\n throw new Error(`basePath must not contain '..' segments (got '${raw}')`);\n }\n if (normalized.includes('//')) {\n throw new Error(`basePath must not contain consecutive slashes (got '${raw}')`);\n }\n if (!/^\\/[a-zA-Z0-9/_-]*$/.test(normalized)) {\n throw new Error(\n `basePath contains invalid characters (got '${raw}'). ` +\n 'Only alphanumeric characters, /, _, and - are allowed.',\n );\n }\n\n return normalized;\n}\n\nfunction resolveClientDist(): string | undefined {\n // Resolve the directory of this file (dist/ in published package).\n const dir =\n import.meta.dirname ??\n (typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url)));\n const candidate = resolve(dir, 'client');\n return existsSync(resolve(candidate, 'index.html')) ? candidate : undefined;\n}\n","import { resolve, relative, basename } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport type { AxlRuntime, EvalExecuteWorkflow } from '@axlsdk/axl';\nimport { importModule, expandGlob, registerConditions, pickDefault, pickExport } from '@axlsdk/axl';\n\n// In the CJS bundle, tsup stubs import.meta as {} so import.meta.url is\n// undefined. Fall back to __filename (which CJS defines) converted to a\n// file:// URL so tsImport() gets a valid parentURL.\nconst parentURL: string =\n import.meta.url ?? pathToFileURL(typeof __filename !== 'undefined' ? __filename : __dirname).href;\n\n/**\n * Configuration for lazy eval file discovery.\n *\n * - `string` — a glob pattern or explicit file path\n * - `string[]` — multiple patterns/paths\n * - `object` — patterns with optional import conditions\n */\nexport type EvalLoaderConfig =\n | string\n | string[]\n | {\n files: string | string[];\n\n /**\n * Custom Node.js import conditions (e.g., `['development']`).\n *\n * In monorepos, package.json `exports` often use the `development` condition\n * to point at source (`.ts`) instead of built dist. Without this, eval files\n * that import workspace packages resolve to dist files, which may not exist.\n *\n * **WARNING**: Conditions are registered process-wide via `module.register()`.\n * They affect all subsequent imports in the process, not just eval files.\n */\n conditions?: string[];\n };\n\n/**\n * Create a lazy eval loader that resolves file patterns and dynamically imports\n * eval files on first call, registering them with the runtime.\n *\n * The loader is idempotent — subsequent calls return the same promise.\n * Concurrent callers all await the same loading work.\n *\n * Eval files should export a default config with `{ workflow, dataset, scorers }`\n * (the result of `defineEval()` from `@axlsdk/eval`). An optional named export\n * `executeWorkflow` overrides the default `runtime.execute()` behavior.\n *\n * Eval names are the file's path relative to `cwd` (project root), minus the\n * `.eval.*` suffix. This makes names completely stable — a file's name never\n * changes regardless of what other files or patterns exist.\n *\n * @param config Glob patterns, file paths, or object with conditions\n * @param runtime The AxlRuntime to register discovered evals on\n * @param cwd Base directory for resolving patterns and deriving names (default: `process.cwd()`)\n */\nexport function createEvalLoader(\n config: EvalLoaderConfig,\n runtime: AxlRuntime,\n cwd?: string,\n): () => Promise<void> {\n let loadPromise: Promise<void> | undefined;\n const { patterns, conditions } = normalizeConfig(config);\n const baseCwd = cwd ?? process.cwd();\n\n return () => {\n if (!loadPromise) {\n loadPromise = loadEvalFiles(patterns, conditions, baseCwd, runtime).catch((err) => {\n loadPromise = undefined; // Allow retry on next request\n throw err;\n });\n }\n return loadPromise;\n };\n}\n\n// ── Core loading logic ─────────────────────────────────────────────\n\nasync function loadEvalFiles(\n patterns: string[],\n conditions: string[],\n cwd: string,\n runtime: AxlRuntime,\n): Promise<void> {\n if (conditions.length > 0) {\n await registerConditions(conditions);\n }\n\n const files = resolvePatterns(patterns, cwd);\n\n if (files.length === 0) {\n console.warn(`[axl-studio] No eval files found matching: ${patterns.join(', ')}`);\n return;\n }\n\n for (const file of files) {\n try {\n const mod = await importModule(file, parentURL);\n const evalConfig = pickDefault<{\n workflow?: string;\n dataset?: unknown;\n scorers?: unknown;\n }>(mod);\n\n if (!evalConfig || typeof evalConfig !== 'object') {\n console.warn(\n `[axl-studio] Skipping ${file}: default export is ` +\n `${evalConfig === null ? 'null' : typeof evalConfig}, expected an eval config object.`,\n );\n continue;\n }\n if (Array.isArray(evalConfig.scorers) && evalConfig.scorers.length === 0) {\n console.warn(\n `[axl-studio] Skipping ${file}: scorers array is empty — at least one scorer is required.`,\n );\n continue;\n }\n if (!evalConfig.workflow || !evalConfig.dataset || !evalConfig.scorers) {\n const missing = [\n !evalConfig.workflow && 'workflow',\n !evalConfig.dataset && 'dataset',\n !evalConfig.scorers && 'scorers',\n ]\n .filter(Boolean)\n .join(', ');\n const keys = Object.keys(evalConfig).slice(0, 10);\n const got = keys.length > 0 ? ` Got: { ${keys.join(', ')} }.` : '';\n console.warn(\n `[axl-studio] Skipping ${file}: not a valid eval config (missing ${missing}).${got}`,\n );\n continue;\n }\n\n const name = deriveEvalName(file, cwd);\n\n if (runtime.getRegisteredEval(name)) {\n console.warn(\n `[axl-studio] Eval name \"${name}\" from ${file} collides with an ` +\n `already-registered eval — overwriting`,\n );\n }\n\n // Walk the ESM/CJS interop chain symmetrically with the default export\n // so `executeWorkflow` stays visible when tsx loads the eval module as CJS\n // (e.g. a `.ts` file in a package without `\"type\": \"module\"`). Validate\n // the export is callable — a non-function `executeWorkflow` is silently\n // ignored so registration falls back to `runtime.execute()`, with a\n // warning so the user knows their export was rejected.\n const exported = pickExport<unknown>(mod, 'executeWorkflow');\n let customExecute: EvalExecuteWorkflow | undefined;\n if (typeof exported === 'function') {\n customExecute = exported as EvalExecuteWorkflow;\n } else if (exported !== undefined) {\n console.warn(\n `[axl-studio] ${file} exports executeWorkflow but it is ${typeof exported}, ` +\n `not a function — ignoring and falling back to runtime.execute()`,\n );\n }\n runtime.registerEval(name, evalConfig, customExecute);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n console.warn(`[axl-studio] Failed to load eval ${file}: ${msg}`);\n }\n }\n}\n\n// ── Internal helpers ───────────────────────────────────────────────\n\nfunction normalizeConfig(config: EvalLoaderConfig): {\n patterns: string[];\n conditions: string[];\n} {\n if (typeof config === 'string') {\n return { patterns: [config], conditions: [] };\n }\n if (Array.isArray(config)) {\n return { patterns: config, conditions: [] };\n }\n const files = typeof config.files === 'string' ? [config.files] : config.files;\n return { patterns: files, conditions: config.conditions ?? [] };\n}\n\n/**\n * Derive eval name from file path relative to cwd.\n *\n * Examples (cwd = `/project`):\n * - `/project/evals/suggestions.eval.ts` → `\"evals/suggestions\"`\n * - `/project/evals/api/accuracy.eval.ts` → `\"evals/api/accuracy\"`\n */\nfunction deriveEvalName(filePath: string, cwd: string): string {\n const rel = relative(cwd, filePath);\n // Normalize to forward slashes for cross-platform consistency\n const normalized = rel.replace(/\\\\/g, '/');\n // Guard: file outside cwd (symlink, absolute path) — fall back to basename\n if (normalized.startsWith('../')) {\n const base = basename(filePath);\n const stripped = base.replace(/\\.eval\\.[mc]?[jt]sx?$/, '');\n return stripped !== base ? stripped : base.replace(/\\.[mc]?[jt]sx?$/, '') || base;\n }\n // Strip .eval.ts, .eval.mjs, .eval.js, etc.\n const withoutEval = normalized.replace(/\\.eval\\.[mc]?[jt]sx?$/, '');\n if (withoutEval !== normalized) return withoutEval;\n // Fallback: strip extension\n const withoutExt = normalized.replace(/\\.[mc]?[jt]sx?$/, '');\n return withoutExt || normalized;\n}\n\n/**\n * Resolve patterns to absolute file paths.\n *\n * Supports:\n * - Explicit file paths (no wildcards)\n * - Single-directory globs: `dir/*.eval.ts`\n * - Recursive globs: `dir/**\\/*.eval.ts` or `**\\/*.eval.ts`\n *\n * Multi-segment `**` (e.g., `a/**\\/b/**\\/*.ts`) is not supported.\n */\nfunction resolvePatterns(patterns: string[], cwd: string): string[] {\n const files: string[] = [];\n const seen = new Set<string>();\n for (const pattern of patterns) {\n const resolved = pattern.includes('*') ? expandGlob(pattern, cwd) : [resolve(cwd, pattern)];\n for (const file of resolved) {\n if (!seen.has(file)) {\n seen.add(file);\n files.push(file);\n }\n }\n }\n return files;\n}\n\n// `expandGlob` and `registerConditions` are imported from @axlsdk/axl above.\n// The studio-specific glob helpers and conditions registration that used to\n// live here are now shared with @axlsdk/eval to prevent drift.\n"],"mappings":"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","names":["resolve","resolve"]}
@@ -121,6 +121,19 @@ function redactStreamEvent(event, redact) {
121
121
  if (!redact) return event;
122
122
  return (0, import_axl.redactHistoricalEvent)(event);
123
123
  }
124
+ function sanitizeRichInputFailure(event) {
125
+ const message = "Playground media input failed";
126
+ if (event.type === "ask_end" && !event.outcome.ok) {
127
+ return { ...event, outcome: { ok: false, error: message } };
128
+ }
129
+ if (event.type === "agent_call_end" && event.data.error !== void 0) {
130
+ return { ...event, data: { ...event.data, error: message } };
131
+ }
132
+ if (event.type === "error") {
133
+ return { ...event, data: { ...event.data, message } };
134
+ }
135
+ return event;
136
+ }
124
137
  function redactEvalItem(item) {
125
138
  const scrubbed = {
126
139
  ...item,
@@ -839,11 +852,11 @@ function reduceCost(acc, event) {
839
852
  byWorkflow2[event.workflow] = { ...prev, executions: prev.executions + 1 };
840
853
  return { ...acc, byWorkflow: byWorkflow2 };
841
854
  }
842
- if (event.cost == null && !event.tokens) return acc;
855
+ const unpricedLeaf = (0, import_axl2.isUnpricedLeaf)(event);
856
+ if (event.cost == null && !event.tokens && !unpricedLeaf) return acc;
843
857
  const cost = (0, import_axl2.eventCostContribution)(event);
844
858
  if (event.type === "ask_end") return acc;
845
859
  const tokens = event.tokens ?? {};
846
- const unpricedLeaf = (0, import_axl2.isUnpricedLeaf)(event);
847
860
  const totalTokens = event.type === "agent_call_end" ? {
848
861
  input: acc.totalTokens.input + finite(tokens.input),
849
862
  output: acc.totalTokens.output + finite(tokens.output),
@@ -1356,6 +1369,24 @@ var executions_default = createExecutionRoutes();
1356
1369
 
1357
1370
  // src/server/routes/sessions.ts
1358
1371
  var import_hono4 = require("hono");
1372
+ function describeStudioInput(content) {
1373
+ if (typeof content === "string") return void 0;
1374
+ return {
1375
+ parts: content.map((part) => {
1376
+ if (part.type === "text") return { type: "text", characters: part.text.length };
1377
+ const { source } = part;
1378
+ const bytes = source.type === "bytes" ? source.data.byteLength : source.type === "base64" ? Math.floor(source.data.length * 3 / 4) - (source.data.endsWith("==") ? 2 : source.data.endsWith("=") ? 1 : 0) : void 0;
1379
+ return {
1380
+ type: "image",
1381
+ source: source.type,
1382
+ ...source.mediaType ? { mediaType: source.mediaType } : {},
1383
+ ...bytes !== void 0 ? { bytes } : {}
1384
+ // URLs, provider-file references, and labels are intentionally not
1385
+ // included: this is a Studio-safe descriptor, not a replay payload.
1386
+ };
1387
+ })
1388
+ };
1389
+ }
1359
1390
  function createSessionRoutes(connMgr) {
1360
1391
  const app5 = new import_hono4.Hono();
1361
1392
  app5.get("/sessions", async (c) => {
@@ -1378,11 +1409,17 @@ function createSessionRoutes(connMgr) {
1378
1409
  const id = c.req.param("id");
1379
1410
  const history = await store.getSession(id);
1380
1411
  const handoffHistory = await store.getSessionMeta(id, "handoffHistory");
1412
+ const redact = runtime.isRedactEnabled();
1413
+ const studioHistory = history.map((message) => {
1414
+ const input = describeStudioInput(message.content);
1415
+ if (input) return { ...message, content: input };
1416
+ return redact ? redactSessionHistory([message], true)[0] : message;
1417
+ });
1381
1418
  return c.json({
1382
1419
  ok: true,
1383
1420
  data: {
1384
1421
  id,
1385
- history: redactSessionHistory(history, runtime.isRedactEnabled()),
1422
+ history: studioHistory,
1386
1423
  // HandoffRecord has no content fields (source/target/mode/
1387
1424
  // timestamp/duration) — nothing to scrub.
1388
1425
  handoffHistory: handoffHistory ?? []
@@ -15952,6 +15989,54 @@ function date4(params) {
15952
15989
  // ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/external.js
15953
15990
  config(en_default());
15954
15991
 
15992
+ // src/playground-image.ts
15993
+ var PLAYGROUND_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
15994
+ var PLAYGROUND_IMAGE_MAX_BASE64_CHARACTERS = Math.ceil(PLAYGROUND_IMAGE_MAX_BYTES / 3) * 4;
15995
+ var PLAYGROUND_IMAGE_REQUEST_MAX_BYTES = PLAYGROUND_IMAGE_MAX_BASE64_CHARACTERS + 16 * 1024;
15996
+ var PLAYGROUND_IMAGE_MEDIA_TYPES = [
15997
+ "image/png",
15998
+ "image/jpeg",
15999
+ "image/webp",
16000
+ "image/gif"
16001
+ ];
16002
+ function isPlaygroundImageMediaType(value) {
16003
+ return typeof value === "string" && PLAYGROUND_IMAGE_MEDIA_TYPES.includes(value);
16004
+ }
16005
+ function base64ByteLength(value) {
16006
+ if (value.length === 0 || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
16007
+ return void 0;
16008
+ }
16009
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
16010
+ return value.length / 4 * 3 - padding;
16011
+ }
16012
+ function decodedPrefix(value, length) {
16013
+ try {
16014
+ const prefix = value.slice(0, Math.ceil(length / 3) * 4);
16015
+ const decoded = atob(prefix);
16016
+ return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
16017
+ } catch {
16018
+ return void 0;
16019
+ }
16020
+ }
16021
+ function hasMatchingImageSignature(mediaType, data) {
16022
+ const bytes = decodedPrefix(data, 12);
16023
+ if (!bytes) return false;
16024
+ const startsWith = (...prefix) => prefix.every((byte, index) => bytes[index] === byte);
16025
+ switch (mediaType) {
16026
+ case "image/png":
16027
+ return startsWith(137, 80, 78, 71, 13, 10, 26, 10);
16028
+ case "image/jpeg":
16029
+ return startsWith(255, 216, 255);
16030
+ case "image/gif":
16031
+ return startsWith(71, 73, 70, 56) && (bytes[4] === 55 || bytes[4] === 57) && bytes[5] === 97;
16032
+ case "image/webp":
16033
+ return startsWith(82, 73, 70, 70) && startsWithAt(bytes, 8, 87, 69, 66, 80);
16034
+ }
16035
+ }
16036
+ function startsWithAt(bytes, offset, ...prefix) {
16037
+ return prefix.every((byte, index) => bytes[offset + index] === byte);
16038
+ }
16039
+
15955
16040
  // src/server/routes/playground.ts
15956
16041
  var DEMO_SCHEMA_BY_AGENT = {
15957
16042
  "streaming-structured-agent": external_exports.object({
@@ -15961,11 +16046,74 @@ var DEMO_SCHEMA_BY_AGENT = {
15961
16046
  confidence: external_exports.number().min(0).max(1)
15962
16047
  })
15963
16048
  };
16049
+ var PlaygroundBodyTooLargeError = class extends Error {
16050
+ };
16051
+ async function readPlaygroundBody(request) {
16052
+ const contentLength = request.headers.get("content-length");
16053
+ if (contentLength !== null) {
16054
+ const bytes = Number(contentLength);
16055
+ if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > PLAYGROUND_IMAGE_REQUEST_MAX_BYTES) {
16056
+ throw new PlaygroundBodyTooLargeError();
16057
+ }
16058
+ }
16059
+ if (!request.body) throw new SyntaxError("missing body");
16060
+ const reader = request.body.getReader();
16061
+ const chunks = [];
16062
+ let total = 0;
16063
+ try {
16064
+ while (true) {
16065
+ const next = await reader.read();
16066
+ if (next.done) break;
16067
+ total += next.value.byteLength;
16068
+ if (total > PLAYGROUND_IMAGE_REQUEST_MAX_BYTES) {
16069
+ await reader.cancel();
16070
+ throw new PlaygroundBodyTooLargeError();
16071
+ }
16072
+ chunks.push(next.value);
16073
+ }
16074
+ } finally {
16075
+ reader.releaseLock();
16076
+ }
16077
+ const body = new Uint8Array(total);
16078
+ let offset = 0;
16079
+ for (const chunk of chunks) {
16080
+ body.set(chunk, offset);
16081
+ offset += chunk.byteLength;
16082
+ }
16083
+ return JSON.parse(new TextDecoder().decode(body));
16084
+ }
15964
16085
  function createPlaygroundRoutes(connMgr) {
15965
16086
  const app5 = new import_hono11.Hono();
15966
16087
  app5.post("/playground/chat", async (c) => {
15967
16088
  const runtime = c.get("runtime");
15968
- const body = await c.req.json();
16089
+ let parsedBody;
16090
+ try {
16091
+ parsedBody = await readPlaygroundBody(c.req.raw);
16092
+ } catch (error48) {
16093
+ if (error48 instanceof PlaygroundBodyTooLargeError) {
16094
+ return c.json(
16095
+ {
16096
+ ok: false,
16097
+ error: { code: "REQUEST_TOO_LARGE", message: "playground image request is too large" }
16098
+ },
16099
+ 413
16100
+ );
16101
+ }
16102
+ return c.json(
16103
+ { ok: false, error: { code: "INVALID_INPUT", message: "request body must be valid JSON" } },
16104
+ 400
16105
+ );
16106
+ }
16107
+ if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
16108
+ return c.json(
16109
+ {
16110
+ ok: false,
16111
+ error: { code: "INVALID_INPUT", message: "request body must be a JSON object" }
16112
+ },
16113
+ 400
16114
+ );
16115
+ }
16116
+ const body = parsedBody;
15969
16117
  if (!body.message || typeof body.message !== "string" || !body.message.trim()) {
15970
16118
  return c.json(
15971
16119
  {
@@ -15978,6 +16126,58 @@ function createPlaygroundRoutes(connMgr) {
15978
16126
  400
15979
16127
  );
15980
16128
  }
16129
+ const message = body.message;
16130
+ let image;
16131
+ if (body.image !== void 0) {
16132
+ const candidate = body.image;
16133
+ if (!candidate || typeof candidate !== "object" || !isPlaygroundImageMediaType(candidate.mediaType) || typeof candidate.data !== "string") {
16134
+ return c.json(
16135
+ {
16136
+ ok: false,
16137
+ error: { code: "INVALID_IMAGE", message: "image must be a supported base64 image" }
16138
+ },
16139
+ 400
16140
+ );
16141
+ }
16142
+ if (candidate.data.length > PLAYGROUND_IMAGE_MAX_BASE64_CHARACTERS) {
16143
+ return c.json(
16144
+ { ok: false, error: { code: "IMAGE_TOO_LARGE", message: "image must be at most 5 MiB" } },
16145
+ 400
16146
+ );
16147
+ }
16148
+ const bytes = base64ByteLength(candidate.data);
16149
+ if (bytes === void 0) {
16150
+ return c.json(
16151
+ {
16152
+ ok: false,
16153
+ error: { code: "INVALID_IMAGE", message: "image data must be valid base64" }
16154
+ },
16155
+ 400
16156
+ );
16157
+ }
16158
+ if (bytes > PLAYGROUND_IMAGE_MAX_BYTES) {
16159
+ return c.json(
16160
+ {
16161
+ ok: false,
16162
+ error: {
16163
+ code: "IMAGE_TOO_LARGE",
16164
+ message: `image must be at most ${PLAYGROUND_IMAGE_MAX_BYTES / 1024 / 1024} MiB`
16165
+ }
16166
+ },
16167
+ 400
16168
+ );
16169
+ }
16170
+ if (!hasMatchingImageSignature(candidate.mediaType, candidate.data)) {
16171
+ return c.json(
16172
+ {
16173
+ ok: false,
16174
+ error: { code: "INVALID_IMAGE", message: "image data does not match its media type" }
16175
+ },
16176
+ 400
16177
+ );
16178
+ }
16179
+ image = { mediaType: candidate.mediaType, data: candidate.data };
16180
+ }
15981
16181
  const agents = runtime.getAgents();
15982
16182
  const agent = body.agent ? agents.find((a) => a._name === body.agent) : agents[0];
15983
16183
  if (!agent) {
@@ -15992,14 +16192,18 @@ function createPlaygroundRoutes(connMgr) {
15992
16192
  const sessionId = body.sessionId ?? `playground-${Date.now()}`;
15993
16193
  const store = runtime.getStateStore();
15994
16194
  const history = await store.getSession(sessionId);
15995
- history.push({ role: "user", content: body.message });
16195
+ const persistedHistory = [...history, { role: "user", content: message }];
15996
16196
  const redactOn = runtime.isRedactEnabled();
15997
16197
  const ctx = runtime.createContext({ sessionHistory: history });
15998
16198
  const executionId = ctx.executionId;
15999
16199
  void ctx.events;
16000
16200
  const traceListener = (event) => {
16001
16201
  if (event.executionId !== executionId) return;
16002
- connMgr.broadcastWithWildcard(`execution:${executionId}`, redactStreamEvent(event, redactOn));
16202
+ const safeEvent = image ? sanitizeRichInputFailure(event) : event;
16203
+ connMgr.broadcastWithWildcard(
16204
+ `execution:${executionId}`,
16205
+ redactStreamEvent(safeEvent, redactOn)
16206
+ );
16003
16207
  };
16004
16208
  runtime.on("trace", traceListener);
16005
16209
  (async () => {
@@ -16012,10 +16216,17 @@ function createPlaygroundRoutes(connMgr) {
16012
16216
  });
16013
16217
  try {
16014
16218
  const schema = DEMO_SCHEMA_BY_AGENT[agent._name];
16015
- const result = await ctx.ask(agent, body.message, schema ? { schema } : void 0);
16219
+ const input = image ? [
16220
+ { type: "text", text: message },
16221
+ {
16222
+ type: "image",
16223
+ source: { type: "base64", data: image.data, mediaType: image.mediaType }
16224
+ }
16225
+ ] : message;
16226
+ const result = await ctx.ask(agent, input, schema ? { schema } : void 0);
16016
16227
  const resultText = typeof result === "string" ? result : JSON.stringify(result);
16017
- history.push({ role: "assistant", content: resultText });
16018
- await store.saveSession(sessionId, history);
16228
+ persistedHistory.push({ role: "assistant", content: resultText });
16229
+ await store.saveSession(sessionId, persistedHistory);
16019
16230
  const doneEvent = {
16020
16231
  ...terminalFields(),
16021
16232
  type: "done",
@@ -16029,7 +16240,11 @@ function createPlaygroundRoutes(connMgr) {
16029
16240
  const errorEvent = {
16030
16241
  ...terminalFields(),
16031
16242
  type: "error",
16032
- data: { message: err instanceof Error ? err.message : String(err) }
16243
+ // Media payloads can make providers echo user-controlled content in
16244
+ // errors. The manual terminal event is deliberately fixed and safe.
16245
+ data: {
16246
+ message: image ? "Playground media input failed" : err instanceof Error ? err.message : String(err)
16247
+ }
16033
16248
  };
16034
16249
  connMgr.broadcastWithWildcard(
16035
16250
  `execution:${executionId}`,