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
4 changed files with 216 additions and 13 deletions
|
|
@ -197,6 +197,26 @@ describe("EditReplayModal", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// `JSON.parse` rounds a whole number past 2^53−1 to the nearest double, so
|
||||
// the draft shows digits the wire will not carry.
|
||||
it("disables Send for a whole number that loses digits when parsed", async () => {
|
||||
renderWithMantine(<EditReplayModal {...baseProps} method="tools/call" />);
|
||||
await setAceText('{"name":"echo","arguments":{"id":9007199254740993}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/Whole numbers must be within/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Send for a non-string prompt argument", async () => {
|
||||
renderWithMantine(<EditReplayModal {...baseProps} method="prompts/get" />);
|
||||
await setAceText('{"name":"greet","arguments":{"count":2}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/`count` would be sent as JSON text/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes without sending when cancelled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
|
||||
import { isJsonObject } from "../../../utils/jsonObjectDraft";
|
||||
import {
|
||||
hasRoundedInteger,
|
||||
isJsonObject,
|
||||
ROUNDED_INTEGER_ERROR,
|
||||
} from "../../../utils/jsonObjectDraft";
|
||||
import {
|
||||
replayableParams,
|
||||
reshapedReplayParam,
|
||||
|
|
@ -95,6 +99,9 @@ function parseParamsDraft(
|
|||
"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 };
|
||||
}
|
||||
// Seeding from the projection is only half of it: a key the dispatcher does
|
||||
// not read can also be *added* to the draft, and Send would then discard an
|
||||
// edit the user was looking at. Checked with the same function the seed is
|
||||
|
|
|
|||
|
|
@ -1913,6 +1913,22 @@ describe("SchemaForm raw JSON (#2151)", () => {
|
|||
expect(screen.getByText(/Numbers must be finite/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The quieter half of the same defect the `1e400` case covers: a whole number
|
||||
// past 2^53−1 is rounded by `JSON.parse`, so the editor would show digits the
|
||||
// wire will not carry.
|
||||
it("blocks submission for a whole number that loses digits when parsed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValidityChange = vi.fn();
|
||||
renderWithMantine(<RawHarness onValidityChange={onValidityChange} />);
|
||||
await enableRawJson(user);
|
||||
|
||||
await setAceTextByLabel(/Arguments JSON/, '{"id":9007199254740993}');
|
||||
expect(onValidityChange).toHaveBeenLastCalledWith(true);
|
||||
expect(
|
||||
screen.getByText(/Whole numbers must be within/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Nothing in `values` names a branch, so the picker's index is held by the
|
||||
// form. Editing the discriminator in the raw document is a change to which
|
||||
// branch is in effect, and it carries no other signal — without the re-derive
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ import {
|
|||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import { EnlargeButton } from "../../elements/EnlargeButton/EnlargeButton";
|
||||
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
|
||||
import { isJsonObject } from "../../../utils/jsonObjectDraft";
|
||||
import {
|
||||
hasRoundedInteger,
|
||||
isJsonObject,
|
||||
ROUNDED_INTEGER_ERROR,
|
||||
} from "../../../utils/jsonObjectDraft";
|
||||
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
|
||||
import { useValueChange } from "../../../hooks/useValueChange";
|
||||
import type {
|
||||
|
|
@ -280,9 +284,10 @@ function toNumericValue(raw: string | number): number | undefined {
|
|||
* That covers text that is not JSON *yet* — the mid-edit state this field's
|
||||
* whole draft/value split exists for — and text that parses but does not
|
||||
* survive being written back out: `JSON.parse("1e400")` yields `Infinity`,
|
||||
* which `JSON.stringify` writes as `null`. Both report no value, so the field
|
||||
* shows its error and `onValidityChange` blocks submission, rather than sending
|
||||
* a number the user never typed. Same guard, same reason, as
|
||||
* which `JSON.stringify` writes as `null`, and a whole number past 2^53−1 is
|
||||
* rounded to the nearest double. All three report no value, so the field shows
|
||||
* its error and `onValidityChange` blocks submission, rather than sending a
|
||||
* number the user never typed. Same guards, same reason, as
|
||||
* `parseJsonObjectDraft` and the raw-arguments editor above.
|
||||
*/
|
||||
function parseJsonDraft(text: string): unknown {
|
||||
|
|
@ -295,7 +300,10 @@ function parseJsonDraft(text: string): unknown {
|
|||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return isSerializableJson(parsed) ? parsed : undefined;
|
||||
if (!isSerializableJson(parsed) || hasRoundedInteger(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Structural equality for the draft/value re-sync, via canonical JSON. */
|
||||
|
|
@ -609,6 +617,11 @@ function parseRawArgumentsDraft(
|
|||
"Numbers must be finite — a value like `1e400` overflows and would be sent as null",
|
||||
};
|
||||
}
|
||||
// The quieter half of the same defect: a whole number past 2^53−1 parses to
|
||||
// the nearest double, so the draft shows digits the wire will not carry.
|
||||
if (hasRoundedInteger(parsed)) {
|
||||
return { ok: false, error: ROUNDED_INTEGER_ERROR };
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue