From fd1551d46e1943efe6f2a65f974963033c9e6e8d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:34:45 +0200 Subject: [PATCH] fix(cli): reject fractional integer options before rounding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate decimal integrality before Number conversion can hide a fraction. Keep exact integer scientific notation, signed capture indices, and schedule limit updates working. Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com> --- packages/cli/src/commands/schedule/shared.ts | 6 +- packages/cli/src/commands/terminal/capture.ts | 6 +- packages/cli/src/utils/integer.ts | 20 ++++++ .../terminal-workspace-sdk.e2e.test.ts | 7 ++ .../server/schedule-run-lifecycle.e2e.test.ts | 64 ++++++++++--------- packages/server/src/server/test-utils/cli.ts | 10 ++- 6 files changed, 77 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/utils/integer.ts diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts index 40f161cdb9..4c5f43b505 100644 --- a/packages/cli/src/commands/schedule/shared.ts +++ b/packages/cli/src/commands/schedule/shared.ts @@ -1,3 +1,4 @@ +import { parseSafeInteger } from "../../utils/integer.js"; import type { DaemonTarget } from "../../utils/daemon-target.js"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, CommandOptions } from "../../output/index.js"; @@ -441,9 +442,8 @@ function buildNewAgentConfigPatch( } function parsePositiveInt(value: string, flag: string): number { - const parsed = Number(value); - const isPositiveInteger = Number.isSafeInteger(parsed) && parsed > 0; - if (!isPositiveInteger) { + const parsed = parseSafeInteger(value); + if (parsed === undefined || parsed <= 0) { throw { code: "INVALID_INTEGER", message: `${flag} must be a positive integer`, diff --git a/packages/cli/src/commands/terminal/capture.ts b/packages/cli/src/commands/terminal/capture.ts index 3519ce2064..94f2dc2fc1 100644 --- a/packages/cli/src/commands/terminal/capture.ts +++ b/packages/cli/src/commands/terminal/capture.ts @@ -1,3 +1,4 @@ +import { parseSafeInteger } from "../../utils/integer.js"; import type { Command } from "commander"; import { renderError, toCommandError } from "../../output/render.js"; import { @@ -85,9 +86,8 @@ function parseLineNumber(flag: string, value?: string): number | undefined { return undefined; } - const parsed = Number(value); - const isIntegerLineNumber = value.trim().length > 0 && Number.isSafeInteger(parsed); - if (!isIntegerLineNumber) { + const parsed = parseSafeInteger(value); + if (parsed === undefined) { throw { code: "INVALID_LINE_NUMBER", message: `Invalid ${flag} value: ${value}`, diff --git a/packages/cli/src/utils/integer.ts b/packages/cli/src/utils/integer.ts new file mode 100644 index 0000000000..e6fbf5f520 --- /dev/null +++ b/packages/cli/src/utils/integer.ts @@ -0,0 +1,20 @@ +export function parseSafeInteger(value: string): number | undefined { + const input = value.trim(); + const match = /^[+-]?(\d*)(?:\.(\d*))?(?:e([+-]?\d+))?$/i.exec(input); + if (!match) { + return undefined; + } + + const digits = match[1] + (match[2] ?? ""); + const parsed = Number(input); + if (digits.length === 0 || !Number.isSafeInteger(parsed)) { + return undefined; + } + + // Check the decimal value before floating-point rounding can hide a fraction. + const decimalPlaces = (match[2]?.length ?? 0) - Number(match[3] ?? "0"); + if (decimalPlaces > 0 && /[1-9]/.test(digits.slice(-decimalPlaces))) { + return undefined; + } + return parsed; +} diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts index 82ba1b5cab..35c94f2a9c 100644 --- a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -40,6 +40,10 @@ async function createQuietTerminal() { } test.each([ + ["--end", "4503599627370496.5"], + ["--start", "1.0000000000000001"], + ["--end", "4.5035996273704965e15"], + ["--end", "1e-324"], ["--start", "1.5"], ["--end", "1.5"], ["--start", "1abc"], @@ -71,6 +75,9 @@ test.each([ test.each([ { args: [], lineCount: 10 }, { args: ["--start", "0", "--end", "1"], lineCount: 2 }, + { args: ["--end", "1.0"], lineCount: 2 }, + { args: ["--end", "10e-1"], lineCount: 2 }, + { args: ["--end", ".1e1"], lineCount: 2 }, { args: ["--start", "-2", "--end", "-1"], lineCount: 2 }, { args: ["--scrollback", "--start", "7", "--end", "1"], lineCount: 2 }, ])("CLI terminal capture preserves valid ranges $args", async ({ args, lineCount }) => { diff --git a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts index c671b42164..944fda2ba8 100644 --- a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts +++ b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts @@ -43,36 +43,42 @@ function makeTempDir(prefix: string): string { return dir; } -test.each(["2.5", "2runs", "9007199254740993", "0", "-1", ""])( - "CLI schedule create rejects max-runs %j without saving a schedule", - async (maxRuns) => { - const cwd = makeTempDir("schedule-cli-integer-"); - const result = await runPaseoCli({ - daemon: ctx.daemon, - args: [ - "schedule", - "create", - "Integer limit", - "--cron", - "0 0 1 1 *", - "--provider", - "claude", - "--cwd", - cwd, - "--max-runs", - maxRuns, - "--json", - ], - }); - expect(result.exitCode, result.stdout + result.stderr).toBe(1); - expect(JSON.parse(result.stderr)).toEqual({ - error: { code: "INVALID_INTEGER", message: "--max-runs must be a positive integer" }, - }); - expect((await ctx.client.scheduleList()).schedules).toEqual([]); - }, -); +test.each([ + "4503599627370496.5", + "1.0000000000000001", + "2.5", + "2runs", + "9007199254740993", + "0", + "-1", + "", +])("CLI schedule create rejects max-runs %j without saving a schedule", async (maxRuns) => { + const cwd = makeTempDir("schedule-cli-integer-"); + const result = await runPaseoCli({ + daemon: ctx.daemon, + args: [ + "schedule", + "create", + "Integer limit", + "--cron", + "0 0 1 1 *", + "--provider", + "claude", + "--cwd", + cwd, + "--max-runs", + maxRuns, + "--json", + ], + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(1); + expect(JSON.parse(result.stderr)).toEqual({ + error: { code: "INVALID_INTEGER", message: "--max-runs must be a positive integer" }, + }); + expect((await ctx.client.scheduleList()).schedules).toEqual([]); +}); -test.each(["2.5", "2runs"])( +test.each(["2.5", "2runs", "4503599627370496.5"])( "CLI schedule update rejects max-runs %j without changing the schedule", async (maxRuns) => { const cwd = makeTempDir("schedule-cli-update-integer-"); diff --git a/packages/server/src/server/test-utils/cli.ts b/packages/server/src/server/test-utils/cli.ts index e180519fda..32c66da9c2 100644 --- a/packages/server/src/server/test-utils/cli.ts +++ b/packages/server/src/server/test-utils/cli.ts @@ -30,6 +30,8 @@ export function runPaseoCli({ daemon, args }: CliOptions): Promise { { env: { ...process.env, PASEO_HOME: daemon.paseoHome }, stdio: ["ignore", "pipe", "pipe"], + timeout: 20_000, + killSignal: "SIGKILL", }, ); let stdout = ""; @@ -41,6 +43,12 @@ export function runPaseoCli({ daemon, args }: CliOptions): Promise { stderr += chunk.toString(); }); child.once("error", reject); - child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr })); + child.once("close", (exitCode, signal) => { + if (signal) { + reject(new Error(`CLI exited on ${signal}:\n${stdout}\n${stderr}`)); + } else { + resolve({ exitCode, stdout, stderr }); + } + }); }); }