mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
fix(environment): keep bodies too deep for the state's nesting out of the state
A recorded body at the JSON depth limit was accepted by extraction, then nested a few levels deeper as a document or entity value, so sealing the state Evidence threw and environment-serve and environment-export stopped instead of falling back. stateBodyJson, the one rule extraction and serving share, now treats a body nesting deeper than the limit minus 16 levels as unusable, so its tool stays a recorded view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
83fcc739f7
commit
d9c0357b2a
5 files changed
+81
-2
No files matched your search
@@ -61,3 +61,12 @@ any path conversion on every platform), a real `https://` download (tests
|
||||
inject the fetcher), the memory and time of a budgeted batch (not measured),
|
||||
agent token usage, a real AgentEnv framework run, a real-world capture, and
|
||||
write effects, which are a later increment.
|
||||
|
||||
After refusing bodies too deep for the state's own nesting (a recorded body
|
||||
must nest at most the JSON depth limit minus 16 levels to enter the state):
|
||||
`npm run check:fast`, `npm run format:check`, `npx knip`, `npm run scan:todos`
|
||||
passed; `npm run jscpd`: 3.37% over 3373 files; `npx vitest run --project
|
||||
domain --project services`: 347 files, 5950 tests passed, 5 pre-existing
|
||||
skips; `npm run build:cached`, then `npm run test:focused --` the serve CLI,
|
||||
MCP and process boundaries, the export CLI boundary and the two serve adapter
|
||||
tests: 6 files, 48 tests passed.
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../../../tests/fixtures/environmentServe.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
inspectAnalysisView,
|
||||
inspectAnalysisViewValidated,
|
||||
@@ -226,6 +227,28 @@ describe("prepareServedState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("derives a state when a body leaves no room for the state's own nesting", () => {
|
||||
let deep: JsonValue = { theme: "dark" };
|
||||
for (let level = 0; level < 995; level += 1) deep = { nested: deep };
|
||||
const deepFixture = environmentStateServeFixture(
|
||||
timedCaptureEvidence([
|
||||
["GET", "/api/settings", deep],
|
||||
["PUT", "/api/settings", { theme: "light" }],
|
||||
]),
|
||||
);
|
||||
const deepSource = loadEnvironmentSource(
|
||||
deepFixture.manifest,
|
||||
deepFixture.capture,
|
||||
);
|
||||
if (!deepSource.ok) throw deepSource.error;
|
||||
expect(
|
||||
prepareServedState(deepSource.value, deepFixture.capture).report,
|
||||
).toEqual({
|
||||
served: true,
|
||||
evidence_id: deepFixture.state.evidence_id,
|
||||
});
|
||||
});
|
||||
|
||||
it("serves replay and reports the code and message when the capture yields a manifest but no state", () => {
|
||||
const headerOnly = scenarioServeFixture(
|
||||
scenarioHeaderOnlyCaptureEvidence(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { parseExactJson } from "../exactJsonNumber.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import { MAX_JSON_DEPTH, type JsonValue } from "../jsonValue.js";
|
||||
import { ok, type Result } from "../result.js";
|
||||
import {
|
||||
WEB_NETWORK_CAPTURE_LIMITS,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { EnvironmentStateExtraction } from "./EnvironmentStateExtraction.js";
|
||||
import type { TemplateRule } from "./environmentManifestRules.js";
|
||||
import {
|
||||
deeperThan,
|
||||
hasPrototypeKey,
|
||||
isStateWrite,
|
||||
type ParsedBody,
|
||||
@@ -52,6 +53,13 @@ type StateFields = Pick<
|
||||
/** Bodies beyond this many UTF-8 bytes are not parsed into state; their views stay recorded. */
|
||||
export const STATE_BODY_LIMIT_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Levels the state's own nesting adds above a recorded value: Evidence, a `data/get` reply and a
|
||||
* JSON-RPC response hold a body's members about ten levels down, so a body must leave this much
|
||||
* room under the JSON depth limit to be sealed and served.
|
||||
*/
|
||||
const STATE_NESTING_DEPTH = 16;
|
||||
|
||||
/** A recorded response body as state JSON: a JSON-like media type, complete UTF-8 text within the state body limit, exact numbers and no `__proto__` member; otherwise undefined. */
|
||||
export const stateBodyJson = (
|
||||
contentType: string | null,
|
||||
@@ -67,7 +75,11 @@ export const stateBodyJson = (
|
||||
)
|
||||
return undefined;
|
||||
const value = parseExactJson(body.bodyText);
|
||||
return value === undefined || hasPrototypeKey(value) ? undefined : value;
|
||||
return value === undefined ||
|
||||
hasPrototypeKey(value) ||
|
||||
deeperThan(value, MAX_JSON_DEPTH - STATE_NESTING_DEPTH)
|
||||
? undefined
|
||||
: value;
|
||||
};
|
||||
|
||||
const timingSchema = z.object({
|
||||
|
||||
@@ -297,6 +297,24 @@ export const jsonBytes = (value: JsonValue): number => {
|
||||
return ENTRY_BYTES;
|
||||
};
|
||||
|
||||
/** Whether a JSON value nests more than `limit` levels below its root. */
|
||||
export const deeperThan = (value: JsonValue, limit: number): boolean => {
|
||||
const pending: { readonly value: JsonValue; readonly depth: number }[] = [
|
||||
{ value, depth: 0 },
|
||||
];
|
||||
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
|
||||
if (next.depth > limit) return true;
|
||||
const children = Array.isArray(next.value)
|
||||
? next.value
|
||||
: isObject(next.value)
|
||||
? Object.values(next.value)
|
||||
: [];
|
||||
for (const child of children)
|
||||
pending.push({ value: child, depth: next.depth + 1 });
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** JSON parsers keep a `__proto__` member that plain object construction would turn into a prototype. */
|
||||
export const hasPrototypeKey = (value: JsonValue): boolean =>
|
||||
Array.isArray(value)
|
||||
|
||||
@@ -327,6 +327,23 @@ it("leaves recorded the views it cannot reproduce, with the reason", () => {
|
||||
expect(state.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves a body too deep for the state's own nesting recorded instead of failing to seal", () => {
|
||||
let deep: JsonValue = { theme: "dark" };
|
||||
for (let level = 0; level < 995; level += 1) deep = { nested: deep };
|
||||
const state = stateOf(
|
||||
captureOf([
|
||||
["GET", "/api/settings", deep],
|
||||
["PUT", "/api/settings", { theme: "light" }],
|
||||
]),
|
||||
);
|
||||
expect(state.documents).toEqual([]);
|
||||
expect(viewOf(state, "get_settings")).toEqual({
|
||||
tool: "get_settings",
|
||||
shape: "recorded",
|
||||
reason: "body-unusable",
|
||||
});
|
||||
});
|
||||
|
||||
it("holds a written path's non-entity read as a document", () => {
|
||||
const state = stateOf(
|
||||
captureOf([
|
||||
|
||||
Reference in new issue
Block a user