Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 4c6f285930 test(worktrees): await repository cleanup after remote timeouts 2026-10-12 06:20:51 +02:00
paseo-bot[bot] 23fb1cc85a test(schedules): use the canonical Git root for worktree fixtures 2026-10-12 06:20:51 +02:00
paseo-bot[bot]and见安 fd1551d46e 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>
2026-10-12 06:20:51 +02:00
见安 4d67f08922 test(cli): verify integer options against a real daemon 2026-10-12 06:20:51 +02:00
见安 35edd688b5 fix(terminal): validate complete capture line numbers 2026-10-12 06:20:22 +02:00
见安 eed6478a7d fix(schedule): validate complete max-runs values 2026-10-12 06:20:22 +02:00
8 changed files with 293 additions and 8 deletions
+3 -2
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,8 +442,8 @@ function buildNewAgentConfigPatch(
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
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,8 +86,8 @@ function parseLineNumber(flag: string, value?: string): number | undefined {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) {
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;
}
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/schedule-run-lifecycle.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, expect, test } from "vitest";
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { runPaseoCli } from "../test-utils/cli.js";
let daemon: TestPaseoDaemon;
let client: DaemonClient;
@@ -28,6 +29,75 @@ afterEach(async () => {
await rm(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
});
async function createQuietTerminal() {
const workspaceId = await createWorkspace("CLI capture");
return sdk.terminals.create({
workspaceId,
command: process.execPath,
args: ["-e", "process.stdin.resume()"],
size: { rows: 10, cols: 40 },
});
}
test.each([
["--end", "4503599627370496.5"],
["--start", "1.0000000000000001"],
["--end", "4.5035996273704965e15"],
["--end", "1e-324"],
["--start", "1.5"],
["--end", "1.5"],
["--start", "1abc"],
["--end", "1abc"],
["--start", ""],
["--end", ""],
["--start", "9007199254740993"],
["--end", "9007199254740993"],
])("CLI terminal capture rejects %s %j", async (flag, value) => {
const terminal = await createQuietTerminal();
try {
const result = await runPaseoCli({
daemon,
args: ["terminal", "capture", terminal.id, flag, value, "--json"],
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
details: "Use an integer line number.",
},
});
} finally {
await terminal.kill();
}
});
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 }) => {
const terminal = await createQuietTerminal();
try {
const result = await runPaseoCli({
daemon,
args: ["terminal", "capture", terminal.id, ...args, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
terminalId: terminal.id,
lines: Array.from({ length: lineCount }, () => ""),
totalLines: 10,
});
} finally {
await terminal.kill();
}
});
test("SDK and workspace handles preserve ownership and actual process directories", async () => {
const first = await createWorkspace("main");
const second = await createWorkspace("feature-work");
@@ -13,6 +13,7 @@ import {
} from "./test-utils/index.js";
import type { SessionOutboundMessage } from "./messages.js";
import { getPaseoWorktreesRoot } from "../utils/worktree.js";
import { runPaseoCli } from "./test-utils/cli.js";
type AgentUpdateMessage = Extract<SessionOutboundMessage, { type: "agent_update" }>;
type WorkspaceUpdateMessage = Extract<SessionOutboundMessage, { type: "workspace_update" }>;
@@ -42,6 +43,137 @@ function makeTempDir(prefix: string): string {
return dir;
}
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", "4503599627370496.5"])(
"CLI schedule update rejects max-runs %j without changing the schedule",
async (maxRuns) => {
const cwd = makeTempDir("schedule-cli-update-integer-");
const schedule = await createNewAgentSchedule({
prompt: "Original prompt",
cadence: { type: "cron", expression: "0 0 1 1 *", timezone: "UTC" },
target: { type: "new-agent", config: { provider: "claude", cwd } },
maxRuns: 3,
runOnCreate: false,
});
const before = await ctx.client.scheduleInspect({ id: schedule.id });
const result = await runPaseoCli({
daemon: ctx.daemon,
args: [
"schedule",
"update",
schedule.id,
"--prompt",
"Should not apply",
"--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" },
});
const after = await ctx.client.scheduleInspect({ id: schedule.id });
expect(after.error).toBeNull();
expect(after.schedule).toEqual(before.schedule);
},
);
test.each([
{ limitArgs: [], maxRuns: null },
{ limitArgs: ["--max-runs", "3"], maxRuns: 3 },
{ limitArgs: ["--max-runs", "3.0"], maxRuns: 3 },
{ limitArgs: ["--max-runs", "1e2"], maxRuns: 100 },
])(
"CLI schedule create stores max-runs $maxRuns from $limitArgs",
async ({ limitArgs, maxRuns }) => {
const cwd = makeTempDir("schedule-cli-valid-integer-");
const result = await runPaseoCli({
daemon: ctx.daemon,
args: [
"schedule",
"create",
"Integer limit",
"--cron",
"0 0 1 1 *",
"--provider",
"claude",
"--cwd",
cwd,
...limitArgs,
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
expect((await ctx.client.scheduleList()).schedules).toEqual([
expect.objectContaining({ maxRuns, prompt: "Integer limit" }),
]);
},
);
test("CLI schedule update preserves, changes, and clears valid run limits", async () => {
const cwd = makeTempDir("schedule-cli-valid-update-");
const schedule = await createNewAgentSchedule({
prompt: "Original prompt",
cadence: { type: "cron", expression: "0 0 1 1 *", timezone: "UTC" },
target: { type: "new-agent", config: { provider: "claude", cwd } },
maxRuns: 3,
runOnCreate: false,
});
for (const { args, maxRuns } of [
{ args: ["--name", "Renamed"], maxRuns: 3 },
{ args: ["--max-runs", "5"], maxRuns: 5 },
{ args: ["--no-max-runs"], maxRuns: null },
]) {
const result = await runPaseoCli({
daemon: ctx.daemon,
args: ["schedule", "update", schedule.id, ...args, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
expect((await ctx.client.scheduleInspect({ id: schedule.id })).schedule).toMatchObject({
maxRuns,
name: "Renamed",
prompt: schedule.prompt,
target: schedule.target,
cadence: schedule.cadence,
});
}
}, 60_000);
function createGitRepo(): string {
const tempRoot = makeTempDir("schedule-run-worktree-");
const repoDir = path.join(tempRoot, "repo");
@@ -60,7 +192,13 @@ function createGitRepo(): string {
cwd: repoDir,
stdio: "pipe",
});
return repoDir;
// Use Git's canonical root, as worktree creation does, including Windows path aliases.
return realpathSync(
execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: repoDir,
encoding: "utf8",
}).trim(),
);
}
async function createNewAgentSchedule(options: ScheduleCreateOptions): Promise<ScheduleSummary> {
@@ -0,0 +1,54 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import type { TestPaseoDaemon } from "./paseo-daemon.js";
interface CliOptions {
daemon: Pick<TestPaseoDaemon, "port" | "paseoHome">;
args: string[];
}
interface CliResult {
exitCode: number | null;
stdout: string;
stderr: string;
}
export function runPaseoCli({ daemon, args }: CliOptions): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[
"--no-warnings",
"--conditions=source",
"--import",
"tsx",
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${daemon.port}`,
...args,
],
{
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
stdio: ["ignore", "pipe", "pipe"],
timeout: 20_000,
killSignal: "SIGKILL",
},
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.once("error", reject);
child.once("close", (exitCode, signal) => {
if (signal) {
reject(new Error(`CLI exited on ${signal}:\n${stdout}\n${stderr}`));
} else {
resolve({ exitCode, stdout, stderr });
}
});
});
}
+3 -2
View File
@@ -10,6 +10,7 @@ import {
type WorktreeConfig,
} from "./worktree";
import { execFileSync } from "child_process";
import { rm } from "node:fs/promises";
import {
mkdtempSync,
mkdirSync,
@@ -89,8 +90,8 @@ describe("paseo worktree manager", () => {
});
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {