fix: reject partial numeric CLI options

This commit is contained in:
Peter Steinberger
2026-05-27 03:34:44 -04:00
parent f4e20f806e
commit d2d5010aec
13 changed files with 195 additions and 32 deletions

View File

@@ -20,6 +20,8 @@ import {
buildNodeInvokeParams,
callGatewayCli,
nodesCallOpts,
parseOptionalNodeNonNegativeInteger,
parseOptionalNodePositiveInteger,
resolveNode,
resolveNodeId,
} from "./rpc.js";
@@ -131,16 +133,17 @@ export function registerNodesCameraCommands(nodes: Command) {
);
})();
const maxWidth = opts.maxWidth ? Number.parseInt(opts.maxWidth, 10) : undefined;
const maxWidth = parseOptionalNodePositiveInteger(opts.maxWidth, "--max-width");
const quality = opts.quality ? Number.parseFloat(opts.quality) : undefined;
const delayMs = opts.delayMs ? Number.parseInt(opts.delayMs, 10) : undefined;
const delayMs = parseOptionalNodeNonNegativeInteger(opts.delayMs, "--delay-ms");
const deviceId = normalizeOptionalString(opts.deviceId);
if (deviceId && facings.length > 1) {
throw new Error("facing=both is not allowed when --device-id is set");
}
const timeoutMs = opts.invokeTimeout
? Number.parseInt(opts.invokeTimeout, 10)
: undefined;
const timeoutMs = parseOptionalNodePositiveInteger(
opts.invokeTimeout,
"--invoke-timeout",
);
const results: Array<{
facing: CameraFacing;
@@ -216,9 +219,10 @@ export function registerNodesCameraCommands(nodes: Command) {
const facing = parseFacing(opts.facing ?? "front");
const durationMs = parseDurationMs(opts.duration ?? "3000");
const includeAudio = opts.audio !== false;
const timeoutMs = opts.invokeTimeout
? Number.parseInt(opts.invokeTimeout, 10)
: undefined;
const timeoutMs = parseOptionalNodePositiveInteger(
opts.invokeTimeout,
"--invoke-timeout",
);
const deviceId = normalizeOptionalString(opts.deviceId);
const invokeParams = buildNodeInvokeParams({

View File

@@ -6,7 +6,12 @@ import {
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import {
callGatewayCli,
nodesCallOpts,
parseOptionalNodePositiveInteger,
resolveNodeId,
} from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
const BLOCKED_NODE_INVOKE_COMMANDS = new Set(["system.run", "system.run.prepare"]);
@@ -37,9 +42,10 @@ export function registerNodesInvokeCommands(nodes: Command) {
);
}
const params = JSON.parse(opts.params ?? "{}") as unknown;
const timeoutMs = opts.invokeTimeout
? Number.parseInt(opts.invokeTimeout, 10)
: undefined;
const timeoutMs = parseOptionalNodePositiveInteger(
opts.invokeTimeout,
"--invoke-timeout",
);
const invokeParams: Record<string, unknown> = {
nodeId,

View File

@@ -3,7 +3,13 @@ import { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
import { runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import {
callGatewayCli,
nodesCallOpts,
parseOptionalNodeNonNegativeInteger,
parseOptionalNodePositiveInteger,
resolveNodeId,
} from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
export function registerNodesLocationCommands(nodes: Command) {
@@ -24,7 +30,7 @@ export function registerNodesLocationCommands(nodes: Command) {
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("location get", async () => {
const nodeId = await resolveNodeId(opts, opts.node ?? "");
const maxAgeMs = opts.maxAge ? Number.parseInt(opts.maxAge, 10) : undefined;
const maxAgeMs = parseOptionalNodeNonNegativeInteger(opts.maxAge, "--max-age");
const desiredAccuracyRaw = normalizeOptionalLowercaseString(opts.accuracy);
const desiredAccuracy =
desiredAccuracyRaw === "coarse" ||
@@ -32,12 +38,14 @@ export function registerNodesLocationCommands(nodes: Command) {
desiredAccuracyRaw === "precise"
? desiredAccuracyRaw
: undefined;
const timeoutMs = opts.locationTimeout
? Number.parseInt(opts.locationTimeout, 10)
: undefined;
const invokeTimeoutMs = opts.invokeTimeout
? Number.parseInt(opts.invokeTimeout, 10)
: undefined;
const timeoutMs = parseOptionalNodePositiveInteger(
opts.locationTimeout,
"--location-timeout",
);
const invokeTimeoutMs = parseOptionalNodePositiveInteger(
opts.invokeTimeout,
"--invoke-timeout",
);
const invokeParams: Record<string, unknown> = {
nodeId,

View File

@@ -3,7 +3,12 @@ import { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import {
callGatewayCli,
nodesCallOpts,
parseOptionalNodePositiveInteger,
resolveNodeId,
} from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
export function registerNodesNotifyCommand(nodes: Command) {
@@ -26,9 +31,10 @@ export function registerNodesNotifyCommand(nodes: Command) {
if (!title && !body) {
throw new Error("missing --title or --body");
}
const invokeTimeout = opts.invokeTimeout
? Number.parseInt(opts.invokeTimeout, 10)
: undefined;
const invokeTimeout = parseOptionalNodePositiveInteger(
opts.invokeTimeout,
"--invoke-timeout",
);
const invokeParams: Record<string, unknown> = {
nodeId,
command: "system.notify",

View File

@@ -8,7 +8,14 @@ import {
} from "../nodes-screen.js";
import { parseDurationMs } from "../parse-duration.js";
import { runNodesCommand } from "./cli-utils.js";
import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import {
buildNodeInvokeParams,
callGatewayCli,
nodesCallOpts,
parseOptionalNodeNonNegativeInteger,
parseOptionalNodePositiveInteger,
resolveNodeId,
} from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
export function registerNodesScreenCommands(nodes: Command) {
@@ -31,11 +38,12 @@ export function registerNodesScreenCommands(nodes: Command) {
await runNodesCommand("screen record", async () => {
const nodeId = await resolveNodeId(opts, opts.node ?? "");
const durationMs = parseDurationMs(opts.duration ?? "");
const screenIndex = Number.parseInt(opts.screen ?? "0", 10);
const screenIndex = parseOptionalNodeNonNegativeInteger(opts.screen ?? "0", "--screen");
const fps = Number.parseFloat(opts.fps ?? "10");
const timeoutMs = opts.invokeTimeout
? Number.parseInt(opts.invokeTimeout, 10)
: undefined;
const timeoutMs = parseOptionalNodePositiveInteger(
opts.invokeTimeout,
"--invoke-timeout",
);
const invokeParams = buildNodeInvokeParams({
nodeId,

View File

@@ -1,6 +1,10 @@
import { randomUUID } from "node:crypto";
import type { Command } from "commander";
import type { OperatorScope } from "../../gateway/method-scopes.js";
import {
parseStrictNonNegativeInteger,
parseStrictPositiveInteger,
} from "../../infra/parse-finite-number.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { resolveNodeFromNodeList } from "../../shared/node-resolve.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
@@ -63,6 +67,35 @@ export function buildNodeInvokeParams(params: {
return invokeParams;
}
function hasOptionalValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== "";
}
export function parseOptionalNodePositiveInteger(value: unknown, flag: string): number | undefined {
if (!hasOptionalValue(value)) {
return undefined;
}
const parsed = parseStrictPositiveInteger(value);
if (parsed === undefined) {
throw new Error(`${flag} must be a positive integer.`);
}
return parsed;
}
export function parseOptionalNodeNonNegativeInteger(
value: unknown,
flag: string,
): number | undefined {
if (!hasOptionalValue(value)) {
return undefined;
}
const parsed = parseStrictNonNegativeInteger(value);
if (parsed === undefined) {
throw new Error(`${flag} must be a non-negative integer.`);
}
return parsed;
}
export function unauthorizedHintForMessage(message: string): string | null {
const haystack = normalizeLowercaseStringOrEmpty(message);
if (