import { resolve } from "node:path"; import { z } from "incur"; import type { Logger } from "pino"; import { ENVIRONMENT_EXPORT_OPERATION } from "../application/environment/environmentBundle.js"; import { exportEnvironmentBundle, readEnvironmentExportInputs, type EnvironmentExportResult, } from "../application/environment/environmentExport.js"; import { ENVIRONMENT_SERVE_OPERATION, loadEnvironmentSource, } from "../application/environment/environmentSource.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; import { logCliCommand } from "../cliLogging.js"; import { parseEnvironmentName } from "../config/environmentName.js"; import { parseMcpHttpEndpoint, type McpHttpEndpoint, } from "../config/mcpHttpEndpoint.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { createLogger } from "../logger.js"; import { withCommandCancellation } from "./commandCancellation.js"; import type { CliInstance } from "./types.js"; /** Same envelope label as the other JSON-input commands and `parseCliJsonInput`. */ const cliError = (error: AnalysisError): JsonValue => ({ error: "Application workflow failed", ...projectAnalysisError(error), }); /** The listener announces its URL at info like `rea mcp --http`; an explicit REA_LOG_LEVEL still wins. */ const listenerLogger = ( environment: Readonly, logger: Logger, ): Logger => environment.REA_LOG_LEVEL === undefined ? createLogger("cli", "info") : logger; /** Resolve until the one-shot signal requests shutdown. */ const untilAborted = (signal: AbortSignal): Promise => new Promise((resolve) => { if (signal.aborted) resolve(undefined); else signal.addEventListener("abort", () => resolve(undefined), { once: true, }); }); /** A listener that could not bind or failed after binding, as the command's typed refusal. */ const transportError = ( endpoint: McpHttpEndpoint, cause: Error, ): AnalysisResourceConstraintError => new AnalysisResourceConstraintError( ENVIRONMENT_SERVE_OPERATION, "transport", cause.message, { host: endpoint.host, port: endpoint.port }, { cause, retryable: false, remediationAction: "Choose another --port or MCP_PORT, or stop the process using it.", }, ); interface ServeArguments { readonly manifest: string; readonly evidence: string; readonly host?: string | undefined; readonly port?: number | undefined; } /** Load both Evidence files, bind the listener and run until a signal ends it. */ const serveEnvironmentCommand = async ( input: ServeArguments, environment: Readonly, logger: Logger, signal: AbortSignal, ): Promise => { const endpoint = parseMcpHttpEndpoint(environment, { ...(input.host === undefined ? {} : { host: input.host }), ...(input.port === undefined ? {} : { port: input.port }), }); if (!endpoint.ok) return cliError(endpoint.error); const environmentName = parseEnvironmentName(environment); if (!environmentName.ok) return cliError(environmentName.error); const manifest = await parseCliJsonInput( input.manifest, ENVIRONMENT_SERVE_OPERATION, ); if (!manifest.ok) return manifest.error; const evidence = await parseCliJsonInput( input.evidence, ENVIRONMENT_SERVE_OPERATION, ); if (!evidence.ok) return evidence.error; const source = loadEnvironmentSource(manifest.value, evidence.value); if (!source.ok) return cliError(source.error); // Loaded here so registering the command catalog never loads the HTTP listener. const { serveEnvironment } = await import("../server/environment/serveEnvironment.js"); const { StreamableHttpListenError } = await import("../server/http/streamableHttpListenError.js"); // A listener that fails after binding ends the command instead of serving nothing. let failListener: (error: Error) => void = () => undefined; const listenerFailed = new Promise((resolve) => { failListener = resolve; }); let served: Awaited>; try { served = await serveEnvironment({ source: source.value, ...endpoint.value, ...(environmentName.value === undefined ? {} : { environmentName: environmentName.value }), logger: listenerLogger(environment, logger), onerror: (error) => failListener(error), }); } catch (cause: unknown) { if (!(cause instanceof StreamableHttpListenError)) throw cause; return cliError(transportError(endpoint.value, cause)); } let failure: Error | undefined; try { failure = await Promise.race([untilAborted(signal), listenerFailed]); } finally { await served.close(); } if (failure !== undefined) return cliError(transportError(endpoint.value, failure)); return { status: "stopped", url: served.url, environment: served.card.name, tools: served.card.capabilities.tools.length, evidence_id: source.value.evidenceId, manifest_evidence_id: source.value.manifestEvidenceId, }; }; interface ExportArguments { readonly manifest: string; readonly evidence: string; readonly directory: string; readonly packageTarball?: string | undefined; readonly name?: string | undefined; } /** The written files with sizes and the pinned package source, as the command prints them. */ const exportDocument = (result: EnvironmentExportResult): JsonValue => ({ status: result.status, directory: result.directory, name: result.name, tool_count: result.tool_count, evidence_id: result.evidence_id, manifest_evidence_id: result.manifest_evidence_id, package_source: result.package_source, files: result.files.map(({ path, bytes }) => ({ path, bytes })), }); /** Read each file once, authenticate the pair as the serve command does, then write the bundle from the same bytes. */ const exportEnvironmentCommand = async ( input: ExportArguments, ): Promise => { const inputs = await readEnvironmentExportInputs( input.manifest, input.evidence, ); if (!inputs.ok) return cliError(inputs.error); const source = loadEnvironmentSource( inputs.value.manifest.value, inputs.value.evidence.value, ENVIRONMENT_EXPORT_OPERATION, ); if (!source.ok) return cliError(source.error); const exported = await exportEnvironmentBundle({ ...inputs.value, source: source.value, directory: resolve(input.directory), name: input.name, packageTarball: input.packageTarball === undefined ? undefined : resolve(input.packageTarball), }); return exported.ok ? exportDocument(exported.value) : cliError(exported.error); }; const manifestArgument = z .string() .describe( "Environment manifest Evidence JSON path (inspect-analysis-view environment-manifest output)", ); const evidenceOption = z .string() .describe( "Parent capture Evidence JSON path (inspect-web-network-capture output) the manifest cites", ); /** Register the one-shot bundle export command. */ const registerExportCommand = (cli: CliInstance, logger: Logger): void => { cli.command(CLI_COMMANDS.environmentExport, { description: "Export an environment manifest and its capture Evidence as an agent environment bundle folder the agentenv framework runs with agent-env run", args: z.object({ manifest: manifestArgument, directory: z .string() .describe( "Bundle directory to create; must be absent or empty, nothing is overwritten", ), }), options: z.object({ evidence: evidenceOption, packageTarball: z .string() .optional() .describe( "Packed rea-agents tarball (npm pack) copied into the env folder and installed by the Dockerfile instead of the registry package pinned to this version", ), name: z .string() .optional() .describe( "Env folder and env id (kebab-case); default the manifest's name", ), }), run: ({ args, options }) => logCliCommand(logger, CLI_COMMANDS.environmentExport, () => exportEnvironmentCommand({ manifest: args.manifest, evidence: options.evidence, directory: args.directory, packageTarball: options.packageTarball, name: options.name, }), ), }); }; /** Register the long-running agent environment server command and the bundle export. */ export const registerEnvironmentCommands = ( cli: CliInstance, logger: Logger, environment: Readonly, ): void => { registerExportCommand(cli, logger); cli.command(CLI_COMMANDS.environmentServe, { description: "Serve an environment manifest as an agent environment that replays retained capture records over streamable HTTP until SIGINT or SIGTERM", args: z.object({ manifest: manifestArgument }), options: z.object({ evidence: evidenceOption, host: z .string() .min(1) .optional() .describe("Listener address; default MCP_HOST, else 127.0.0.1"), port: z .number() .int() .min(0) .max(65535) .optional() .describe( "Listener port; default MCP_PORT, else 0 for an ephemeral port", ), }), run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.environmentServe, () => serveEnvironmentCommand( { manifest: args.manifest, evidence: options.evidence, host: options.host, port: options.port, }, environment, logger, signal, ), ), ), }); };