diff --git a/docs/agent-environments.md b/docs/agent-environments.md index fad7eee9de..04925f1649 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -488,8 +488,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 7402f3dbbe..bced6224af 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", () => {