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;