From 6726241cbe67a33d2998dbfb2e69a441d62e10d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=98=89=E4=BC=9F?= <202383014@uibe.edu.cn> Date: Wed, 7 Oct 2026 22:40:19 +0800 Subject: [PATCH] fix(web): preserve structured detail integrity --- .../WEB_STRUCTURED_DETAILS_2026-10-07.md | 28 +++++++++ tests/web/protocol.test.ts | 58 +++++++++++++++++++ web/protocol/types.ts | 19 +++++- 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 docs/research/WEB_STRUCTURED_DETAILS_2026-10-07.md diff --git a/docs/research/WEB_STRUCTURED_DETAILS_2026-10-07.md b/docs/research/WEB_STRUCTURED_DETAILS_2026-10-07.md new file mode 100644 index 00000000..7494ed6b --- /dev/null +++ b/docs/research/WEB_STRUCTURED_DETAILS_2026-10-07.md @@ -0,0 +1,28 @@ +# Structured Web evidence integrity + +- Status: validated source and production-function regression +- Created / verified: 2026-10-07 +- Source: `origin/main` at `3cb2ecfe1bfbb98252651885311d309f83749428` +- Issue: [#695](https://github.com/openpi-dev/openpi/issues/695) +- Supersedes: none + +## Verified facts + +`projectMessage` dropped own `__proto__` keys in parsed JSON tool details by +assigning them through the ordinary object setter. This changed only the +projection object's prototype; global prototype pollution is not claimed. +Its shared bounded projection also silently omitted array elements or later +object properties when the 512-node work budget was exhausted exactly. +Four arrays of 128 numbers became arrays of lengths 128, 128, 128 and 123, +without a details-truncation marker. Regression tests failed on the source +baseline for both special-key loss and budget exhaustion. + +## Fix and verification boundary + +Own data properties are defined without invoking prototype setters. A remaining +item at an exhausted budget explicitly marks omission, while complete data at +the exact boundary remains retained. Existing policy omits partial details and +reports `truncation.details`; work, depth, byte and accessor limits remain. +The 25-test protocol suite passes after the fix. These are deterministic +production-function tests, not installed browser or model acceptance. Tool +execution, Session persistence and model context are unchanged. diff --git a/tests/web/protocol.test.ts b/tests/web/protocol.test.ts index fc6f082b..bd062148 100644 --- a/tests/web/protocol.test.ts +++ b/tests/web/protocol.test.ts @@ -184,6 +184,64 @@ test("message projection keeps tool result correlation and details", () => { }); }); +test("structured tool details preserve special own JSON keys without changing prototypes", () => { + const details = JSON.parse( + '{"__proto__":{"evidence":"retained"},"constructor":"literal","prototype":{"__proto__":{"nested":true}}}', + ); + const projected = projectMessage({ + role: "toolResult", + content: "done", + details, + }); + assert.equal(JSON.stringify(projected.details), JSON.stringify(details)); + assert.ok(projected.details && typeof projected.details === "object"); + assert.equal(Object.getPrototypeOf(projected.details), Object.prototype); + assert.equal(Object.hasOwn(projected.details, "__proto__"), true); + assert.equal(Object.hasOwn(Object.prototype, "evidence"), false); + assert.equal(Object.hasOwn(Object.prototype, "nested"), false); + assert.equal(projected.truncation?.details, undefined); +}); + +test("structured detail node exhaustion reports omitted array elements", () => { + const projected = projectMessage({ + role: "toolResult", + content: "done", + details: Array.from({ length: 4 }, () => Array(128).fill(1)), + }); + assert.equal(projected.details, undefined); + assert.equal(projected.truncation?.details, true); +}); + +test("structured detail node exhaustion reports omitted object properties", () => { + const projected = projectMessage({ + role: "toolResult", + content: "done", + details: { + first: Array.from({ length: 3 }, () => Array(128).fill(1)), + last: Array(122).fill(1), + omitted: true, + }, + }); + assert.equal(projected.details, undefined); + assert.equal(projected.truncation?.details, true); +}); + +test("structured details exactly at the node boundary remain complete", () => { + const details = [ + Array(128).fill(1), + Array(128).fill(1), + Array(128).fill(1), + Array(123).fill(1), + ]; + const projected = projectMessage({ + role: "toolResult", + content: "done", + details, + }); + assert.deepEqual(projected.details, details); + assert.equal(projected.truncation?.details, undefined); +}); + test("native subagent display receipts survive projection while model follow-ups stay hidden", () => { const details = { results: [ diff --git a/web/protocol/types.ts b/web/protocol/types.ts index 6e4b6794..c4056d98 100644 --- a/web/protocol/types.ts +++ b/web/protocol/types.ts @@ -611,9 +611,12 @@ function boundedStructuredValue( if (value.length > length) budget.truncated = true; const projected: unknown[] = []; for (let index = 0; index < length; index++) { + if (budget.nodes <= 0 || budget.bytes <= 0) { + budget.truncated = true; + break; + } const item = boundedStructuredValue(value[index], budget, depth + 1); if (item !== undefined) projected.push(item); - if (budget.nodes <= 0 || budget.bytes <= 0) break; } return projected; } @@ -625,6 +628,10 @@ function boundedStructuredValue( break; } if (!Object.prototype.hasOwnProperty.call(value, key)) continue; + if (budget.nodes <= 0 || budget.bytes <= 0) { + budget.truncated = true; + break; + } const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor || !("value" in descriptor)) { budget.truncated = true; @@ -632,8 +639,14 @@ function boundedStructuredValue( } const boundedKey = consumeStructuredText(key, budget); const item = boundedStructuredValue(descriptor.value, budget, depth + 1); - if (item !== undefined) projected[boundedKey] = item; - if (budget.nodes <= 0 || budget.bytes <= 0) break; + if (item !== undefined) { + Object.defineProperty(projected, boundedKey, { + value: item, + enumerable: true, + configurable: true, + writable: true, + }); + } } return projected; }