Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a54464548e |
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