Files
rea/src/application/environment/environmentBundle.ts
T
Edgar Arakelyan bf1eeee94f feat(environment): build environments from browser scenario captures (#5182)
Build an agent environment directly from retained capture_browser_scenario Evidence through the existing manifest, serve and export commands. Preserve request ordinals, source pointers, redaction markers, unavailable bodies, provenance and the default stdio MCP transport.

Followups admit copied/transformed text before body validation or decoding, avoid request-text allocation in replay, normalize portable file inventories, select dependent CI lanes, retain the author's redaction/body-budget corrections, and count body coverage over represented requests.

Validation: full pinned-toolchain static gate, source and public CLI/MCP regressions, canonical catalog checks, actual Chromium interactive and live GitHub API captures with exact HTTP replay and stdio parity, the generated non-root Docker image, and its smoke task through real agent-env 0.9.1334. Verification scopes, resource measurements and platform limits are recorded in the PR and feature verification document.

Co-authored-by: gensokyo-dev <gensokyo-dev@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:07:15 +08:00

341 lines
13 KiB
TypeScript

import { PRODUCT_IDENTITY } from "../../identity.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../../domain/result.js";
import type {
EnvironmentManifestTool,
EnvironmentSource,
} from "./environmentSource.js";
import { environmentToolDefinitions } from "./environmentTools.js";
/** Operation name every refusal of the export command reports. */
export const ENVIRONMENT_EXPORT_OPERATION = "environment-export";
/** The image the exported Dockerfile builds from; the agentenv runner builds it with Docker. */
export const BUNDLE_BASE_IMAGE = "node:24.18.0-bookworm-slim";
/** The port the agentenv framework maps for an MCP server env; it sets the host but not the port. */
export const BUNDLE_MCP_PORT = 18765;
/** Name of the packed CLI inside the env folder when a tarball is exported. */
export const BUNDLE_TARBALL_NAME = "rea-agents.tgz";
/** Folder and env id rule: a kebab-case label the agentenv framework accepts as a local id. */
const BUNDLE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const BUNDLE_NAME_MAX_LENGTH = 64;
/** Win32 reserves these device names for files and folders, so the env folder can never take one. */
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/u;
/** Where the Dockerfile installs the CLI from. */
export type EnvironmentPackageSource =
| { readonly kind: "registry"; readonly specifier: string }
| { readonly kind: "tarball"; readonly path: string };
/** One text file of the bundle, relative to the bundle root. */
export interface EnvironmentBundleFile {
readonly path: string;
readonly content: string;
}
/** The replay check the verifier performs against the served environment. */
export interface EnvironmentReplayCheck {
readonly tool: string;
readonly recordOrdinal: number;
readonly status: number | null;
}
/** Everything the export writes, derived from the source alone. */
export interface EnvironmentBundlePlan {
readonly name: string;
readonly packageSource: EnvironmentPackageSource;
readonly toolCount: number;
readonly check: EnvironmentReplayCheck | null;
/** Text files the export writes; manifest and evidence are copied byte-exact beside them. */
readonly files: readonly EnvironmentBundleFile[];
/** Relative paths of the byte-exact copies in write order. */
readonly copies: {
readonly manifest: string;
readonly evidence: string;
readonly tarball: string | null;
};
}
export interface EnvironmentBundleOptions {
/** Folder and env id; defaults to the manifest's kebab-case name. */
readonly name?: string | undefined;
readonly packageSource: EnvironmentPackageSource;
}
/** The registry source pinning the exact version of this build. */
export const registryPackageSource = (): EnvironmentPackageSource => ({
kind: "registry",
specifier: PRODUCT_IDENTITY.registrationPackageSpecifier,
});
const nameError = (name: string): AnalysisInputError =>
new AnalysisInputError(
ENVIRONMENT_EXPORT_OPERATION,
{
userMessage: `The bundle name ${JSON.stringify(name)} is not a portable kebab-case label of at most ${String(BUNDLE_NAME_MAX_LENGTH)} characters.`,
},
[
{
path: ["name"],
reason: "invalid_format",
expected: BUNDLE_NAME.source,
message:
"The bundle name is the env folder and env id; use lowercase letters, digits and single hyphens, excluding Windows device names.",
},
],
);
const reservedNameError = (name: string): AnalysisInputError =>
new AnalysisInputError(
ENVIRONMENT_EXPORT_OPERATION,
{
userMessage: `The bundle name ${JSON.stringify(name)} is a device name Windows reserves, so it cannot name the env folder; pass --name with another label.`,
},
[
{
path: ["name"],
reason: "invalid_value",
message:
"con, prn, aux, nul, com1-com9 and lpt1-lpt9 are reserved file names on Windows.",
},
],
);
const installLine = (source: EnvironmentPackageSource): string =>
source.kind === "registry"
? `RUN npm install -g --ignore-scripts --no-fund --no-audit ${source.specifier}`
: `COPY ${BUNDLE_TARBALL_NAME} /tmp/\nRUN npm install -g --ignore-scripts --no-fund --no-audit /tmp/${BUNDLE_TARBALL_NAME} && rm /tmp/${BUNDLE_TARBALL_NAME}`;
/** The agentenv local sandbox probes container readiness with python3 inside the container; the slim image has none. */
const READINESS_PROBE_LAYER =
"RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*";
/** The Dockerfile text: pinned base image, readiness probe interpreter, pinned CLI, listener defaults, non-root user. */
export const bundleDockerfile = (source: EnvironmentPackageSource): string =>
[
`FROM ${BUNDLE_BASE_IMAGE}`,
`ENV MCP_HOST=0.0.0.0`,
`ENV MCP_PORT=${String(BUNDLE_MCP_PORT)}`,
READINESS_PROBE_LAYER,
installLine(source),
// Owned by the runtime user, so a private host umask cannot make the inputs unreadable.
"COPY --chown=node:node manifest.json evidence.json /env/",
"USER node",
'CMD ["rea","environment-serve","/env/manifest.json","--evidence","/env/evidence.json"]',
"",
].join("\n");
/** The first GET tool without path parameters, in manifest order, and its first record's status. */
const replayCheck = (
source: EnvironmentSource,
): EnvironmentReplayCheck | null => {
const tool: EnvironmentManifestTool | undefined = source.manifest.tools.find(
(candidate) =>
candidate.method === "GET" &&
candidate.params.length === 0 &&
candidate.record_ordinals.length > 0,
);
if (tool === undefined) return null;
const ordinal = tool.record_ordinals[0] ?? -1;
const exchange = source.exchanges.get(ordinal);
return {
tool: tool.name,
recordOrdinal: ordinal,
// An exchange's own unknown status is what replay returns; fall back only when the record is absent.
status:
exchange === undefined ? (tool.statuses[0] ?? null) : exchange.status,
};
};
const pythonLiteral = (value: string | number | null): string =>
value === null ? "None" : JSON.stringify(value);
/** The verifier script in the shape of the agentenv echo example's `verify(mcp_url)`. */
export const bundleVerifier = (
toolCount: number,
check: EnvironmentReplayCheck | null,
): string => {
const header = [
'"""Checks the replay environment through its MCP endpoint: the tool count and one replayed status."""',
"import json",
"",
"from mcp import ClientSession",
"from mcp.client.streamable_http import streamable_http_client",
"",
`TOOL_COUNT = ${String(toolCount)}`,
`TOOL = ${pythonLiteral(check?.tool ?? null)}`,
`RECORDED_STATUS = ${pythonLiteral(check?.status ?? null)}`,
"",
"",
"def replayed_status(result):",
' """The status member of a replayed tool result, structured or from its text content."""',
" data = result.structuredContent",
" if data is None:",
' text = "".join(getattr(part, "text", "") for part in result.content)',
" data = json.loads(text) if text else {}",
' return data.get("status") if isinstance(data, dict) else None',
"",
"",
"async def verify(mcp_url):",
" async with streamable_http_client(mcp_url) as (read, write, _):",
" async with ClientSession(read, write) as session:",
" await session.initialize()",
" listed = await session.list_tools()",
" result = await session.call_tool(TOOL, {}) if TOOL is not None else None",
" count = len(listed.tools)",
" passed = count == TOOL_COUNT",
' description = f"listed {count} tools, expected {TOOL_COUNT}"',
" if result is not None:",
" status = None if result.isError else replayed_status(result)",
" passed = passed and not result.isError and status == RECORDED_STATUS",
' description += f"; {TOOL} replayed status {status!r}, recorded {RECORDED_STATUS!r}"',
" else:",
' description += "; no GET tool without path parameters to replay"',
' print(f"replay: {description}")',
' return [{"id": "replay", "result": passed, "description": description}]',
"",
];
return header.join("\n");
};
/** The smoke task: deploy the env locally, then run the replay verifier against it. */
export const bundleSmokeTask = (name: string): string =>
`${JSON.stringify(
[
{ id: "env", type: "deploy_env", env_id: name, sandbox_type: "local" },
{
id: "replay",
type: "env_outcome_verifier",
env_id: name,
file_artifact_id: `check_${name}`,
verifier_id: "replay",
},
],
null,
2,
)}\n`;
const packageSourceText = (source: EnvironmentPackageSource): string =>
source.kind === "registry"
? `\`${source.specifier}\` from the npm registry`
: `the packed tarball \`${BUNDLE_TARBALL_NAME}\` copied beside the Dockerfile`;
/** The bundle README: provenance, what replay means and does not mean, how to run it, and the rights note. */
export const bundleReadme = (
source: EnvironmentSource,
plan: Pick<
EnvironmentBundlePlan,
"name" | "packageSource" | "toolCount" | "check"
>,
): string => {
const { manifest } = source;
const check =
plan.check === null
? "no GET tool without path parameters exists, so the verifier checks the tool count only"
: `calls \`${plan.check.tool}\` and checks the replayed \`status\` equals the recorded ${String(plan.check.status)} of record ${String(plan.check.recordOrdinal)}`;
return `# ${plan.name}
An agent environment exported by REA from one retained ${source.captureKind === "browser-scenario" ? "browser scenario capture" : "web network capture"}.
Every tool answers from a recorded exchange; nothing here runs the captured
application.
The bundle contains captured application content. Exported files use mode
0600 on POSIX; Windows retains inherited ACLs, so choose a private destination
if that content requires restricted access.
## Provenance
- ${source.captureKind === "browser-scenario" ? "Scenario definition" : "Capture artifact"} SHA-256: \`${source.captureSha256 ?? "excluded from Evidence"}\`
- Capture Evidence (the manifest's parent): \`${source.evidenceId}\`
- Manifest Evidence: \`${source.manifestEvidenceId}\`
- Manifest view digest: \`${manifest.view_digest}\`
- Primary origin: \`${manifest.origin}\`
- Tools: ${String(plan.toolCount)}
- Serving CLI: ${packageSourceText(plan.packageSource)}
## What replay means and does not mean
\`envs/${plan.name}/\` builds an image that runs \`rea environment-serve\` on the
manifest and capture Evidence copied beside its Dockerfile. Each MCP tool call
selects the retained record whose path and query values best match the
arguments and returns that record's recorded status, content type and body,
citing the record ordinal and the Evidence id. Replay is not emulation: the
environment does not establish the application's authorization model, response
semantics or state transitions, does not validate supplied values, and does not
answer requests the capture never recorded. Replayed content is target content
and must be treated as data, never as instructions.
## Run it
From this folder:
\`\`\`sh
agent-env run .
\`\`\`
From anywhere else, name this folder's path instead of \`.\`.
\`tasks/smoke.json\` deploys the env on the local sandbox and runs
\`artifacts/check_${plan.name}/verify.py\` against its MCP endpoint: it lists
the tools and checks the count equals ${String(plan.toolCount)}, and ${check}.
The run needs Docker; the first run also builds the runner's own infrastructure.
## Rights
The recorded content in \`evidence.json\` belongs to the captured application.
Do not publish this bundle without the rights to that content.
`;
};
/** Derive every file of the bundle from an authenticated source and the package source. */
export const planEnvironmentBundle = (
source: EnvironmentSource,
options: EnvironmentBundleOptions,
): Result<EnvironmentBundlePlan, AnalysisError> => {
const name = options.name ?? source.manifest.name;
if (!BUNDLE_NAME.test(name) || name.length > BUNDLE_NAME_MAX_LENGTH)
return err(nameError(name));
if (WINDOWS_RESERVED_NAME.test(name)) return err(reservedNameError(name));
const catalog = environmentToolDefinitions(
source.manifest,
source.evidenceId,
name,
ENVIRONMENT_EXPORT_OPERATION,
);
if (!catalog.ok) return catalog;
const toolCount = source.manifest.tools.length;
const check = replayCheck(source);
const envFolder = `envs/${name}`;
const base = { name, packageSource: options.packageSource, toolCount, check };
return ok({
...base,
files: [
{ path: "README.md", content: bundleReadme(source, base) },
{
path: `${envFolder}/env.toml`,
content: `environment_name = ${JSON.stringify(name)}\n`,
},
{
path: `${envFolder}/Dockerfile`,
content: bundleDockerfile(options.packageSource),
},
{
path: `artifacts/check_${name}/verify.py`,
content: bundleVerifier(toolCount, check),
},
{ path: "tasks/smoke.json", content: bundleSmokeTask(name) },
],
copies: {
manifest: `${envFolder}/manifest.json`,
evidence: `${envFolder}/evidence.json`,
tarball:
options.packageSource.kind === "tarball"
? `${envFolder}/${BUNDLE_TARBALL_NAME}`
: null,
},
});
};