fix(setup): read signed 64-bit TOML integers losslessly (#4937)

Codex and Grok client TOML was parsed with smol-toml's default reader,
which rejects any integer beyond JavaScript's safe Number range. Valid
TOML 1.0 Int64 values such as `max_bytes = 9223372036854775807` made
setup, doctor and uninstall treat the whole file as malformed.

The client TOML owner now parses with `integersAsBigInt: "asNeeded"`,
keeping safe integers as numbers and larger ones exact as bigint, and
rejects values outside signed Int64 before any write. All TOML parses in
the document owner (document read, inline string decoding and the
preserving-edit check) use that reader. The inline-table writer emits
bigint values, and setup and doctor accept a positive bigint
`startup_timeout_sec`/`tool_timeout_sec` instead of replacing or
rejecting it.

Fixes #4917

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-17 19:02:26 -07:00
committed by GitHub
parent d5fc7e6ebf
commit 270bc38f72
4 changed files with 163 additions and 7 deletions
+32 -3
View File
@@ -104,12 +104,40 @@ export const clientConfigurationServersKey = (
const isEmptyClientConfigurationText = (text: string): boolean =>
text.replace(/^\uFEFF/, "").trim() === "";
const INT64_MIN = -(2n ** 63n);
const INT64_MAX = 2n ** 63n - 1n;
/**
* Parse client TOML with TOML 1.0's signed 64-bit integer contract. Integers
* beyond JavaScript's safe range stay exact as bigint; values outside Int64,
* which Codex rejects, are malformed.
*/
const parseClientToml = (text: string): Record<string, unknown> => {
const parsed = parseToml(text, { integersAsBigInt: "asNeeded" });
const pending: unknown[] = [parsed];
for (let value = pending.pop(); value !== undefined; value = pending.pop()) {
if (typeof value === "bigint") {
if (value < INT64_MIN || value > INT64_MAX)
throw new SyntaxError(
`Invalid TOML document: integer ${String(value)} is outside the signed 64-bit range`,
);
} else if (
typeof value === "object" &&
value !== null &&
!(value instanceof Date)
)
// Large documents can exceed argument limits, so do not spread.
for (const nested of Object.values(value)) pending.push(nested);
}
return parsed;
};
const parseDocument = (
text: string,
format: ClientConfigurationFormat,
): Record<string, unknown> => {
if (format === "toml" || format === "grok")
return requireObject(parseToml(text), "The configuration root");
return requireObject(parseClientToml(text), "The configuration root");
// Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so
// comments, trailing commas, a BOM, or an empty existing file must be
// rejected instead of registering into a document the client cannot load.
@@ -646,6 +674,7 @@ const tomlInlineValue = (value: unknown): string => {
if (typeof value === "string") return tomlBasicString(value);
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number" && Number.isFinite(value)) return String(value);
if (typeof value === "bigint") return String(value);
if (Array.isArray(value))
return `[${value.map((item) => tomlInlineValue(item)).join(", ")}]`;
if (typeof value === "object" && value !== null) {
@@ -817,7 +846,7 @@ const rebuildDelimitedEntries = (
*/
const tomlStringValue = (text: string): string | undefined => {
try {
const value = parseToml(`value = ${text}`)["value"];
const value = parseClientToml(`value = ${text}`)["value"];
return typeof value === "string" ? value : undefined;
} catch (cause: unknown) {
void cause;
@@ -1251,7 +1280,7 @@ const tomlTextHolds = (
): boolean => {
try {
return clientConfigurationValuesEqual(
withoutEmptyServerTable(objectSchema.parse(parseToml(text))),
withoutEmptyServerTable(objectSchema.parse(parseClientToml(text))),
withoutEmptyServerTable(document),
);
} catch (cause: unknown) {
+10 -3
View File
@@ -80,12 +80,18 @@ export type UnhealthyClientRegistrationStatus = Exclude<
{ readonly state: "aligned" }
>;
const tomlTimeoutSchema = z.union([
z.number().positive(),
z.bigint().positive(),
]);
const registrationSchema = z
.object({
command: z.string().min(1),
args: z.array(z.string()).default([]),
startup_timeout_sec: z.number().positive().optional(),
tool_timeout_sec: z.number().positive().optional(),
// TOML keeps integers beyond the safe Number range exact as bigint.
startup_timeout_sec: tomlTimeoutSchema.optional(),
tool_timeout_sec: tomlTimeoutSchema.optional(),
startupTimeoutMs: z.number().positive().optional(),
toolTimeoutMs: z.number().positive().optional(),
type: z.string().optional(),
@@ -546,7 +552,8 @@ const timeoutAdvisory = (
if (toolTimeout === undefined) return undefined;
const providerAllowance = mcpProviderDeadlineAllowanceMs(timeoutInputs);
const isKimi = client.name === "kimi";
const toolTimeoutMs = isKimi ? toolTimeout : toolTimeout * 1000;
// A bigint is beyond the safe Number range, so rounding cannot cross the allowance.
const toolTimeoutMs = Number(toolTimeout) * (isKimi ? 1 : 1000);
if (toolTimeoutMs >= providerAllowance) return undefined;
const setting = isKimi ? "toolTimeoutMs" : "tool_timeout_sec";
const currentValue = isKimi
+3 -1
View File
@@ -792,9 +792,11 @@ const clientConfigurationDesired = (
const positiveTimeout = (
registration: unknown,
key: string,
): number | undefined => {
): number | bigint | undefined => {
if (typeof registration !== "object" || registration === null) return;
const value: unknown = Reflect.get(registration, key);
// TOML keeps integers beyond the safe Number range exact as bigint.
if (typeof value === "bigint") return value > 0n ? value : undefined;
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: undefined;
@@ -0,0 +1,118 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const environmentFor = (home: string) => ({
HOME: home,
USERPROFILE: home,
CODEX_HOME: join(home, ".codex"),
XDG_CONFIG_HOME: join(home, ".config"),
TMPDIR: home,
});
const setupArguments = [
"setup",
"--client",
"codex",
"--skill=false",
"--json",
] as const;
cliTest(
"codex setup and uninstall preserve signed 64-bit TOML integers exactly",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-toml-int64-");
const config = join(home, ".codex/config.toml");
const original =
"[history]\n" +
"max_bytes = 9223372036854775807 # Int64 maximum\n" +
"floor = -9223372036854775808\n" +
"spelled = 0x7fff_ffff_ffff_ffff\n";
await mkdir(dirname(config), { recursive: true });
await writeFile(config, original);
const run = (arguments_: readonly string[]) =>
cli.run({
arguments: arguments_,
cwd: home,
environment: environmentFor(home),
});
const preview = await run([...setupArguments, "--dry-run"]);
expect(preview.exitCode, preview.stdout).toBe(0);
expect(preview.json).toMatchObject({ status: "planned" });
expect(await readFile(config, "utf8")).toBe(original);
const applied = await run([...setupArguments, "--yes"]);
expect(applied.exitCode, applied.stdout).toBe(0);
expect(applied.json).toMatchObject({
status: "ready",
clients: { codex: { status: "configured" } },
});
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original);
const configured = await readFile(config, "utf8");
expect(configured.startsWith(original)).toBe(true);
expect(configured).toContain("[mcp_servers.rea]");
const repeated = await run([...setupArguments, "--yes"]);
expect(repeated.exitCode, repeated.stdout).toBe(0);
expect(repeated.json).toMatchObject({
status: "ready",
appliedActions: [],
});
expect(await readFile(config, "utf8")).toBe(configured);
const removed = await run(["uninstall", "--json"]);
expect(removed.exitCode, removed.stdout).toBe(0);
expect(removed.json).toMatchObject({ status: "complete" });
expect(await readFile(config, "utf8")).toBe(original);
},
);
cliTest(
"codex setup keeps exact integers and a large REA timeout in an inline server table",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-toml-int64-inline-");
const config = join(home, ".codex/config.toml");
const original =
"# retained comment\n" +
'mcp_servers = { other = { command = "other", budget = 9007199254740993 }, ' +
'rea = { command = "previous", tool_timeout_sec = 9223372036854775807 } }\n';
await mkdir(dirname(config), { recursive: true });
await writeFile(config, original);
const applied = await cli.run({
arguments: [...setupArguments, "--yes"],
cwd: home,
environment: environmentFor(home),
});
expect(applied.exitCode, applied.stdout).toBe(0);
const configured = await readFile(config, "utf8");
expect(configured.startsWith("# retained comment\n")).toBe(true);
expect(configured).toContain("budget = 9007199254740993");
expect(configured).toContain("tool_timeout_sec = 9223372036854775807");
expect(configured).not.toContain('"previous"');
},
);
for (const value of ["9223372036854775808", "-9223372036854775809"])
cliTest(
`codex setup rejects ${value} outside Int64 before writing`,
async ({ cli }) => {
const home = await createTestTempDirectory("rea-toml-int64-range-");
const config = join(home, ".codex/config.toml");
const original = `[history]\nmax_bytes = ${value}\n`;
await mkdir(dirname(config), { recursive: true });
await writeFile(config, original);
const applied = await cli.run({
arguments: [...setupArguments, "--yes"],
cwd: home,
environment: environmentFor(home),
});
expect(applied.exitCode, applied.stdout).toBe(1);
expect(applied.json).toMatchObject({ status: "needs_human" });
expect(applied.stdout).toContain("outside the signed 64-bit range");
expect(await readFile(config, "utf8")).toBe(original);
},
);