From 270bc38f7282f4bead10a652b7b328a96f6d739d Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Sat, 17 Oct 2026 19:02:26 -0700 Subject: [PATCH] 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 --- .../ClientConfigurationDocument.ts | 35 +++++- src/application/ClientRegistrationStatus.ts | 13 +- src/application/SetupClientConfiguration.ts | 4 +- .../tomlConfigurationIntegerRangeCli.test.ts | 118 ++++++++++++++++++ 4 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 tests/acceptance/setup/tomlConfigurationIntegerRangeCli.test.ts diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index 087d5a6f1a..f36446f143 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -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 => { + 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 => { 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) { diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 9d6acea53b..68fc3c37a3 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -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 diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index d0d6c6f242..ed7b1bb495 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -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; diff --git a/tests/acceptance/setup/tomlConfigurationIntegerRangeCli.test.ts b/tests/acceptance/setup/tomlConfigurationIntegerRangeCli.test.ts new file mode 100644 index 0000000000..eb558517a3 --- /dev/null +++ b/tests/acceptance/setup/tomlConfigurationIntegerRangeCli.test.ts @@ -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); + }, + );