From 6b8db19891434229dc94fd89b176dfc0695f4bf5 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 21:08:54 -0700 Subject: [PATCH] fix(environment): refuse a loaded state that data/get would write past the intake limit data/add measured only the document it was given, while data/get writes the state back with the members a document may omit (types, the state Evidence id, view fields and record citations). A document just under 32 MiB could therefore load and be reported larger than any intake accepts, breaking the data/get then data/add round trip. replace now measures the state as data/get writes it, part by part, and refuses it past the limit, leaving the state unchanged. The limit moves to the state document module, so the application store can read it; the intake re-exports it. Co-Authored-By: Claude Opus 5.5 --- docs/agent-environments.md | 6 ++- .../agent-environments/state-serve/spec.md | 4 +- .../environment/environmentState.test.ts | 43 +++++++++++++++++++ .../environment/environmentStateDocument.ts | 3 ++ .../environment/environmentStateStore.ts | 40 +++++++++++++++-- src/server/environment/stateIntake.ts | 4 +- 6 files changed, 91 insertions(+), 9 deletions(-) diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 861fd0b3f4..94dc1a753f 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -424,8 +424,10 @@ within 30 seconds, including reading the body; `mimeType`, when given, is `application/json`. A `file://` URL naming another host, or a UNC path such as `file:////server/share/state.json`, is refused before anything is opened, so a load never reaches a network share. A loaded document is at most 32 -MiB; an inline part also stays within the 4 MiB request body bound, so larger -states arrive by `uri`. Anything else, including `{"parts": []}`, is `-32602 +MiB, and so is the state as `data/get` writes it back with the members a +document may omit (`types`, `state_evidence_id`, record citations), so a +reported state always loads again; an inline part also stays within the 4 MiB +request body bound, so larger states arrive by `uri`. Anything else, including `{"parts": []}`, is `-32602 invalid params`; a download that fails or answers a non-2xx status is `-32000`. Errors never echo the uri or the content; a file or download failure names its category in `error.data.reason`: `not-found`, `permission`, diff --git a/docs/specs/agent-environments/state-serve/spec.md b/docs/specs/agent-environments/state-serve/spec.md index ae08ecd656..49837d5bd8 100644 --- a/docs/specs/agent-environments/state-serve/spec.md +++ b/docs/specs/agent-environments/state-serve/spec.md @@ -77,7 +77,9 @@ this increment: write tools are not served from the state. `mimeType` when given is `application/json`) and `data/get` (`{parts: [{kind: "data", data: rea_state}]}`). A `file://` uri whose host is neither empty nor `localhost`, or whose path is a UNC path, is `-32602` before - anything is opened. Anything else is `-32602`, including empty `parts`; a + anything is opened. A document that `data/get` would write back past 32 MiB, + once it adds the members a document may omit, is `-32602`, so a reported + state always loads again. Anything else is `-32602`, including empty `parts`; a failed download is `-32000`; errors never echo a uri or content and name a coarse failure category in `error.data.reason`. Calls run one at a time in arrival order across requests; every message of a batch is queued when the diff --git a/src/application/environment/environmentState.test.ts b/src/application/environment/environmentState.test.ts index 2d11d0292a..bdb66a88b9 100644 --- a/src/application/environment/environmentState.test.ts +++ b/src/application/environment/environmentState.test.ts @@ -28,6 +28,7 @@ import { loadEnvironmentState, prepareServedState, } from "./environmentState.js"; +import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js"; import { EnvironmentStateStore } from "./environmentStateStore.js"; const fixture = environmentStateServeFixture(); @@ -373,6 +374,48 @@ describe("EnvironmentStateStore", () => { expect(refused.ok).toBe(false); expect(store.snapshot()).toEqual(before); }); + + it("refuses a document that data/get would write back past what data/add loads", () => { + const store = new EnvironmentStateStore(state); + const { rea_state: held } = store.snapshot(); + // Without the members data/get adds back, the document fits only until they return. + const document = { + rea_state: { + version: held.version, + entities: held.entities.map(({ type, collection, id, fields }) => ({ + type, + collection, + id, + fields: { ...fields }, + })), + documents: held.documents, + }, + }; + const entity = document.rea_state.entities[0]!; + const { fields } = entity; + const idField = state.types.find( + (type) => type.collection_template === entity.type, + )?.id_field; + const text = Object.keys(fields).find( + (field) => field !== idField && typeof fields[field] === "string", + )!; + fields[text] = `${String(fields[text])}${"a".repeat( + STATE_INTAKE_LIMIT_BYTES - + Buffer.byteLength(JSON.stringify(document), "utf8"), + )}`; + expect(Buffer.byteLength(JSON.stringify(document), "utf8")).toBe( + STATE_INTAKE_LIMIT_BYTES, + ); + const before = store.snapshot(); + const replaced = store.replace(document); + expect(!replaced.ok && replaced.error.issues).toEqual([ + expect.objectContaining({ + reason: "out_of_range", + maximum: STATE_INTAKE_LIMIT_BYTES, + }), + ]); + expect(store.snapshot()).toEqual(before); + }); }); describe("rea-state/v1 validation", () => { diff --git a/src/application/environment/environmentStateDocument.ts b/src/application/environment/environmentStateDocument.ts index c3250ccbed..d4d21603a3 100644 --- a/src/application/environment/environmentStateDocument.ts +++ b/src/application/environment/environmentStateDocument.ts @@ -23,6 +23,9 @@ import type { EnvironmentState } from "./environmentState.js"; import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js"; import { REA_STATE_FORMAT } from "./environmentTools.js"; +/** A loaded state document is at most this large, whether it arrives as bytes, a file or a download. */ +export const STATE_INTAKE_LIMIT_BYTES = 32 * 1024 * 1024; + /** A document as the state holds it; only a value equal to the Evidence's cites the record it came from. */ export type HeldDocument = Omit & { readonly record_ordinal?: number; diff --git a/src/application/environment/environmentStateStore.ts b/src/application/environment/environmentStateStore.ts index 916b182bca..66d13eaa70 100644 --- a/src/application/environment/environmentStateStore.ts +++ b/src/application/environment/environmentStateStore.ts @@ -2,16 +2,26 @@ import type { StateEntity, StateType, } from "../../domain/analysisView/environmentStateRules.js"; -import type { AnalysisInputError } from "../../domain/analysisErrorCore.js"; -import { ok, type Result } from "../../domain/result.js"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { jsonParts } from "../../domain/jsonSerialization.js"; +import { err, ok, type Result } from "../../domain/result.js"; import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; +import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js"; import type { EnvironmentState } from "./environmentState.js"; import { parseStateDocument, + STATE_INTAKE_LIMIT_BYTES, type HeldDocument, type StateContent, } from "./environmentStateDocument.js"; +/** The UTF-8 bytes a value's JSON takes, counted part by part so it is never one string. */ +const jsonBytes = (value: unknown): number => { + let bytes = 0; + for (const part of jsonParts(value)) bytes += Buffer.byteLength(part, "utf8"); + return bytes; +}; + /** What `data/get` reports: the current state as a `rea-state/v1` document. */ export interface StateSnapshot { readonly rea_state: { @@ -52,6 +62,24 @@ export class EnvironmentStateStore { replace(document: unknown): Result { const content = parseStateDocument(this.state, document); if (!content.ok) return content; + // data/get writes members a document may omit, so the state it reports must still be one data/add loads. + if (jsonBytes(this.#snapshotOf(content.value)) > STATE_INTAKE_LIMIT_BYTES) + return err( + new AnalysisInputError( + ENVIRONMENT_SERVE_OPERATION, + { + userMessage: `The state would exceed ${String(STATE_INTAKE_LIMIT_BYTES)} bytes as data/get writes it.`, + }, + [ + { + path: [], + reason: "out_of_range", + message: `would exceed ${String(STATE_INTAKE_LIMIT_BYTES)} bytes as data/get writes it`, + maximum: STATE_INTAKE_LIMIT_BYTES, + }, + ], + ), + ); this.#install(content.value); return ok(undefined); } @@ -83,13 +111,17 @@ export class EnvironmentStateStore { } snapshot(): StateSnapshot { + return this.#snapshotOf(this.#content); + } + + #snapshotOf(content: StateContent): StateSnapshot { return { rea_state: { version: 1, state_evidence_id: this.state.evidenceId, types: this.state.types, - entities: this.#content.entities, - documents: this.#content.documents, + entities: content.entities, + documents: content.documents, }, }; } diff --git a/src/server/environment/stateIntake.ts b/src/server/environment/stateIntake.ts index b8e05cad75..2e7ab8c559 100644 --- a/src/server/environment/stateIntake.ts +++ b/src/server/environment/stateIntake.ts @@ -1,6 +1,7 @@ import type { FileHandle } from "node:fs/promises"; import { fileURLToPath } from "node:url"; +import { STATE_INTAKE_LIMIT_BYTES } from "../../application/environment/environmentStateDocument.js"; import { mediaTypeEssence } from "../../domain/analysisView/webNetworkRouteTemplates.js"; import { isExactJsonNumber } from "../../domain/exactJsonNumber.js"; import { err, ok, type Result } from "../../domain/result.js"; @@ -44,8 +45,7 @@ export const CANCELLED: RpcError = { data: { reason: "cancelled" }, }; -/** A loaded state document is at most this large, whether it arrives as bytes, a file or a download. */ -export const STATE_INTAKE_LIMIT_BYTES = 32 * 1024 * 1024; +export { STATE_INTAKE_LIMIT_BYTES }; /** An https state download must finish within this time. */ export const STATE_FETCH_TIMEOUT_MS = 30_000;