Serve retained capture Evidence as a bounded MCP replay environment with discovery. Preserve default stdio, caller-selected facts and complete source Evidence. Fix numeric fidelity, argument identity, startup cancellation and bounded discovery, query/path matching and output metadata. Verify real recorded API data and public CLI/MCP workflows; preserve explicit gaps for Windows and third-party hosts. Co-authored-by: Edgar Arakelyan <[email protected]> Co-authored-by: gensokyo-dev <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import {
|
|
analysisViewResultSchema,
|
|
type AnalysisViewResult,
|
|
} from "../../domain/analysisView/analysisView.js";
|
|
import { readRecordedRequestLine } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import {
|
|
AnalysisInputError,
|
|
type AnalysisInputIssue,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import { parseEvidence, type Evidence } from "../../domain/evidence.js";
|
|
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
|
import { err, ok, type Result } from "../../domain/result.js";
|
|
import {
|
|
webNetworkCaptureSchema,
|
|
type WebNetworkCaptureRecord,
|
|
} from "../../domain/webNetworkCapture.js";
|
|
|
|
/** Operation name every refusal of the serve command reports. */
|
|
export const ENVIRONMENT_SERVE_OPERATION = "environment-serve";
|
|
|
|
export type EnvironmentManifest = Extract<
|
|
AnalysisViewResult,
|
|
{ kind: "environment-manifest" }
|
|
>;
|
|
export type EnvironmentManifestTool = EnvironmentManifest["tools"][number];
|
|
|
|
/** One retained parent record reduced to what a replay returns. */
|
|
export interface RecordedExchange {
|
|
readonly ordinal: number;
|
|
readonly path: string;
|
|
/** WHATWG query spelling including `?`, or null when the request had none. */
|
|
readonly query: string | null;
|
|
readonly status: number | null;
|
|
readonly contentType: string | null;
|
|
/** Producer-decoded response text, or null when the record retained none. */
|
|
readonly bodyText: string | null;
|
|
/** Set when the retained bytes are not UTF-8, so `bodyText` holds their base64 rather than a lossy decoding. */
|
|
readonly bodyEncoding?: "base64";
|
|
/** Set when only a prefix of a large base64 body was decoded; replay reports it truncated. */
|
|
readonly bodyCut?: true;
|
|
}
|
|
|
|
/** Replayed bodies larger than this are cut at a UTF-8 boundary and flagged. */
|
|
export const REPLAY_BODY_LIMIT_BYTES = 256 * 1024;
|
|
|
|
/** Base64 characters that decode to just over the replay limit, so a cut prefix still fills it. */
|
|
const BASE64_PREFIX_CHARS = Math.ceil((REPLAY_BODY_LIMIT_BYTES + 4) / 3) * 4;
|
|
|
|
/** A manifest and the parent capture it cites, authenticated and cross-checked. */
|
|
export interface EnvironmentSource {
|
|
readonly manifest: EnvironmentManifest;
|
|
readonly manifestEvidenceId: string;
|
|
readonly evidenceId: string;
|
|
/** Parent records cited by at least one tool, keyed by producer ordinal. */
|
|
readonly exchanges: ReadonlyMap<number, RecordedExchange>;
|
|
}
|
|
|
|
const inputError = (
|
|
issues: readonly AnalysisInputIssue[],
|
|
userMessage?: string,
|
|
): AnalysisInputError =>
|
|
new AnalysisInputError(
|
|
ENVIRONMENT_SERVE_OPERATION,
|
|
userMessage === undefined ? undefined : { userMessage },
|
|
issues,
|
|
);
|
|
|
|
/** Authenticate one Evidence file; schema failures name the file role in their paths. */
|
|
const parseEvidenceFile = (
|
|
role: "manifest" | "evidence",
|
|
value: unknown,
|
|
): Result<Evidence, AnalysisError> => {
|
|
try {
|
|
return ok(parseEvidence(value));
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof z.ZodError)) throw cause;
|
|
return err(
|
|
inputError(
|
|
projectInputIssues(cause.issues, value).map((issue) => ({
|
|
...issue,
|
|
path: [role, ...issue.path],
|
|
})),
|
|
`The ${role} file is not authenticated REA Evidence JSON.`,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
const contentTextSchema = z.object({
|
|
response: z.object({
|
|
content: z.object({
|
|
text: z.unknown().optional(),
|
|
encoding: z.unknown().optional(),
|
|
}),
|
|
}),
|
|
});
|
|
|
|
/** Strict, and keeping a leading byte-order mark, so replay returns the recorded text unchanged. */
|
|
const strictUtf8 = (): TextDecoder =>
|
|
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
|
|
/** The retained response text of one record, decoding HAR base64 content when the decoder kept it as valid UTF-8. */
|
|
const recordedBody = (
|
|
record: WebNetworkCaptureRecord,
|
|
): Pick<RecordedExchange, "bodyText" | "bodyEncoding"> => {
|
|
const content = contentTextSchema.safeParse(record.reported);
|
|
if (!content.success) return { bodyText: null };
|
|
const { text, encoding } = content.data.response.content;
|
|
if (typeof text !== "string") return { bodyText: null };
|
|
if (encoding !== "base64") return { bodyText: text };
|
|
const binary = record.binary_fields.find(
|
|
(field) =>
|
|
field.pointer === "/response/content/text" && field.state === "retained",
|
|
);
|
|
if (binary === undefined || binary.content_base64 === null)
|
|
return { bodyText: null };
|
|
// Replay returns at most REPLAY_BODY_LIMIT_BYTES, so a large body is decoded only that far.
|
|
const cut = binary.content_base64.length > BASE64_PREFIX_CHARS;
|
|
const encoded = cut
|
|
? binary.content_base64.slice(0, BASE64_PREFIX_CHARS)
|
|
: binary.content_base64;
|
|
const flags = cut ? { bodyCut: true as const } : {};
|
|
try {
|
|
// A cut prefix may end inside a character; streaming leaves that tail undecoded.
|
|
const text = strictUtf8().decode(Buffer.from(encoded, "base64"), {
|
|
stream: cut,
|
|
});
|
|
return { bodyText: text, ...flags };
|
|
} catch {
|
|
// Binary or non-UTF-8 bytes: keep them exactly, as base64, instead of inventing replacement characters.
|
|
return { bodyText: encoded, bodyEncoding: "base64", ...flags };
|
|
}
|
|
};
|
|
|
|
const recordedExchange = (
|
|
record: WebNetworkCaptureRecord,
|
|
): RecordedExchange | undefined => {
|
|
const line = readRecordedRequestLine(record.reported, true, () => true);
|
|
if (line === null || line.kind !== "line") return undefined;
|
|
return {
|
|
ordinal: record.ordinal,
|
|
path: line.path,
|
|
query: line.query === undefined || line.query === "" ? null : line.query,
|
|
status: line.status,
|
|
contentType: line.rawMime === "" ? null : line.rawMime,
|
|
...recordedBody(record),
|
|
};
|
|
};
|
|
|
|
/** Authenticate the manifest file and require the environment-manifest view kind. */
|
|
const parseManifestEvidence = (
|
|
value: unknown,
|
|
): Result<
|
|
{ readonly manifest: EnvironmentManifest; readonly evidenceId: string },
|
|
AnalysisError
|
|
> => {
|
|
const evidence = parseEvidenceFile("manifest", value);
|
|
if (!evidence.ok) return evidence;
|
|
const view = analysisViewResultSchema.safeParse(
|
|
evidence.value.normalized_result,
|
|
);
|
|
if (
|
|
evidence.value.operation !== "inspect_analysis_view" ||
|
|
!view.success ||
|
|
view.data.kind !== "environment-manifest"
|
|
)
|
|
return err(
|
|
inputError(
|
|
[
|
|
{
|
|
path: ["manifest", "normalized_result", "kind"],
|
|
reason: "invalid_value",
|
|
expected: "environment-manifest",
|
|
message:
|
|
"The manifest file must be inspect_analysis_view Evidence of kind environment-manifest.",
|
|
},
|
|
],
|
|
"The manifest file is not environment-manifest view Evidence.",
|
|
),
|
|
);
|
|
return ok({ manifest: view.data, evidenceId: evidence.value.evidence_id });
|
|
};
|
|
|
|
/** Authenticate the evidence file and require complete capture Evidence. */
|
|
const parseCaptureEvidence = (
|
|
value: unknown,
|
|
): Result<
|
|
{ readonly records: readonly WebNetworkCaptureRecord[]; evidenceId: string },
|
|
AnalysisError
|
|
> => {
|
|
const evidence = parseEvidenceFile("evidence", value);
|
|
if (!evidence.ok) return evidence;
|
|
const capture = webNetworkCaptureSchema.safeParse(
|
|
evidence.value.normalized_result,
|
|
);
|
|
if (
|
|
evidence.value.operation !== "inspect_web_network_capture" ||
|
|
!capture.success
|
|
)
|
|
return err(
|
|
inputError(
|
|
[
|
|
{
|
|
path: ["evidence", "operation"],
|
|
reason: "invalid_value",
|
|
expected: "inspect_web_network_capture",
|
|
message:
|
|
"The evidence file must be complete inspect_web_network_capture Evidence.",
|
|
},
|
|
],
|
|
"The evidence file is not web network capture Evidence.",
|
|
),
|
|
);
|
|
// The manifest view projects HAR captures only, and replay reads HAR's response fields.
|
|
if (capture.data.format !== "har")
|
|
return err(
|
|
inputError(
|
|
[
|
|
{
|
|
path: ["evidence", "normalized_result", "format"],
|
|
reason: "invalid_value",
|
|
expected: "har",
|
|
message: `Agent environments replay HAR captures; this capture's format is ${capture.data.format}.`,
|
|
},
|
|
],
|
|
"The evidence file is not a HAR capture.",
|
|
),
|
|
);
|
|
return ok({
|
|
records: capture.data.records,
|
|
evidenceId: evidence.value.evidence_id,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Reduce every record a tool cites to its replayable exchange. Authentication
|
|
* already proves the capture is the manifest's parent, so every cited ordinal
|
|
* is a record with a request line; a record that still yields none is left
|
|
* out and the tool refuses at call time.
|
|
*/
|
|
const indexExchanges = (
|
|
manifest: EnvironmentManifest,
|
|
records: readonly WebNetworkCaptureRecord[],
|
|
): ReadonlyMap<number, RecordedExchange> => {
|
|
const byOrdinal = new Map(records.map((record) => [record.ordinal, record]));
|
|
const exchanges = new Map<number, RecordedExchange>();
|
|
for (const tool of manifest.tools)
|
|
for (const ordinal of tool.record_ordinals) {
|
|
if (exchanges.has(ordinal)) continue;
|
|
const record = byOrdinal.get(ordinal);
|
|
const exchange =
|
|
record === undefined ? undefined : recordedExchange(record);
|
|
if (exchange !== undefined) exchanges.set(ordinal, exchange);
|
|
}
|
|
return exchanges;
|
|
};
|
|
|
|
/** Load the manifest Evidence and its parent capture Evidence, refusing any pair that does not belong together. */
|
|
export const loadEnvironmentSource = (
|
|
manifestValue: unknown,
|
|
evidenceValue: unknown,
|
|
): Result<EnvironmentSource, AnalysisError> => {
|
|
const manifest = parseManifestEvidence(manifestValue);
|
|
if (!manifest.ok) return manifest;
|
|
const capture = parseCaptureEvidence(evidenceValue);
|
|
if (!capture.ok) return capture;
|
|
const parentId = manifest.value.manifest.parent_evidence_id;
|
|
if (parentId !== capture.value.evidenceId)
|
|
return err(
|
|
inputError(
|
|
[
|
|
{
|
|
path: ["evidence", "evidence_id"],
|
|
reason: "invalid_value",
|
|
expected: parentId,
|
|
message: `The manifest cites parent Evidence ${parentId}; the evidence file is ${capture.value.evidenceId}.`,
|
|
},
|
|
],
|
|
"The manifest was projected from a different capture than the supplied evidence file.",
|
|
),
|
|
);
|
|
return ok({
|
|
manifest: manifest.value.manifest,
|
|
manifestEvidenceId: manifest.value.evidenceId,
|
|
evidenceId: capture.value.evidenceId,
|
|
exchanges: indexExchanges(manifest.value.manifest, capture.value.records),
|
|
});
|
|
};
|