Skip to content

Commit 4dadf7e

Browse files
ruanrrnvincentkoc
andcommitted
fix(cli): validate gateway-rpc --timeout and cron --timeout-seconds
Reject malformed explicit gateway RPC timeout values while preserving the 30000 ms omitted-option default. Keep cron agent timeout validation from silently dropping invalid inputs. - Add resolveGatewayRpcTimeoutMs() to gateway-rpc.ts for strict validation - Reject non-positive-integer --timeout values instead of silently falling back - Validate --timeout-seconds in cron add CLI (checks option source) - Add gateway-rpc.timeout-option.test.ts with edge case coverage - Carry forward #54646 with adaptations for current main Co-Authored-By: ProjectClownfish <[email protected]>
1 parent 3e592a8 commit 4dadf7e

3 files changed

Lines changed: 78 additions & 0 deletions

File tree

src/cli/cron-cli/register.cron-add.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,12 @@ export function registerCronAddCommand(cron: Command) {
161161
? (name: string) => cmd.getOptionValueSource(name)
162162
: () => undefined;
163163

164+
const timeoutSecondsRaw = opts.timeoutSeconds;
165+
const timeoutSecondsCli = parsePositiveIntOrUndefined(timeoutSecondsRaw);
166+
if (optionSource("timeoutSeconds") === "cli" && timeoutSecondsCli === undefined) {
167+
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
168+
}
169+
164170
const hasAnnounce = Boolean(opts.announce) || opts.deliver === true;
165171
const hasNoDeliver = opts.deliver === false;
166172
const webhookUrl = normalizeOptionalString(opts.webhook);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => {
4+
return {
5+
callGateway: vi.fn(async () => ({ ok: true })),
6+
};
7+
});
8+
9+
vi.mock("../gateway/call.js", () => ({
10+
callGateway: mocks.callGateway,
11+
}));
12+
13+
vi.mock("./progress.js", () => ({
14+
withProgress: async (_opts: unknown, action: () => Promise<unknown>) => await action(),
15+
}));
16+
17+
const { callGatewayFromCli } = await import("./gateway-rpc.js");
18+
19+
describe("gateway-rpc timeout option", () => {
20+
beforeEach(() => {
21+
mocks.callGateway.mockClear();
22+
});
23+
24+
it("passes a parsed timeoutMs to callGateway", async () => {
25+
await callGatewayFromCli("health", { timeout: "1234", json: true }, {});
26+
27+
expect(mocks.callGateway).toHaveBeenCalledWith(
28+
expect.objectContaining({
29+
timeoutMs: 1234,
30+
}),
31+
);
32+
});
33+
34+
it("defaults to 30s when timeout is missing", async () => {
35+
await callGatewayFromCli("health", { json: true }, {});
36+
37+
expect(mocks.callGateway).toHaveBeenCalledWith(
38+
expect.objectContaining({
39+
timeoutMs: 30_000,
40+
}),
41+
);
42+
});
43+
44+
it("rejects invalid timeout values instead of silently falling back", async () => {
45+
for (const timeout of ["", " ", "nope", "10ms", "1000abc", "1e3", "1.5"]) {
46+
mocks.callGateway.mockClear();
47+
await expect(callGatewayFromCli("health", { timeout, json: true }, {})).rejects.toThrow(
48+
"--timeout must be a positive integer",
49+
);
50+
expect(mocks.callGateway).not.toHaveBeenCalled();
51+
}
52+
});
53+
});

src/cli/gateway-rpc.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { OperatorScope } from "../gateway/operator-scopes.js";
77
import type { DeviceIdentity } from "../infra/device-identity.js";
88
import { createLazyImportLoader } from "../shared/lazy-promise.js";
99
import type { GatewayRpcOpts } from "./gateway-rpc.types.js";
10+
import { parsePositiveIntOrUndefined } from "./program/helpers.js";
1011
export type { GatewayRpcOpts } from "./gateway-rpc.types.js";
1112

1213
type GatewayRpcRuntimeModule = typeof import("./gateway-rpc.runtime.js");
@@ -27,6 +28,22 @@ export function addGatewayClientOptions(cmd: Command) {
2728
.option("--expect-final", "Wait for final response (agent)", false);
2829
}
2930

31+
const DEFAULT_GATEWAY_RPC_TIMEOUT_MS = 30_000;
32+
33+
function resolveGatewayRpcTimeoutMs(timeout: unknown): number {
34+
if (timeout === undefined || timeout === null) {
35+
return DEFAULT_GATEWAY_RPC_TIMEOUT_MS;
36+
}
37+
if (typeof timeout === "string" && timeout.trim() === "") {
38+
throw new Error("--timeout must be a positive integer (milliseconds)");
39+
}
40+
const parsed = parsePositiveIntOrUndefined(timeout);
41+
if (parsed === undefined) {
42+
throw new Error("--timeout must be a positive integer (milliseconds)");
43+
}
44+
return parsed;
45+
}
46+
3047
export async function callGatewayFromCli(
3148
method: string,
3249
opts: GatewayRpcOpts,
@@ -40,6 +57,8 @@ export async function callGatewayFromCli(
4057
scopes?: OperatorScope[];
4158
},
4259
) {
60+
const timeoutMs = resolveGatewayRpcTimeoutMs(opts.timeout);
61+
opts.timeout = String(timeoutMs);
4362
const runtime = await loadGatewayRpcRuntime();
4463
return await runtime.callGatewayFromCliRuntime(method, opts, params, extra);
4564
}

0 commit comments

Comments
 (0)