From b0fb51cb4c54a73da130087c3f3875ecdf1ea43e Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:48:26 +0800 Subject: [PATCH] fix(environment): share inline state byte admission (#7721) Reuse shared padded-base64 facts and derive encoded admission from the existing decoded-byte budget. Refuse overflow before decoded allocation and retain padded syntax. Cover capacity, compact HTTP replies and live MCP state readback. Fixes #7719 Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) --- .../environment/serveEnvironmentState.test.ts | 40 ++++++++++++++ src/server/environment/stateIntake.test.ts | 53 +++++++++++++++++++ src/server/environment/stateIntake.ts | 13 +++-- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/src/server/environment/serveEnvironmentState.test.ts b/src/server/environment/serveEnvironmentState.test.ts index 027330a32b..347be26234 100644 --- a/src/server/environment/serveEnvironmentState.test.ts +++ b/src/server/environment/serveEnvironmentState.test.ts @@ -879,6 +879,46 @@ const withClient = async ( } }; +it("loads a padded inline state file and keeps the RPC result compact", async () => { + const environment = await serveState({ source, state }); + const document = JSON.stringify(renamedDocument()).padEnd( + 2 * 1024 * 1024, + " ", + ); + const added = await fetch(new URL(ENVIRONMENT_RPC_PATH, environment.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "data/add", + params: { + parts: [ + { + kind: "file", + file: { bytes: Buffer.from(document).toString("base64") }, + }, + ], + }, + }), + }); + expect(added.status).toBe(200); + const reply = await added.text(); + expect(JSON.parse(reply)).toEqual({ jsonrpc: "2.0", id: 1, result: {} }); + expect(Buffer.byteLength(reply)).toBeLessThan(128); + await withClient(environment, async (client) => { + const read = await client.callTool({ + name: "get_users_by_id", + arguments: { id: "2" }, + }); + expect(read.isError).toBeFalsy(); + expect(read.structuredContent).toMatchObject({ + body: { login: "renamed" }, + }); + }); + await expect(environment.close()).resolves.toBeUndefined(); +}); + describe("serveEnvironment with a state", () => { it("declares the data plane, serves read tools from the state and reflects data/add in the next answer", async () => { const environment = await serveState({ source, state }); diff --git a/src/server/environment/stateIntake.test.ts b/src/server/environment/stateIntake.test.ts index e72923e83e..b5aaf98665 100644 --- a/src/server/environment/stateIntake.test.ts +++ b/src/server/environment/stateIntake.test.ts @@ -1,11 +1,64 @@ import { expect, it, vi } from "vitest"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { STATE_INTAKE_LIMIT_BYTES } from "../../application/environment/environmentStateDocument.js"; import { DEFAULT_STATE_INTAKE, fetchStateDocument, + partDocument, STATE_FETCH_TIMEOUT_MS, } from "./stateIntake.js"; +const inlineDocument = async (bytes: string) => { + const files = new ArtifactResourceScope(); + try { + return await partDocument( + { kind: "file", file: { bytes } }, + DEFAULT_STATE_INTAKE, + new AbortController().signal, + files, + ); + } finally { + await files.close(); + } +}; + +it("preserves padded syntax without imposing canonical pad bits", async () => { + await expect(inlineDocument("e31=")).resolves.toEqual({ + ok: true, + value: {}, + }); +}); + +it("admits an inline JSON document at the decoded-byte budget", async () => { + const bytes = Buffer.from( + "{}".padEnd(STATE_INTAKE_LIMIT_BYTES, " "), + ).toString("base64"); + await expect(inlineDocument(bytes)).resolves.toEqual({ ok: true, value: {} }); +}); + +it.each([0, 1])( + "refuses decoded-byte overflow before allocating the decoded buffer: %s extra quartets", + async (extraQuartets) => { + const bytes = "AAAA".repeat( + Math.ceil(STATE_INTAKE_LIMIT_BYTES / 3) + extraQuartets, + ); + const decoded = vi.spyOn(Buffer, "from"); + try { + await expect(inlineDocument(bytes)).resolves.toMatchObject({ + ok: false, + error: { + code: -32602, + message: expect.stringContaining(String(STATE_INTAKE_LIMIT_BYTES)), + }, + }); + expect(decoded.mock.calls.length).toBe(0); + } finally { + decoded.mockRestore(); + } + }, +); + it("downloads with redirects refused and the 30 s timeout by default", async () => { const fetched = vi .spyOn(globalThis, "fetch") diff --git a/src/server/environment/stateIntake.ts b/src/server/environment/stateIntake.ts index 92e6cc25e1..5ce12e714b 100644 --- a/src/server/environment/stateIntake.ts +++ b/src/server/environment/stateIntake.ts @@ -7,6 +7,7 @@ import { } from "../../artifacts/ArtifactResourceScope.js"; import { STATE_INTAKE_LIMIT_BYTES } from "../../application/environment/environmentStateDocument.js"; import { isExactJsonNumber } from "../../domain/exactJsonNumber.js"; +import { paddedBase64ByteLength } from "../../domain/paddedBase64.js"; import { err, ok, type Result } from "../../domain/result.js"; import { safeParseJson } from "../../domain/safeJson.js"; import { @@ -126,8 +127,8 @@ const fileFailure = (cause: unknown): IntakeFailure => const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); -const BASE64 = - /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; +const MAX_INLINE_BASE64_CHARACTERS = + Math.ceil(STATE_INTAKE_LIMIT_BYTES / 3) * 4; /** Parse loaded bytes as UTF-8 JSON, marking values parsing would change; the content is never echoed. */ const parseDocumentBytes = (bytes: Uint8Array): Result => { @@ -316,10 +317,12 @@ const fileBytes = async ( files: ArtifactResourceScope, ): Promise> => { if (typeof file.bytes === "string" && file.uri === undefined) { - if (!BASE64.test(file.bytes)) + if (file.bytes.length > MAX_INLINE_BASE64_CHARACTERS) return err(TOO_LARGE); + const byteLength = paddedBase64ByteLength(file.bytes); + if (byteLength === undefined) return err(invalidParams("the file part's bytes are not base64")); - const bytes = Buffer.from(file.bytes, "base64"); - return bytes.length > STATE_INTAKE_LIMIT_BYTES ? err(TOO_LARGE) : ok(bytes); + if (byteLength > STATE_INTAKE_LIMIT_BYTES) return err(TOO_LARGE); + return ok(Buffer.from(file.bytes, "base64")); } if ( typeof file.uri !== "string" ||