fix(web): reject drafts whose digits or types would not survive being sent
Copilot's seventh pass, three comments, all the same class: the editor showing
one value while the wire carries another.
- `JSON.parse` rounds a whole number past 2^53−1 to the nearest double, so a
draft reading `{"id":9007199254740993}` submits `…992`. New
`hasRoundedInteger`, applied by all four JSON-draft parsers — the raw
arguments editor, `SchemaJsonField`, the Edit-and-replay modal, and
`parseJsonObjectDraft`, which Copilot did not flag but shares the defect.
It is narrower than "unsafe integer": it also requires the number's shortest
form to be written out in full, because JS stringifies from 1e21 upward in
exponent form and such a value was typed that way — `{"n":1e308}` is a
legitimate `_meta` value that `parseJsonObjectDraft`'s tests already pin as
accepted. It still refuses the rare full-form integer that happens to be
exactly representable, erring toward refusing a sendable value rather than
misreporting an unsendable one — the trade `toNumericValue` already makes.
- `getPrompt` runs `convertPromptArguments`, which JSON-stringifies every
non-string argument, so `{"count":2}` leaves as `{"count":"2"}` while the
editor still shows a number. `reshapedReplayParam` now rejects a non-string
prompt argument. Not a workaround: the spec types
`GetPromptRequest.params.arguments` as `Record<string, string>`, so a string
is the only thing a prompt argument can be. `tools/call` is untouched —
`callTool` sends its arguments as given.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
0e9069a78d
commit
8f4aa7f8af
2 changed files with 216 additions and 13 deletions
|
|
@ -1,5 +1,51 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { parseJsonObjectDraft } from "./jsonObjectDraft";
|
||||
import { hasRoundedInteger, parseJsonObjectDraft } from "./jsonObjectDraft";
|
||||
|
||||
// `isSerializableJson` catches a literal too large to represent (`1e400` →
|
||||
// `Infinity` → `null` on the wire). This is the quieter one: a whole number
|
||||
// past 2^53−1 parses to the nearest double, so the draft shows digits the wire
|
||||
// will not carry.
|
||||
describe("hasRoundedInteger", () => {
|
||||
it("accepts whole numbers inside the safe range", () => {
|
||||
expect(hasRoundedInteger({ id: Number.MAX_SAFE_INTEGER })).toBe(false);
|
||||
expect(hasRoundedInteger({ id: -Number.MAX_SAFE_INTEGER })).toBe(false);
|
||||
expect(hasRoundedInteger({ id: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a whole number past the safe range", () => {
|
||||
// Typed as …93, parsed as …92.
|
||||
expect(hasRoundedInteger(JSON.parse('{"id":9007199254740993}'))).toBe(true);
|
||||
});
|
||||
|
||||
// A fractional or exponent-form value is a double by nature and is sent as
|
||||
// the double it parsed to, so nothing is lost between typing and sending.
|
||||
it("accepts a fractional value however large", () => {
|
||||
expect(hasRoundedInteger({ ratio: 1.5 })).toBe(false);
|
||||
expect(hasRoundedInteger({ ratio: 0.1 + 0.2 })).toBe(false);
|
||||
});
|
||||
|
||||
// JS stringifies from 1e21 upward in exponent form, so such a value was typed
|
||||
// that way and the parsed double is what was asked for. `parseJsonObjectDraft`
|
||||
// has always accepted these.
|
||||
it("accepts a value whose shortest form carries an exponent", () => {
|
||||
expect(hasRoundedInteger({ n: 1e308 })).toBe(false);
|
||||
expect(hasRoundedInteger({ n: 1e21 })).toBe(false);
|
||||
});
|
||||
|
||||
it("looks inside arrays and nested objects", () => {
|
||||
expect(hasRoundedInteger(JSON.parse('[{"a":[9007199254740993]}]'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasRoundedInteger({ a: { b: [1, 2, "x", null, true] } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("has nothing to say about non-numeric values", () => {
|
||||
expect(hasRoundedInteger("9007199254740993")).toBe(false);
|
||||
expect(hasRoundedInteger(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJsonObjectDraft", () => {
|
||||
it("reads empty text as the empty object rather than an error", () => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,55 @@ export function isJsonObject(value: unknown): value is StrictJsonObject {
|
|||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `JSON.parse` dropped digits from a whole number written out in full.
|
||||
*
|
||||
* `isSerializableJson` catches a literal too large to *represent* — `1e400`
|
||||
* parses to `Infinity` and writes back as `null`. This catches the quieter one:
|
||||
* a whole number past 2^53−1 parses to the nearest double, so
|
||||
* `{"id":9007199254740993}` becomes `…992` with no error anywhere. The draft
|
||||
* still shows what was typed, which is what makes it a misreport rather than a
|
||||
* formatting difference — the editor displays one value and the wire carries
|
||||
* another. Snowflake-style ids are the realistic case.
|
||||
*
|
||||
* Two conditions, and the second is what keeps this from over-reaching:
|
||||
*
|
||||
* - **An unsafe whole number.** A fractional value is a double by nature and is
|
||||
* sent as the double it parsed to, so nothing is lost between typing and
|
||||
* sending.
|
||||
* - **Whose shortest representation is written out in full.** JS switches to
|
||||
* exponent form at 1e21, so a value that stringifies as `1e+308` was typed in
|
||||
* exponent form, where the parsed double *is* the value the user asked for.
|
||||
* Without this, `{"n":1e308}` — a legitimate `_meta` value — would be
|
||||
* refused, which `parseJsonObjectDraft`'s own tests pin against.
|
||||
*
|
||||
* It still refuses the rare full-form integer past 2^53−1 that happens to be
|
||||
* exactly representable (2^54, say), because nothing here can tell it apart
|
||||
* from one that lost digits without the original literal. That errs toward
|
||||
* refusing a sendable value rather than misreporting an unsendable one, which
|
||||
* is the same trade `toNumericValue` makes for the schema form's number input.
|
||||
*/
|
||||
export function hasRoundedInteger(value: unknown): boolean {
|
||||
if (typeof value === "number") {
|
||||
return (
|
||||
Number.isInteger(value) &&
|
||||
!Number.isSafeInteger(value) &&
|
||||
!String(value).includes("e")
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) return value.some(hasRoundedInteger);
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.values(value as Record<string, unknown>).some(
|
||||
hasRoundedInteger,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The message every draft parser uses for {@link hasRoundedInteger}. */
|
||||
export const ROUNDED_INTEGER_ERROR =
|
||||
"Whole numbers must be within ±(2^53 − 1) — a longer one is rounded when parsed, so a different value would be sent";
|
||||
|
||||
/**
|
||||
* Read a JSON-object editor's draft text.
|
||||
*
|
||||
|
|
@ -60,5 +109,8 @@ export function parseJsonObjectDraft(text: string): JsonObjectDraft {
|
|||
"Numbers must be finite — a value like `1e400` overflows and would be sent as null",
|
||||
};
|
||||
}
|
||||
if (hasRoundedInteger(parsed)) {
|
||||
return { ok: false, error: ROUNDED_INTEGER_ERROR };
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue