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); + }, + );