From fadf4e35f04dd03a30120227a5debc7e0bf047ad Mon Sep 17 00:00:00 2001 From: xy200303 <3483421977@qq.com> Date: Wed, 10 Jun 2026 22:38:10 +0800 Subject: [PATCH 1/4] fix: reject malformed numeric CLI options --- .prettierrc.json | 3 +++ src/commands/artifacts-command.ts | 9 +-------- src/commands/option-parsers.ts | 29 ++++++++++++++++++++++++----- 3 files changed, 28 insertions(+), 13 deletions(-) create mode 100644 .prettierrc.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..168d9d2a --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "endOfLine": "auto" +} diff --git a/src/commands/artifacts-command.ts b/src/commands/artifacts-command.ts index dfbd24af..d60c29cd 100644 --- a/src/commands/artifacts-command.ts +++ b/src/commands/artifacts-command.ts @@ -30,6 +30,7 @@ import { parseCommanderProgram, } from "./commander-utils.js"; import { setStderrDevLogStorageRootDirectory } from "../runtime/stderr-dev-log.js"; +import { parseNonNegativeInt } from "./option-parsers.js"; interface WriteTarget { write(chunk: string): unknown; @@ -391,14 +392,6 @@ function parseHarnessKindOption(value: string): AgentHarnessKind { ); } -function parseNonNegativeInt(value: string): number { - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - throw new InvalidArgumentError("value must be a non-negative integer"); - } - return parsed; -} - function writeJson(stdout: WriteTarget, value: unknown): void { stdout.write(`${JSON.stringify(value, null, 2)}\n`); } diff --git a/src/commands/option-parsers.ts b/src/commands/option-parsers.ts index 120f78eb..041a97be 100644 --- a/src/commands/option-parsers.ts +++ b/src/commands/option-parsers.ts @@ -14,9 +14,28 @@ import { MIN_ANTHROPIC_THINKING_BUDGET_TOKENS } from "../bootstrap/config/defaul export class InvalidArgumentError extends Error {} +const DECIMAL_INTEGER_PATTERN = /^[+-]?\d+$/u; +const DECIMAL_NUMBER_PATTERN = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu; + +function parseDecimalInteger(value: string): number { + const normalized = value.trim(); + if (!DECIMAL_INTEGER_PATTERN.test(normalized)) { + return Number.NaN; + } + return Number(normalized); +} + +function parseFiniteNumber(value: string): number { + const normalized = value.trim(); + if (!DECIMAL_NUMBER_PATTERN.test(normalized)) { + return Number.NaN; + } + return Number(normalized); +} + export function parsePositiveInt(value: string): number { - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed <= 0) { + const parsed = parseDecimalInteger(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { throw new Error(`Expected positive integer, got: ${value}`); } return parsed; @@ -32,8 +51,8 @@ export function parseOperatingMode(value: string): AgentOperatingMode { } export function parseNonNegativeInt(value: string): number { - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed < 0) { + const parsed = parseDecimalInteger(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) { throw new Error(`Expected non-negative integer, got: ${value}`); } return parsed; @@ -107,7 +126,7 @@ export function parseToolSearchIndexProfile( } export function parseNumber(value: string): number { - const parsed = Number.parseFloat(value); + const parsed = parseFiniteNumber(value); if (!Number.isFinite(parsed)) { throw new Error(`Expected number, got: ${value}`); } From 5c1f00429f3e4de957b6968144f69f05c68fe9da Mon Sep 17 00:00:00 2001 From: xy200303 <3483421977@qq.com> Date: Wed, 10 Jun 2026 22:38:10 +0800 Subject: [PATCH 2/4] fix: reject malformed numeric CLI options --- .prettierrc.json | 3 +++ src/commands/artifacts-command.ts | 9 +-------- src/commands/option-parsers.ts | 29 ++++++++++++++++++++++++----- 3 files changed, 28 insertions(+), 13 deletions(-) create mode 100644 .prettierrc.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..168d9d2a --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "endOfLine": "auto" +} diff --git a/src/commands/artifacts-command.ts b/src/commands/artifacts-command.ts index dfbd24af..d60c29cd 100644 --- a/src/commands/artifacts-command.ts +++ b/src/commands/artifacts-command.ts @@ -30,6 +30,7 @@ import { parseCommanderProgram, } from "./commander-utils.js"; import { setStderrDevLogStorageRootDirectory } from "../runtime/stderr-dev-log.js"; +import { parseNonNegativeInt } from "./option-parsers.js"; interface WriteTarget { write(chunk: string): unknown; @@ -391,14 +392,6 @@ function parseHarnessKindOption(value: string): AgentHarnessKind { ); } -function parseNonNegativeInt(value: string): number { - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - throw new InvalidArgumentError("value must be a non-negative integer"); - } - return parsed; -} - function writeJson(stdout: WriteTarget, value: unknown): void { stdout.write(`${JSON.stringify(value, null, 2)}\n`); } diff --git a/src/commands/option-parsers.ts b/src/commands/option-parsers.ts index 120f78eb..041a97be 100644 --- a/src/commands/option-parsers.ts +++ b/src/commands/option-parsers.ts @@ -14,9 +14,28 @@ import { MIN_ANTHROPIC_THINKING_BUDGET_TOKENS } from "../bootstrap/config/defaul export class InvalidArgumentError extends Error {} +const DECIMAL_INTEGER_PATTERN = /^[+-]?\d+$/u; +const DECIMAL_NUMBER_PATTERN = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu; + +function parseDecimalInteger(value: string): number { + const normalized = value.trim(); + if (!DECIMAL_INTEGER_PATTERN.test(normalized)) { + return Number.NaN; + } + return Number(normalized); +} + +function parseFiniteNumber(value: string): number { + const normalized = value.trim(); + if (!DECIMAL_NUMBER_PATTERN.test(normalized)) { + return Number.NaN; + } + return Number(normalized); +} + export function parsePositiveInt(value: string): number { - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed <= 0) { + const parsed = parseDecimalInteger(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { throw new Error(`Expected positive integer, got: ${value}`); } return parsed; @@ -32,8 +51,8 @@ export function parseOperatingMode(value: string): AgentOperatingMode { } export function parseNonNegativeInt(value: string): number { - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed < 0) { + const parsed = parseDecimalInteger(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) { throw new Error(`Expected non-negative integer, got: ${value}`); } return parsed; @@ -107,7 +126,7 @@ export function parseToolSearchIndexProfile( } export function parseNumber(value: string): number { - const parsed = Number.parseFloat(value); + const parsed = parseFiniteNumber(value); if (!Number.isFinite(parsed)) { throw new Error(`Expected number, got: ${value}`); } From 93a22247443e37b4e088372c7a4e98284267f8ab Mon Sep 17 00:00:00 2001 From: xy200303 <3483421977@qq.com> Date: Wed, 10 Jun 2026 23:10:25 +0800 Subject: [PATCH 3/4] chore: clear existing lint warnings --- extensions/realtime-aec/src/browser-audio-driver.ts | 13 ++++++------- packages/realtime/src/backend/stepfun-stateless.ts | 2 +- packages/realtime/src/vad/cli-handlers.ts | 5 ++--- packages/realtime/src/vad/resolver.ts | 5 ++--- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/extensions/realtime-aec/src/browser-audio-driver.ts b/extensions/realtime-aec/src/browser-audio-driver.ts index f41eaea4..fce21c78 100644 --- a/extensions/realtime-aec/src/browser-audio-driver.ts +++ b/extensions/realtime-aec/src/browser-audio-driver.ts @@ -150,23 +150,22 @@ export class BrowserAudioDriver implements AudioDriver { void this.ensureStarted().catch((err) => log.error({ err: String(err) }, "ensureStarted (capture) failed"), ); - const self = this; const stream: AsyncIterable = { - [Symbol.asyncIterator]() { + [Symbol.asyncIterator]: () => { return { - next(): Promise> { - if (self.captureStopped) { + next: (): Promise> => { + if (this.captureStopped) { return Promise.resolve({ value: undefined, done: true }); } - const queued = self.captureBuf.shift(); + const queued = this.captureBuf.shift(); if (queued) { return Promise.resolve({ value: queued, done: false }); } return new Promise>((resolve) => { - self.capturePending.push({ resolve }); + this.capturePending.push({ resolve }); }); }, - return(): Promise> { + return: (): Promise> => { return Promise.resolve({ value: undefined, done: true }); }, }; diff --git a/packages/realtime/src/backend/stepfun-stateless.ts b/packages/realtime/src/backend/stepfun-stateless.ts index b652b84b..e67fc06f 100644 --- a/packages/realtime/src/backend/stepfun-stateless.ts +++ b/packages/realtime/src/backend/stepfun-stateless.ts @@ -457,7 +457,7 @@ export class StepfunStatelessAdapter implements BackendAdapter { let msg: any; try { msg = JSON.parse(raw); - } catch (e) { + } catch { this.log.warn({ raw: raw.slice(0, 200) }, "non-json message"); return; } diff --git a/packages/realtime/src/vad/cli-handlers.ts b/packages/realtime/src/vad/cli-handlers.ts index 2a83b5b4..c73071eb 100644 --- a/packages/realtime/src/vad/cli-handlers.ts +++ b/packages/realtime/src/vad/cli-handlers.ts @@ -113,9 +113,8 @@ function editDistance(a: string, b: string): number { bl = b.length; if (al === 0) return bl; if (bl === 0) return al; - let prev = new Array(bl + 1), - curr = new Array(bl + 1); - for (let j = 0; j <= bl; j++) prev[j] = j; + let prev = Array.from({ length: bl + 1 }, (_, index) => index), + curr = Array.from({ length: bl + 1 }, () => 0); for (let i = 1; i <= al; i++) { curr[0] = i; for (let j = 1; j <= bl; j++) { diff --git a/packages/realtime/src/vad/resolver.ts b/packages/realtime/src/vad/resolver.ts index 9f1cae5e..a8412155 100644 --- a/packages/realtime/src/vad/resolver.ts +++ b/packages/realtime/src/vad/resolver.ts @@ -249,9 +249,8 @@ function editDistance(a: string, b: string): number { if (al === 0) return bl; if (bl === 0) return al; - let prev = new Array(bl + 1); - let curr = new Array(bl + 1); - for (let j = 0; j <= bl; j++) prev[j] = j; + let prev = Array.from({ length: bl + 1 }, (_, index) => index); + let curr = Array.from({ length: bl + 1 }, () => 0); for (let i = 1; i <= al; i++) { curr[0] = i; From 9ef8010a1119260110ba5214c2fd002ac0e292f2 Mon Sep 17 00:00:00 2001 From: xy200303 <3483421977@qq.com> Date: Thu, 18 Jun 2026 20:57:20 +0800 Subject: [PATCH 4/4] Fix Windows test compatibility after main merge --- packages/utils/src/path.test.ts | 236 +++++++++++++++++-------------- packages/utils/src/shell.test.ts | 9 +- 2 files changed, 135 insertions(+), 110 deletions(-) diff --git a/packages/utils/src/path.test.ts b/packages/utils/src/path.test.ts index e1206b95..27487b9b 100644 --- a/packages/utils/src/path.test.ts +++ b/packages/utils/src/path.test.ts @@ -220,6 +220,10 @@ async function makeWorkspace(): Promise { return fs.realpath(dir); } +// Windows symlink creation requires Developer Mode or elevated privileges. +// Linux/macOS CI still exercises these resolver paths. +const symlinkIt = process.platform === "win32" ? it.skip : it; + describe("resolveExistingPathInWorkspace", () => { it("returns the real path of an existing file inside the workspace", async () => { const root = await makeWorkspace(); @@ -232,23 +236,26 @@ describe("resolveExistingPathInWorkspace", () => { } }); - it("resolves a symlink target that lives inside the workspace", async () => { - const root = await makeWorkspace(); - try { - await fs.writeFile(path.join(root, "real.txt"), "data"); - await fs.symlink( - path.join(root, "real.txt"), - path.join(root, "link.txt"), - ); - const result = await resolveExistingPathInWorkspace(root, "link.txt"); - // Returns the resolved real target. - expect(result).toBe(path.join(root, "real.txt")); - } finally { - await fs.rm(root, { recursive: true }); - } - }); - - it("throws when a symlink points outside the workspace", async () => { + symlinkIt( + "resolves a symlink target that lives inside the workspace", + async () => { + const root = await makeWorkspace(); + try { + await fs.writeFile(path.join(root, "real.txt"), "data"); + await fs.symlink( + path.join(root, "real.txt"), + path.join(root, "link.txt"), + ); + const result = await resolveExistingPathInWorkspace(root, "link.txt"); + // Returns the resolved real target. + expect(result).toBe(path.join(root, "real.txt")); + } finally { + await fs.rm(root, { recursive: true }); + } + }, + ); + + symlinkIt("throws when a symlink points outside the workspace", async () => { const root = await makeWorkspace(); const outside = await makeWorkspace(); try { @@ -266,28 +273,31 @@ describe("resolveExistingPathInWorkspace", () => { } }); - it("throws when the symlink's parent dir escapes the workspace", async () => { - const root = await makeWorkspace(); - const outside = await makeWorkspace(); - try { - // 'sub' inside root is a symlink to an outside dir; addressing - // sub/inner.txt makes the symlink parent check fail. - await fs.mkdir(path.join(outside, "real-dir")); - await fs.writeFile(path.join(outside, "real-dir", "inner.txt"), "y"); - await fs.symlink( - path.join(outside, "real-dir"), - path.join(root, "sub"), - "dir", - ); - // 'sub' itself is a symlink whose realpath is outside. - await expect(resolveExistingPathInWorkspace(root, "sub")).rejects.toThrow( - "Path escapes workspace root", - ); - } finally { - await fs.rm(root, { recursive: true }); - await fs.rm(outside, { recursive: true }); - } - }); + symlinkIt( + "throws when the symlink's parent dir escapes the workspace", + async () => { + const root = await makeWorkspace(); + const outside = await makeWorkspace(); + try { + // 'sub' inside root is a symlink to an outside dir; addressing + // sub/inner.txt makes the symlink parent check fail. + await fs.mkdir(path.join(outside, "real-dir")); + await fs.writeFile(path.join(outside, "real-dir", "inner.txt"), "y"); + await fs.symlink( + path.join(outside, "real-dir"), + path.join(root, "sub"), + "dir", + ); + // 'sub' itself is a symlink whose realpath is outside. + await expect( + resolveExistingPathInWorkspace(root, "sub"), + ).rejects.toThrow("Path escapes workspace root"); + } finally { + await fs.rm(root, { recursive: true }); + await fs.rm(outside, { recursive: true }); + } + }, + ); it("rejects for a non-existent path (ENOENT from realpath)", async () => { const root = await makeWorkspace(); @@ -302,7 +312,7 @@ describe("resolveExistingPathInWorkspace", () => { }); describe("resolveAddressedExistingPathInWorkspace", () => { - it("returns the addressed (non-real) resolved path", async () => { + symlinkIt("returns the addressed (non-real) resolved path", async () => { const root = await makeWorkspace(); try { await fs.writeFile(path.join(root, "real.txt"), "data"); @@ -321,7 +331,7 @@ describe("resolveAddressedExistingPathInWorkspace", () => { } }); - it("throws when symlink escapes workspace", async () => { + symlinkIt("throws when symlink escapes workspace", async () => { const root = await makeWorkspace(); const outside = await makeWorkspace(); try { @@ -341,7 +351,7 @@ describe("resolveAddressedExistingPathInWorkspace", () => { }); describe("resolveAddressedPathEntryInWorkspace", () => { - it("returns the resolved path early for a symlink entry", async () => { + symlinkIt("returns the resolved path early for a symlink entry", async () => { const root = await makeWorkspace(); try { await fs.writeFile(path.join(root, "real.txt"), "data"); @@ -373,27 +383,30 @@ describe("resolveAddressedPathEntryInWorkspace", () => { } }); - it("returns resolved symlink even if it escapes (parent check passes)", async () => { - const root = await makeWorkspace(); - const outside = await makeWorkspace(); - try { - await fs.writeFile(path.join(outside, "secret.txt"), "x"); - // Symlink directly in root; its parent (root) is in the workspace, so the - // parent check passes and the symlink branch returns early. - await fs.symlink( - path.join(outside, "secret.txt"), - path.join(root, "link.txt"), - ); - const result = await resolveAddressedPathEntryInWorkspace( - root, - "link.txt", - ); - expect(result).toBe(path.join(root, "link.txt")); - } finally { - await fs.rm(root, { recursive: true }); - await fs.rm(outside, { recursive: true }); - } - }); + symlinkIt( + "returns resolved symlink even if it escapes (parent check passes)", + async () => { + const root = await makeWorkspace(); + const outside = await makeWorkspace(); + try { + await fs.writeFile(path.join(outside, "secret.txt"), "x"); + // Symlink directly in root; its parent (root) is in the workspace, so the + // parent check passes and the symlink branch returns early. + await fs.symlink( + path.join(outside, "secret.txt"), + path.join(root, "link.txt"), + ); + const result = await resolveAddressedPathEntryInWorkspace( + root, + "link.txt", + ); + expect(result).toBe(path.join(root, "link.txt")); + } finally { + await fs.rm(root, { recursive: true }); + await fs.rm(outside, { recursive: true }); + } + }, + ); }); describe("resolveWritablePathInWorkspace", () => { @@ -421,50 +434,59 @@ describe("resolveWritablePathInWorkspace", () => { } }); - it("follows a symlinked parent dir to an existing location inside workspace", async () => { - const root = await makeWorkspace(); - try { - await fs.mkdir(path.join(root, "actual")); - await fs.symlink(path.join(root, "actual"), path.join(root, "linkdir")); - const result = await resolveWritablePathInWorkspace( - root, - "linkdir/file.txt", - ); - // Nearest existing path is the symlink's real target, then file.txt. - expect(result).toBe(path.join(root, "actual", "file.txt")); - } finally { - await fs.rm(root, { recursive: true }); - } - }); - - it("throws when the nearest existing ancestor escapes the workspace", async () => { - const root = await makeWorkspace(); - const outside = await makeWorkspace(); - try { - await fs.symlink(outside, path.join(root, "escape"), "dir"); - await expect( - resolveWritablePathInWorkspace(root, "escape/child.txt"), - ).rejects.toThrow("Path escapes workspace root"); - } finally { - await fs.rm(root, { recursive: true }); - await fs.rm(outside, { recursive: true }); - } - }); - - it("rethrows non-ENOENT errors from realpath (ELOOP cycle)", async () => { - const root = await makeWorkspace(); - try { - // a -> b, b -> a: realpath fails with ELOOP, which is not ENOENT, so - // findNearestExistingPath rethrows it (covers the non-ENOENT branch). - await fs.symlink(path.join(root, "b"), path.join(root, "a")); - await fs.symlink(path.join(root, "a"), path.join(root, "b")); - await expect( - resolveWritablePathInWorkspace(root, "a/child.txt"), - ).rejects.toThrow(/ELOOP|Symlink cycle|escapes workspace/); - } finally { - await fs.rm(root, { recursive: true }); - } - }); + symlinkIt( + "follows a symlinked parent dir to an existing location inside workspace", + async () => { + const root = await makeWorkspace(); + try { + await fs.mkdir(path.join(root, "actual")); + await fs.symlink(path.join(root, "actual"), path.join(root, "linkdir")); + const result = await resolveWritablePathInWorkspace( + root, + "linkdir/file.txt", + ); + // Nearest existing path is the symlink's real target, then file.txt. + expect(result).toBe(path.join(root, "actual", "file.txt")); + } finally { + await fs.rm(root, { recursive: true }); + } + }, + ); + + symlinkIt( + "throws when the nearest existing ancestor escapes the workspace", + async () => { + const root = await makeWorkspace(); + const outside = await makeWorkspace(); + try { + await fs.symlink(outside, path.join(root, "escape"), "dir"); + await expect( + resolveWritablePathInWorkspace(root, "escape/child.txt"), + ).rejects.toThrow("Path escapes workspace root"); + } finally { + await fs.rm(root, { recursive: true }); + await fs.rm(outside, { recursive: true }); + } + }, + ); + + symlinkIt( + "rethrows non-ENOENT errors from realpath (ELOOP cycle)", + async () => { + const root = await makeWorkspace(); + try { + // a -> b, b -> a: realpath fails with ELOOP, which is not ENOENT, so + // findNearestExistingPath rethrows it (covers the non-ENOENT branch). + await fs.symlink(path.join(root, "b"), path.join(root, "a")); + await fs.symlink(path.join(root, "a"), path.join(root, "b")); + await expect( + resolveWritablePathInWorkspace(root, "a/child.txt"), + ).rejects.toThrow(/ELOOP|Symlink cycle|escapes workspace/); + } finally { + await fs.rm(root, { recursive: true }); + } + }, + ); it("resolves a path whose ancestor chain walks up to the workspace root", async () => { const root = await makeWorkspace(); diff --git a/packages/utils/src/shell.test.ts b/packages/utils/src/shell.test.ts index a4a9c0f8..470f3842 100644 --- a/packages/utils/src/shell.test.ts +++ b/packages/utils/src/shell.test.ts @@ -3,6 +3,9 @@ import os from "node:os"; import { enforceOutputLimit, runShell } from "./shell.js"; const BIG_LIMIT = 1_000_000; +const longRunningCommand = `${JSON.stringify( + process.execPath, +)} -e "setTimeout(() => {}, 5000)"`; // Some runShell cases assert POSIX shell semantics (`;` chaining, `exit N`, // `for`/`seq`) that cmd.exe does not interpret. Skip those on Windows; the @@ -151,7 +154,7 @@ describe("runShell", () => { }); it("times out a long-running command and kills it", async () => { - const result = await runShell("sleep 5", { + const result = await runShell(longRunningCommand, { cwd, timeoutMs: 100, outputLimit: BIG_LIMIT, @@ -164,7 +167,7 @@ describe("runShell", () => { it("interrupts a running command when the signal aborts mid-flight", async () => { const controller = new AbortController(); - const promise = runShell("sleep 5", { + const promise = runShell(longRunningCommand, { cwd, timeoutMs: 10_000, outputLimit: BIG_LIMIT, @@ -190,7 +193,7 @@ describe("runShell", () => { }); it("returns exitCode -1 when the close code is null (killed by timeout)", async () => { - const result = await runShell("sleep 5", { + const result = await runShell(longRunningCommand, { cwd, timeoutMs: 50, outputLimit: BIG_LIMIT,