fix(cli): reject fractional integer options before rounding

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>
This commit is contained in:
paseo-bot[bot]
2026-10-12 06:20:51 +02:00
co-authored by 见安
parent 4d67f08922
commit fd1551d46e
6 changed files with 77 additions and 36 deletions
+3 -3
View File
@@ -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`,
@@ -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}`,
+20
View File
@@ -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;
}
@@ -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 }) => {
@@ -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-");
+9 -1
View File
@@ -30,6 +30,8 @@ export function runPaseoCli({ daemon, args }: CliOptions): Promise<CliResult> {
{
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<CliResult> {
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 });
}
});
});
}