mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(environment)!: keep unsupported HAR body encodings opaque
Preserve declared encodings in capture Evidence and distinguish unsupported response decoding from an absent body in shared replay and state extraction. Use the production HAR decoder in replay regressions instead of synthesizing its binary sidecars. BREAKING CHANGE: replay metadata body_encoding can now be "unsupported". Clients must treat its null body as unavailable decoded content while body_recorded remains true; the cited Evidence retains the encoded source.
This commit is contained in:
1 parent
34563fcc9b
commit
a54464548e
7 files changed
+134
-42
No files matched your search
@@ -215,7 +215,11 @@ text with `truncated: true`. `body_recorded` is false when the capture kept the
|
||||
response's status and content type but no body; `body` is then `null`, which
|
||||
is not a recorded JSON `null`. `body_encoding` is `base64` when the capture
|
||||
retained response bytes that are not valid UTF-8; `body` is then exactly those
|
||||
bytes in base64, never a lossy decoding. A retained base64 body is decoded only as far as the 256 KiB a reply can carry, and a leading byte-order mark is kept. For a `base64` body the limit applies to the recorded bytes, not their longer base64 spelling, and a cut keeps whole base64 groups. Request bodies passed to `POST`, `PUT` and `PATCH`
|
||||
bytes in base64, never a lossy decoding. An unsupported declared HAR encoding
|
||||
returns `body: null`, `body_recorded: true` and `body_encoding: "unsupported"`;
|
||||
the original encoding and encoded text remain in the cited capture Evidence.
|
||||
Such bodies supply no decoded JSON facts to state extraction.
|
||||
A retained base64 body is decoded only as far as the 256 KiB a reply can carry, and a leading byte-order mark is kept. For a `base64` body the limit applies to the recorded bytes, not their longer base64 spelling, and a cut keeps whole base64 groups. Request bodies passed to `POST`, `PUT` and `PATCH`
|
||||
tools are accepted and ignored: nothing is written, and no call changes what a
|
||||
later call returns. Arguments the tool's input schema does not name are
|
||||
refused by the MCP server as invalid arguments, so a misspelled parameter never
|
||||
|
||||
@@ -59,7 +59,9 @@ later increment.
|
||||
`recorded_line_truncated`; an explicit empty query is `?`), `body_recorded` (false
|
||||
when the capture kept no response body, so a `null` body is not mistaken for
|
||||
a recorded one), `body_encoding` (`base64` when the retained bytes are not
|
||||
UTF-8, so they are returned exactly rather than decoded lossily) and
|
||||
UTF-8, so they are returned exactly rather than decoded lossily;
|
||||
`unsupported` with a null body and `body_recorded: true` when a declared HAR
|
||||
encoding cannot be decoded; the cited capture retains its original text and encoding) and
|
||||
`truncated` (a large retained base64 body is decoded only up to that limit),
|
||||
as both
|
||||
structured content and text, with REA's existing target-content provenance
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
analysisViewCaptureEvidence,
|
||||
environmentManifestCaptureFixture,
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import { analysisViewCaptureEvidence } from "../../../tests/fixtures/analysisView.js";
|
||||
import { historicalHar } from "../../../tests/fixtures/historicalHar.js";
|
||||
import { decodeHarCapture } from "../../browser/history/HarCapture.js";
|
||||
import { webNetworkCaptureSchema } from "../../domain/webNetworkCapture.js";
|
||||
import {
|
||||
environmentServeFixture,
|
||||
environmentServeSource,
|
||||
@@ -40,34 +40,44 @@ const replay = (
|
||||
return result.value;
|
||||
};
|
||||
|
||||
/** The fixture source with /api/users/2's response replaced by HAR base64 content the decoder retained. */
|
||||
const base64Source = async (bytes: Buffer): Promise<EnvironmentSource> => {
|
||||
const encoded = bytes.toString("base64");
|
||||
const capture = environmentManifestCaptureFixture([
|
||||
{
|
||||
request: { method: "GET", url: "http://grafana.example/api/users/2" },
|
||||
response: {
|
||||
status: 200,
|
||||
content: {
|
||||
mimeType: "application/json",
|
||||
text: encoded,
|
||||
encoding: "base64",
|
||||
/** Import actual HAR content before deriving a manifest and replay source. */
|
||||
const recordedHarSource = async (text: string, encoding?: string) => {
|
||||
const har = historicalHar();
|
||||
const entry = har.log.entries[0]!;
|
||||
const serialized = JSON.stringify({
|
||||
log: {
|
||||
...har.log,
|
||||
entries: [
|
||||
{
|
||||
...entry,
|
||||
request: {
|
||||
...entry.request,
|
||||
url: "http://grafana.example/api/users/2",
|
||||
},
|
||||
response: {
|
||||
...entry.response,
|
||||
content: {
|
||||
...entry.response.content,
|
||||
mimeType: "application/json",
|
||||
text,
|
||||
...(encoding === undefined ? {} : { encoding }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const record = capture.records[0];
|
||||
if (record === undefined) throw new Error("Fixture lacks /api/users/2");
|
||||
record.binary_fields = [
|
||||
{
|
||||
pointer: "/response/content/text",
|
||||
representation: "har-base64-content",
|
||||
state: "retained",
|
||||
content_base64: encoded,
|
||||
bytes: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
const capture = webNetworkCaptureSchema.parse({
|
||||
...decodeHarCapture(serialized, []),
|
||||
artifact: {
|
||||
path: "/fixtures/replay.har",
|
||||
bytes: Buffer.byteLength(serialized),
|
||||
sha256: createHash("sha256").update(serialized).digest("hex"),
|
||||
},
|
||||
];
|
||||
format: "har",
|
||||
runtime_attribution: "unknown",
|
||||
limitations: [],
|
||||
});
|
||||
const evidence = analysisViewCaptureEvidence(capture);
|
||||
const manifest = await inspectAnalysisView({
|
||||
source: { kind: "inline", evidence },
|
||||
@@ -77,12 +87,18 @@ const base64Source = async (bytes: Buffer): Promise<EnvironmentSource> => {
|
||||
expect(environmentServeFixture().manifest.evidence_id).not.toBe(
|
||||
manifest.value.evidence_id,
|
||||
);
|
||||
return environmentServeSource({
|
||||
manifest: manifest.value,
|
||||
capture: evidence,
|
||||
});
|
||||
return {
|
||||
evidence,
|
||||
source: environmentServeSource({
|
||||
manifest: manifest.value,
|
||||
capture: evidence,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const base64Source = async (bytes: Buffer): Promise<EnvironmentSource> =>
|
||||
(await recordedHarSource(bytes.toString("base64"), "base64")).source;
|
||||
|
||||
describe("replay argument mapping and query admission", () => {
|
||||
it("does not inspect a recorded query when no query argument can affect selection", () => {
|
||||
const users = tool("get_users_by_id");
|
||||
@@ -144,6 +160,66 @@ describe("encoded replay path segments", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("HAR response encodings", () => {
|
||||
it.each(["hex", "base64url", "BASE64", ""])(
|
||||
"keeps unsupported HAR encoding %j opaque through replay and state extraction",
|
||||
async (encoding) => {
|
||||
const text = '{"id":2,"login":"opaque"}';
|
||||
const { source, evidence } = await recordedHarSource(text, encoding);
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
records: [
|
||||
{
|
||||
reported: {
|
||||
response: { content: { text, encoding } },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(replay("get_users_by_id", { id: "2" }, source)).toMatchObject({
|
||||
status: 200,
|
||||
body: null,
|
||||
replay: {
|
||||
evidence_id: evidence.evidence_id,
|
||||
record_ordinal: 0,
|
||||
body_recorded: true,
|
||||
body_encoding: "unsupported",
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const state = await inspectAnalysisView({
|
||||
source: { kind: "inline", evidence },
|
||||
view: { kind: "environment-state" },
|
||||
});
|
||||
if (!state.ok) throw state.error;
|
||||
expect(state.value.normalized_result).toMatchObject({
|
||||
entities: [],
|
||||
documents: [],
|
||||
views: [
|
||||
{
|
||||
tool: "get_users_by_id",
|
||||
shape: "recorded",
|
||||
reason: "body-unusable",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("retains plain and supported base64 HAR JSON controls", async () => {
|
||||
const text = '{"id":2,"login":"decoded"}';
|
||||
for (const [reported, encoding] of [
|
||||
[text, undefined],
|
||||
[Buffer.from(text).toString("base64"), "base64"],
|
||||
] as const) {
|
||||
const { source } = await recordedHarSource(reported, encoding);
|
||||
expect(replay("get_users_by_id", { id: "2" }, source)).toMatchObject({
|
||||
body: { id: 2, login: "decoded" },
|
||||
replay: { body_encoding: "utf-8" },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("replayEnvironmentTool selection", () => {
|
||||
it("uses observed cross-name query order and marks ambiguous key-wise replay inexact", () => {
|
||||
const search = tool("get_search");
|
||||
|
||||
@@ -37,7 +37,9 @@ export interface ReplayResult {
|
||||
readonly recorded_line_truncated: boolean;
|
||||
readonly content_type_truncated: boolean;
|
||||
readonly body_recorded: boolean;
|
||||
readonly body_encoding: "utf-8" | "base64";
|
||||
readonly body_encoding:
|
||||
| "utf-8"
|
||||
| NonNullable<RecordedExchange["bodyEncoding"]>;
|
||||
readonly truncated: boolean;
|
||||
};
|
||||
}
|
||||
@@ -321,6 +323,7 @@ const replayBody = (exchange: RecordedExchange): ReplayBody => {
|
||||
const body = replayedBody(exchange);
|
||||
switch (body.kind) {
|
||||
case "none":
|
||||
case "unsupported-encoding":
|
||||
return { body: null, truncated: false };
|
||||
case "base64":
|
||||
return body.whole
|
||||
@@ -397,7 +400,8 @@ export const replayAnswer = (
|
||||
recordedPath.length < exchange.path.length ||
|
||||
(recordedQuery?.length ?? 0) < (exchange.query?.length ?? 0),
|
||||
content_type_truncated: contentType !== exchange.contentType,
|
||||
body_recorded: exchange.bodyText !== null,
|
||||
body_recorded:
|
||||
exchange.bodyText !== null || exchange.bodyEncoding === "unsupported",
|
||||
body_encoding: exchange.bodyEncoding ?? "utf-8",
|
||||
truncated,
|
||||
},
|
||||
|
||||
@@ -57,6 +57,8 @@ export const REPLAY_BODY_LIMIT_BYTES = 256 * 1024;
|
||||
/** How replay answers with an exchange's body; replay and tool descriptions both read it from here. */
|
||||
export type ReplayedBody =
|
||||
| { readonly kind: "none" }
|
||||
/** Encoded source text was retained, but its declared encoding is not supported. */
|
||||
| { readonly kind: "unsupported-encoding" }
|
||||
/** Non-UTF-8 bytes as base64; `whole` when every recorded byte fits the replay limit. */
|
||||
| {
|
||||
readonly kind: "base64";
|
||||
@@ -75,6 +77,8 @@ const decodedBase64Bytes = (encoded: string): number =>
|
||||
|
||||
/** Classify a retained body once: whole JSON-like UTF-8 text within the limit that parses exactly is JSON. */
|
||||
export const replayedBody = (exchange: RecordedExchange): ReplayedBody => {
|
||||
if (exchange.bodyEncoding === "unsupported")
|
||||
return { kind: "unsupported-encoding" };
|
||||
const text = exchange.bodyText;
|
||||
if (text === null) return { kind: "none" };
|
||||
if (exchange.bodyEncoding === "base64")
|
||||
|
||||
@@ -69,7 +69,7 @@ export const stateBodyJson = (
|
||||
contentType === null ||
|
||||
!isJsonLike(mediaTypeEssence(contentType)) ||
|
||||
body.bodyText === null ||
|
||||
body.bodyEncoding === "base64" ||
|
||||
body.bodyEncoding !== undefined ||
|
||||
body.bodyCut === true ||
|
||||
Buffer.byteLength(body.bodyText, "utf8") > STATE_BODY_LIMIT_BYTES
|
||||
)
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js";
|
||||
|
||||
/** One record's retained response text, as replay and state extraction both read it. */
|
||||
export interface RecordedResponseBody {
|
||||
/** Producer-decoded response text, or null when the record retained none. */
|
||||
/** Producer-decoded response text, or null when none is available. */
|
||||
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";
|
||||
/** Base64 preserves non-UTF-8 bytes; unsupported leaves a retained encoding opaque in the source record. */
|
||||
readonly bodyEncoding?: "base64" | "unsupported";
|
||||
/** Set when only a prefix of a large base64 body was decoded. */
|
||||
readonly bodyCut?: true;
|
||||
}
|
||||
@@ -37,7 +37,9 @@ export const readRecordedResponseBody = (
|
||||
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 };
|
||||
if (encoding === undefined) return { bodyText: text };
|
||||
if (encoding !== "base64")
|
||||
return { bodyText: null, bodyEncoding: "unsupported" };
|
||||
const binary = record.binary_fields.find(
|
||||
(field) =>
|
||||
field.pointer === "/response/content/text" && field.state === "retained",
|
||||
|
||||
Reference in new issue
Block a user