diff --git a/docs/agent-environments.md b/docs/agent-environments.md index e1d0689554..8316afe885 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -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 diff --git a/docs/specs/agent-environments/environment-serve/spec.md b/docs/specs/agent-environments/environment-serve/spec.md index b0f4ab8fb5..d6b7c3acb1 100644 --- a/docs/specs/agent-environments/environment-serve/spec.md +++ b/docs/specs/agent-environments/environment-serve/spec.md @@ -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 diff --git a/src/application/environment/environmentReplay.test.ts b/src/application/environment/environmentReplay.test.ts index 280896ccd1..ba70df09b0 100644 --- a/src/application/environment/environmentReplay.test.ts +++ b/src/application/environment/environmentReplay.test.ts @@ -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 => { - 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 => { 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 => + (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"); diff --git a/src/application/environment/environmentReplay.ts b/src/application/environment/environmentReplay.ts index c7210eb547..4aa61b8688 100644 --- a/src/application/environment/environmentReplay.ts +++ b/src/application/environment/environmentReplay.ts @@ -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; 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, }, diff --git a/src/application/environment/environmentSource.ts b/src/application/environment/environmentSource.ts index 3fe73515a6..bfd8d21ffe 100644 --- a/src/application/environment/environmentSource.ts +++ b/src/application/environment/environmentSource.ts @@ -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") diff --git a/src/domain/analysisView/EnvironmentStateAccumulator.ts b/src/domain/analysisView/EnvironmentStateAccumulator.ts index fdb9d001f4..aaf01d5eff 100644 --- a/src/domain/analysisView/EnvironmentStateAccumulator.ts +++ b/src/domain/analysisView/EnvironmentStateAccumulator.ts @@ -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 ) diff --git a/src/domain/analysisView/recordedResponseBody.ts b/src/domain/analysisView/recordedResponseBody.ts index 1adf258774..410227f118 100644 --- a/src/domain/analysisView/recordedResponseBody.ts +++ b/src/domain/analysisView/recordedResponseBody.ts @@ -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",