Files
reverse-engineering-anything/src/application/environment/environmentBuild.ts
T
Edgar ArakelyanandClaude Opus 5.5 d2e9b68043 feat(browser): save values from UI steps and send them as {saved:name}
- click and press steps take save_response {method, path, save}: the
  recorder waits, before the step acts, for the 2xx JSON response to the
  request it triggers and saves values from it
- {saved:name} sends a saved value in a request path (and its query), a goto
  URL, and a request's JSON body, where a string that is only the
  placeholder keeps the saved JSON type; header values and Basic
  credentials parts take {source: "saved", name}
- Basic credentials take parts: [username, password], each a literal,
  secret, saved, cookie or local storage value, so the schema repeats the
  part union once; path_params and json_values are gone, which keeps the
  compact MCP catalog under its 1 MiB frame
- validation refuses a saved value no earlier step saves; other braces are
  sent as written
- Evidence records an empty save list as absent, so scenarios that save
  nothing record the same parameters and scenario_sha256 as before
- environment-build warns about saved values named like credentials that
  are not marked secret (credential-named-saved-values)
- the skill writes the way the UI does and falls back to request steps;
  skill version 50

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:43:40 -07:00

422 lines
15 KiB
TypeScript

import { setImmediate } from "node:timers/promises";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { summarizeEnvironmentCoverage } from "../../domain/analysisView/environmentCoverageView.js";
import {
TEMPLATE_RULES,
type TemplateRule,
} from "../../domain/analysisView/environmentManifestRules.js";
import type { Evidence } from "../../domain/evidence.js";
import type { EnvironmentBuildReport } from "../../domain/environmentBundleExport.js";
import { err, ok, type Result } from "../../domain/result.js";
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
import {
ENVIRONMENT_EXPORT_OPERATION,
isPortableBundleName,
planEnvironmentBundle,
registryPackageSource,
type EnvironmentPackageSource,
} from "./environmentBundle.js";
import {
anonymousOrigin,
credentialScanWarnings,
savedCredentialWarnings,
nameFromCaptureFile,
type EnvironmentBuildWarning,
} from "./environmentBuildReport.js";
import {
environmentExportEvidenceFile,
exportEnvironmentBundle,
packageTarballBytes,
type EnvironmentExportFile,
type WrittenBundleFile,
} from "./environmentExport.js";
import {
composeEnvironmentSource,
parseCaptureEvidence,
parseEvidenceFile,
parseViewEvidence,
} from "./environmentSource.js";
import { prepareServedState } from "./environmentState.js";
/** Operation name the build command's own refusals report; each stage keeps its own. */
export const ENVIRONMENT_BUILD_OPERATION = "environment-build";
/** The opt-in rules the rebuild pipeline selects for every published capture-built environment. */
export const DEFAULT_BUILD_TEMPLATE_RULES: readonly TemplateRule[] = [
"after-uid-literal",
"opaque-token",
"sibling-variance",
"prefix-variance",
];
/** What the capture argument is, decided from its file name before anything is read. */
export type EnvironmentBuildCaptureInput = "har" | "evidence";
/** A HAR file is inspected; a JSON file must already be capture Evidence. */
export const classifyBuildCapture = (
path: string,
): Result<EnvironmentBuildCaptureInput, AnalysisInputError> => {
if (/\.har$/iu.test(path)) return ok("har");
if (/\.json$/iu.test(path)) return ok("evidence");
return err(
new AnalysisInputError(
ENVIRONMENT_BUILD_OPERATION,
{
userMessage: `${path} is neither a .har capture nor a .json capture Evidence file.`,
},
[
{
path: ["capture"],
reason: "invalid_value",
expected: ".har or .json",
message:
"Pass a HAR file, or capture Evidence JSON from inspect-web-network-capture (HAR) or capture-browser-scenario.",
},
],
),
);
};
/** Rules in the views' fixed order, so the report and manifest list them canonically. */
export const canonicalTemplateRules = (
rules: readonly TemplateRule[],
): TemplateRule[] => TEMPLATE_RULES.filter((rule) => rules.includes(rule));
export interface EnvironmentBuildInput {
/** The capture Evidence the bundle ships, read or serialized once. */
readonly capture: EnvironmentExportFile;
readonly captureInput: EnvironmentBuildCaptureInput;
/** The capture argument, absolute; names a loopback capture's environment. */
readonly capturePath: string;
readonly templateRules: readonly TemplateRule[];
/** Absolute bundle root, absent or empty; may be absent only for a dry run. */
readonly directory?: string | undefined;
readonly name?: string | undefined;
readonly packageTarball?: string | undefined;
readonly dryRun: boolean;
/** Called as each stage starts. */
readonly progress?: ((stage: string) => void) | undefined;
/** Checked between stages; a build cancelled before the write writes nothing. */
readonly signal?: AbortSignal | undefined;
/** Refuse an existing directory and never create its parent, as MCP output directories require. */
readonly exclusiveDestination?: boolean | undefined;
}
/** The report environment-build prints and export_environment_bundle returns. */
export type { EnvironmentBuildReport };
const captureOperations = new Set([
"inspect_web_network_capture",
"capture_browser_scenario",
]);
const harDocument = (value: unknown): boolean => {
if (typeof value !== "object" || value === null) return false;
const log: unknown = Reflect.get(value, "log");
return (
typeof log === "object" &&
log !== null &&
Array.isArray(Reflect.get(log, "entries"))
);
};
const harAsJson = (path: string): AnalysisInputError =>
new AnalysisInputError(
ENVIRONMENT_BUILD_OPERATION,
{
userMessage: `${path} is a HAR document, not capture Evidence; save it as a .har file to build from it, or inspect it as a HAR first (inspect_web_network_capture) and build from that Evidence.`,
},
[
{
path: ["capture"],
reason: "invalid_value",
expected: "capture Evidence JSON, or a .har file",
message:
"A .json capture must be capture Evidence; a HAR document is inspected only from a .har file.",
},
],
);
const repeatedRules = (): AnalysisInputError =>
new AnalysisInputError(ENVIRONMENT_BUILD_OPERATION, undefined, [
{
path: ["template_rules"],
reason: "invalid_value",
message: "template_rules must not repeat a rule",
},
]);
/** A cancellation, naming the bundle when it arrived after the bundle was written. */
const cancelled = async (
signal: AbortSignal | undefined,
written?: string,
): Promise<AnalysisCancelledError | undefined> => {
// The stages are synchronous; yield so a pending interrupt is dispatched before checking.
if (signal !== undefined) await setImmediate();
return signal?.aborted === true
? new AnalysisCancelledError(ENVIRONMENT_BUILD_OPERATION, {
cause: signal.reason,
...(written === undefined
? {}
: {
userMessage: `The build was cancelled after it wrote the bundle at ${written}.`,
diagnostics: { output_directory: written, bundle_written: true },
}),
})
: undefined;
};
const notCaptureEvidence = (operation: string): AnalysisInputError =>
new AnalysisInputError(
ENVIRONMENT_BUILD_OPERATION,
{
userMessage: `The capture file is ${operation} Evidence, not capture Evidence.`,
},
[
{
path: ["capture", "operation"],
reason: "invalid_value",
expected: "inspect_web_network_capture or capture_browser_scenario",
message:
"Pass a HAR file, or capture Evidence JSON from inspect-web-network-capture (HAR) or capture-browser-scenario.",
},
],
);
const unnamed = (path: string): AnalysisInputError =>
new AnalysisInputError(
ENVIRONMENT_BUILD_OPERATION,
{
userMessage: `The capture's origin is a loopback or IP address, and ${path === "" ? "retained Evidence has no file name to take one from" : `${path} yields no portable environment name (a kebab-case label of at most 64 characters that is not a Windows device name)`}; name the environment.`,
},
[
{
path: ["name"],
reason: "invalid_value",
message:
"Name the environment: a kebab-case label of lowercase letters, digits and single hyphens.",
},
],
);
const missingDirectory = (): AnalysisInputError =>
new AnalysisInputError(
ENVIRONMENT_BUILD_OPERATION,
{ userMessage: "Name the bundle directory, or plan a dry run." },
[
{
path: ["directory"],
reason: "invalid_value",
message: "The bundle directory may be omitted only with --dry-run.",
},
],
);
/** The given name; else the manifest's, unless its origin is anonymous, when the capture file names it. */
const environmentName = (
input: EnvironmentBuildInput,
manifest: { readonly name: string; readonly origin: string },
warnings: EnvironmentBuildWarning[],
): Result<string | undefined, AnalysisInputError> => {
if (input.name !== undefined) return ok(input.name);
if (!anonymousOrigin(manifest.origin)) return ok(undefined);
const name = nameFromCaptureFile(input.capturePath);
if (!isPortableBundleName(name)) return err(unnamed(input.capturePath));
warnings.push({
code: "name-from-file",
pointers: [],
message: `The capture's origin ${manifest.origin} names no application, so the environment is named ${name} after the capture file; name it explicitly to choose another.`,
});
return ok(name);
};
/** A path as one POSIX shell word. */
const shellWord = (path: string): string =>
/^[\w./@%+=:,-]+$/u.test(path) ? path : `'${path.replaceAll("'", "'\\''")}'`;
const packageSourceValue = (
source: EnvironmentPackageSource,
): EnvironmentBuildReport["package_source"] =>
source.kind === "registry"
? { kind: "registry", specifier: source.specifier }
: { kind: "tarball", path: source.path };
/**
* Build an agent environment bundle from capture Evidence: project the
* manifest from the in-memory capture, authenticate the pair, prepare the
* state the bundle's server derives and summarize what it serves, then write
* the bundle with the export unless this is a dry run. Every file equals what
* inspect-analysis-view and environment-export produce from the same capture.
*/
export const buildEnvironment = async (
input: EnvironmentBuildInput,
): Promise<Result<EnvironmentBuildReport, AnalysisError>> => {
if (input.directory === undefined && !input.dryRun)
return err(missingDirectory());
if (new Set(input.templateRules).size !== input.templateRules.length)
return err(repeatedRules());
if (input.captureInput === "evidence" && harDocument(input.capture.value))
return err(harAsJson(input.capturePath));
const evidence = parseEvidenceFile(
ENVIRONMENT_BUILD_OPERATION,
"evidence",
input.capture.value,
);
if (!evidence.ok) return evidence;
if (!captureOperations.has(evidence.value.operation))
return err(notCaptureEvidence(evidence.value.operation));
const capture = evidence.value;
const templateRules = canonicalTemplateRules(input.templateRules);
input.progress?.("manifest");
const projected = await inspectAnalysisView(
{
source: { kind: "retained-evidence", evidence_id: capture.evidence_id },
view: { kind: "environment-manifest", template_rules: templateRules },
},
(id) => (id === capture.evidence_id ? capture : undefined),
);
if (!projected.ok) return projected;
const stopped = await cancelled(input.signal);
if (stopped !== undefined) return err(stopped);
const manifest = await environmentExportEvidenceFile(
"manifest",
"manifest.json",
projected.value,
ENVIRONMENT_BUILD_OPERATION,
);
if (!manifest.ok) return manifest;
const parsedManifest = parseViewEvidence(
ENVIRONMENT_BUILD_OPERATION,
"manifest",
manifest.value.value,
"environment-manifest",
);
if (!parsedManifest.ok) return parsedManifest;
const recorded = parseCaptureEvidence(
ENVIRONMENT_BUILD_OPERATION,
input.capture.value,
);
if (!recorded.ok) return recorded;
const source = composeEnvironmentSource(
parsedManifest.value,
recorded.value,
ENVIRONMENT_BUILD_OPERATION,
);
if (!source.ok) return source;
const warnings: EnvironmentBuildWarning[] = [];
const named = environmentName(input, source.value.manifest, warnings);
if (!named.ok) return named;
const name = named.value ?? source.value.manifest.name;
const beforeState = await cancelled(input.signal);
if (beforeState !== undefined) return err(beforeState);
input.progress?.("state");
// Prepared as the export prepares it, so its report and the bundle's README match the three-step path.
const served = prepareServedState(source.value, input.capture.value, {
name,
operation: ENVIRONMENT_EXPORT_OPERATION,
});
warnings.push(
...credentialScanWarnings(recorded.value),
...savedCredentialWarnings(capture.parameters),
);
const packageSource: EnvironmentPackageSource =
input.packageTarball === undefined
? registryPackageSource()
: { kind: "tarball", path: input.packageTarball };
const base = {
name,
capture: {
evidence_id: capture.evidence_id,
operation: capture.operation,
records: recorded.value.records.length,
input: input.captureInput,
},
manifest: {
evidence_id: source.value.manifestEvidenceId,
tools: source.value.manifest.tools.length,
excluded_groups: source.value.manifest.excluded.length,
template_rules: templateRules,
},
state: served.report,
coverage:
served.state === undefined
? null
: summarizeEnvironmentCoverage({
views: served.state.views.values(),
writes: served.state.writes.values(),
}),
warnings,
};
if (input.dryRun) {
const beforePlan = await cancelled(input.signal);
if (beforePlan !== undefined) return err(beforePlan);
const plan = planEnvironmentBundle(source.value, {
name,
packageSource,
served: () => served,
});
if (!plan.ok) return plan;
const files: WrittenBundleFile[] = [
...plan.value.files.map((file) => ({
path: file.path,
bytes: Buffer.byteLength(file.content),
})),
{ path: plan.value.copies.manifest, bytes: manifest.value.bytes.length },
{ path: plan.value.copies.evidence, bytes: input.capture.bytes.length },
];
if (
plan.value.copies.tarball !== null &&
input.packageTarball !== undefined
) {
const bytes = await packageTarballBytes(input.packageTarball);
if (!bytes.ok) return bytes;
files.push({ path: plan.value.copies.tarball, bytes: bytes.value });
}
const afterPlan = await cancelled(input.signal);
if (afterPlan !== undefined) return err(afterPlan);
return ok({
status: "planned",
directory: input.directory ?? null,
...base,
tool_count: plan.value.toolCount,
package_source: packageSourceValue(packageSource),
files,
next: [],
});
}
const directory = input.directory;
if (directory === undefined) return err(missingDirectory());
const beforeWrite = await cancelled(input.signal);
if (beforeWrite !== undefined) return err(beforeWrite);
input.progress?.("bundle");
const exported = await exportEnvironmentBundle({
manifest: manifest.value,
evidence: input.capture,
source: source.value,
served,
directory,
name,
packageTarball: input.packageTarball,
exclusiveDestination: input.exclusiveDestination,
});
if (!exported.ok) return exported;
const report: EnvironmentBuildReport = {
status: "built",
directory,
...base,
tool_count: exported.value.tool_count,
package_source: packageSourceValue(packageSource),
files: [...exported.value.files],
next: [`agent-env run ${shellWord(directory)}`],
};
// A cancellation that arrives during the write still names the bundle it wrote.
const afterWrite = await cancelled(input.signal, directory);
return afterWrite === undefined ? ok(report) : err(afterWrite);
};