From eb7fb1c35f04563f7b6d219f6b0f9de7e69bf837 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Mon, 19 Oct 2026 14:14:35 +0300 Subject: [PATCH 1/4] fix(agent-runtime): bound read: refuse non-regular files, name binaries, stream text (VC-801) read opened every file whole in Electron main before its 2,000-line / 50 KB cut. It now opens a FileSystem.openFileReader (non-blocking open judged by fstat, so a FIFO or device behind a symlink is refused at once), sniffs the first 8 KiB for an image signature (unchanged image path) or a NUL byte (binary notice), and otherwise streams the file in 256 KiB reads into one reused buffer, decoding only the prefix the cut can show. --- packages/agent-runtime/UPSTREAM.md | 5 +- .../agent-runtime/src/pi/credential-env.ts | 8 +- .../src/pi/pi-vendor-tools-coverage.test.ts | 3 +- .../agent-runtime/src/pi/read-bounded.test.ts | 485 ++++++++++++++++++ .../src/pi/scoped-execution-env.ts | 6 + .../src/pi/vendor/pi-harness/README.md | 14 + .../src/pi/vendor/pi-harness/env/nodejs.ts | 101 ++++ .../src/pi/vendor/pi-harness/tools/read.ts | 287 ++++++++--- .../src/pi/vendor/pi-harness/types.ts | 30 ++ 9 files changed, 859 insertions(+), 80 deletions(-) create mode 100644 packages/agent-runtime/src/pi/read-bounded.test.ts diff --git a/packages/agent-runtime/UPSTREAM.md b/packages/agent-runtime/UPSTREAM.md index 2d06f0466..9881bb7a3 100644 --- a/packages/agent-runtime/UPSTREAM.md +++ b/packages/agent-runtime/UPSTREAM.md @@ -181,7 +181,10 @@ request: those endpoints carry prompts in their own `instructions`/`system` and JSON-only (`JsonValue` arrays readonly). Runtime tools already produced JSON, so only test fixtures needed retyping. `ExecutionEnv` gained `openTextLineReader`; `ScopedExecutionEnv` returns `not_supported`, as it does -for `readTextLines`. +for `readTextLines`. (VC-801 later added a Volli-owned `openFileReader`, which +`read` uses; `ScopedExecutionEnv` guards and delegates it like +`readBinaryFile`, and `refusingCredentialReads` refuses it for credential +files.) The optional `Model.inputLimits` and `Model.promptCache` fields (0.87) are used only by Pi's coding-agent for image resizing and cache warming. diff --git a/packages/agent-runtime/src/pi/credential-env.ts b/packages/agent-runtime/src/pi/credential-env.ts index 7e8a32a6f..0e026c72a 100644 --- a/packages/agent-runtime/src/pi/credential-env.ts +++ b/packages/agent-runtime/src/pi/credential-env.ts @@ -39,7 +39,13 @@ export function credentialPath(path: string): boolean { } export function refusingCredentialReads(env: ExecutionEnv, workspacePath: string): ExecutionEnv { - const reads = new Set(["openTextLineReader", "readTextFile", "readTextLines", "readBinaryFile"]); + const reads = new Set([ + "openFileReader", + "openTextLineReader", + "readTextFile", + "readTextLines", + "readBinaryFile", + ]); return new Proxy(env, { get(target, property) { const member: unknown = Reflect.get(target, property); diff --git a/packages/agent-runtime/src/pi/pi-vendor-tools-coverage.test.ts b/packages/agent-runtime/src/pi/pi-vendor-tools-coverage.test.ts index 8769555af..b2ab04e38 100644 --- a/packages/agent-runtime/src/pi/pi-vendor-tools-coverage.test.ts +++ b/packages/agent-runtime/src/pi/pi-vendor-tools-coverage.test.ts @@ -283,7 +283,8 @@ describe("vendored read contracts", () => { read.execute("read", { path: null as unknown as string }, () => {}, { env }, ...inert), ).rejects.toThrow(TypeError); const failure = new FileError("permission_denied", "binary denied"); - const binary = vi.spyOn(env, "readBinaryFile").mockResolvedValue(err(failure)); + // VC-801: `read` opens a bounded reader rather than reading the file whole. + const binary = vi.spyOn(env, "openFileReader").mockResolvedValue(err(failure)); await expect( read.execute("read", { path: "note.txt" }, () => {}, { env }, ...inert), ).rejects.toBe(failure); diff --git a/packages/agent-runtime/src/pi/read-bounded.test.ts b/packages/agent-runtime/src/pi/read-bounded.test.ts new file mode 100644 index 000000000..233caf21a --- /dev/null +++ b/packages/agent-runtime/src/pi/read-bounded.test.ts @@ -0,0 +1,485 @@ +import { execFileSync } from "node:child_process"; +import { constants } from "node:fs"; +import * as fs from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { refusingCredentialReads } from "./credential-env"; +import { + BACKGROUND_CONTEXT, + createReadTool, + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + NodeExecutionEnv, + withAbortSignal, +} from "./harness-env"; +import { ScopedExecutionEnv } from "./scoped-execution-env"; +import type { ExecutionEnv, FileError, Result } from "./vendor/pi-harness/types"; +import { formatSize, truncateHead } from "./vendor/pi-harness/utils/truncate"; + +/** + * VC-801: `read` runs in the host process, so it must never read a file whole + * before its 2,000-line / 50 KB cut, never wait on a FIFO or read a device to + * an end that never comes, and never hand the model a binary file as text. + */ + +const context = BACKGROUND_CONTEXT; +const inert = [undefined as never, context] as const; +const scratchRoot = resolve(import.meta.dirname, "../../../../.bench-tmp/vc-801-read"); +let scratch: string; + +beforeEach(async () => { + await fs.mkdir(scratchRoot, { recursive: true }); + scratch = await fs.mkdtemp(join(scratchRoot, "case-")); +}); +afterEach(async () => { + vi.restoreAllMocks(); + vi.doUnmock("node:fs/promises"); + vi.resetModules(); + await fs.rm(scratch, { recursive: true, force: true }); +}); + +function read(env: ExecutionEnv, path: string, offset?: number, limit?: number) { + return createReadTool().execute("read", { path, offset, limit }, () => {}, { env }, ...inert); +} + +function text(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content.map((part) => part.text ?? "").join(""); +} + +/** + * The read tool's text path before VC-801, verbatim but for `bytes` being + * handed in: decode it all, split it all, slice, then truncate. The streamed + * path must answer exactly this for every input it can see whole. + */ +function wholeFileRead(bytes: Uint8Array, path: string, offset?: number, limit?: number) { + const textContent = new TextDecoder().decode(bytes); + const allLines = textContent.split("\n"); + const totalFileLines = allLines.length; + const startLine = offset ? Math.max(0, offset - 1) : 0; + const startLineDisplay = startLine + 1; + if (startLine >= allLines.length) { + throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`); + } + let selectedContent: string; + let userLimitedLines: number | undefined; + if (limit !== undefined) { + const endLine = Math.min(startLine + limit, allLines.length); + selectedContent = allLines.slice(startLine, endLine).join("\n"); + userLimitedLines = endLine - startLine; + } else { + selectedContent = allLines.slice(startLine).join("\n"); + } + const truncation = truncateHead(selectedContent); + let outputText: string; + let details: { truncation: typeof truncation } | undefined; + if (truncation.firstLineExceedsLimit) { + const firstLineSize = formatSize(new TextEncoder().encode(allLines[startLine]).byteLength); + outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`; + details = { truncation }; + } else if (truncation.truncated) { + const endLineDisplay = startLineDisplay + truncation.outputLines - 1; + const nextOffset = endLineDisplay + 1; + outputText = truncation.content; + if (truncation.truncatedBy === "lines") { + outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; + } else { + outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`; + } + details = { truncation }; + } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) { + const remaining = allLines.length - (startLine + userLimitedLines); + const nextOffset = startLine + userLimitedLines + 1; + outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; + } else { + outputText = truncation.content; + } + return { content: [{ type: "text", text: outputText }], details }; +} + +/** One PNG chunk: length, type and zeroed data and CRC. */ +function pngChunk(type: string, length: number): Buffer { + const data = Buffer.alloc(12 + length); + data.writeUInt32BE(length); + data.write(type, 4, "ascii"); + return data; +} + +function failure(result: Result, code: string) { + expect(result).toMatchObject({ ok: false, error: { name: "FileError", code } }); +} + +/** Byte index where 1-indexed `line` starts. */ +function lineStart(bytes: Uint8Array, line: number): number { + let index = 0; + for (let seen = 1; seen < line; seen++) index = bytes.indexOf(0x0a, index) + 1; + return index; +} + +/** Run `work`, sampling RSS while it yields; resolves with its result and the peak growth. */ +async function peakRssGrowth(work: () => Promise): Promise<{ value: T; growth: number }> { + const baseline = process.memoryUsage.rss(); + let peak = baseline; + const sample = setInterval(() => { + peak = Math.max(peak, process.memoryUsage.rss()); + }, 1); + try { + const value = await work(); + peak = Math.max(peak, process.memoryUsage.rss()); + return { value, growth: peak - baseline }; + } finally { + clearInterval(sample); + } +} + +/** Write `total` bytes of `block` repeated, without holding them. */ +async function writeRepeated(path: string, block: string, total: number): Promise { + const bytes = Buffer.from(block); + const file = await fs.open(path, "w"); + try { + for (let written = 0; written < total; written += bytes.length) await file.write(bytes); + } finally { + await file.close(); + } +} + +describe("read streams text instead of loading the file (VC-801)", () => { + it("returns the first 2,000 lines of a large log with its continuation hint and bounded memory", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + const line = "log entry 123456789\n"; // 20 bytes: 2,000 lines fit under 50 KB + const block = line.repeat(50_000); // 1,000,000 bytes + const total = 128 * block.length; + await writeRepeated(join(scratch, "big.log"), block, total); + const { value: result, growth } = await peakRssGrowth(() => read(env, "big.log")); + const lines = total / line.length; + expect(text(result)).toBe( + `${line.repeat(DEFAULT_MAX_LINES - 1)}${line.slice(0, -1)}\n\n[Showing lines 1-2000 of ${lines + 1}. Use offset=2001 to continue.]`, + ); + expect(result.details?.truncation).toMatchObject({ + truncated: true, + truncatedBy: "lines", + totalLines: lines, + totalBytes: total, + outputLines: DEFAULT_MAX_LINES, + }); + // The whole-file read held the 128 MB buffer, a 128 M-character string and + // its line array at once. + expect(growth).toBeLessThan(24 * 1024 * 1024); + const middle = await read(env, "big.log", 1_000_001, 2); + expect(text(middle)).toBe( + `${line}${line.slice(0, -1)}\n\n[${lines + 1 - 1_000_002} more lines in file. Use offset=1000003 to continue.]`, + ); + }, 60_000); + + it("measures a single enormous line without holding it", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + const block = "x".repeat(1024 * 1024); + await writeRepeated(join(scratch, "minified.json"), block, 64 * block.length); + const { value: result, growth } = await peakRssGrowth(() => read(env, "minified.json")); + expect(text(result)).toBe( + `[Line 1 is 64.0MB, exceeds 50.0KB limit. Use bash: sed -n '1p' minified.json | head -c ${DEFAULT_MAX_BYTES}]`, + ); + expect(result.details?.truncation).toMatchObject({ + firstLineExceedsLimit: true, + totalLines: 1, + totalBytes: 64 * block.length, + }); + expect(growth).toBeLessThan(24 * 1024 * 1024); + }, 60_000); + + it.each([ + ["empty", ""], + ["one newline", "\n"], + ["blank lines", "\n\n\n"], + ["BOM, CRLF and an unterminated tail", "\ufeffa\r\nb\r\n\r\nlast"], + ["trailing newline", "a\nb\nc\n"], + ["multibyte at every boundary", "é😀ab\n".repeat(30_000)], + ["lines at the byte cut", `${"y".repeat(1023)}\n`.repeat(120)], + ["just over the line cut", "z\n".repeat(DEFAULT_MAX_LINES + 1)], + ["exactly the line cut", "z\n".repeat(DEFAULT_MAX_LINES - 1) + "z"], + ["long line after short ones", `a\nb\n${"q".repeat(200_000)}\nc`], + ["a leading BOM before a long first line", `\ufeff${"w".repeat(60_000)}\nx`], + ["U+FF21, whose first byte is a BOM's", "\uff21 fullwidth\nx"], + ["U+FEC0, whose first two bytes are a BOM's", "\ufec0\nx"], + ["a BOM opening a later line", "a\n\ufeffb\nc"], + ])("answers exactly what the whole-file read answered: %s", async (_name, content) => { + const env = new NodeExecutionEnv({ cwd: scratch }); + await fs.writeFile(join(scratch, "file.txt"), content); + const bytes = new TextEncoder().encode(content); + const lines = content.split("\n").length; + const offsets = [undefined, 0, 1, 2, 3, Math.floor(lines / 2), lines, 2.5]; + const limits = [undefined, 0, 1, 2, 3, DEFAULT_MAX_LINES, 2.5]; + for (const offset of offsets) { + for (const limit of limits) { + let expected: unknown; + try { + expected = wholeFileRead(bytes, "file.txt", offset, limit); + } catch (error) { + await expect(read(env, "file.txt", offset, limit)).rejects.toThrow( + (error as Error).message, + ); + continue; + } + if (offset === 2.5) { + // A fractional offset named `allLines[1.5]` (undefined) in the + // first-line message; everything else is the same. + const actual = await read(env, "file.txt", offset, limit); + if (!actual.details?.truncation?.firstLineExceedsLimit) + expect(actual, `offset=${offset} limit=${limit}`).toEqual(expected); + continue; + } + expect( + await read(env, "file.txt", offset, limit), + `offset=${offset} limit=${limit}`, + ).toEqual(expected); + } + } + }); + + it("answers the whole-file read for invalid UTF-8 split across chunks, counting raw bytes", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + const bytes = new Uint8Array(200_000); + for (let index = 0; index < bytes.length; index++) { + bytes[index] = index % 97 === 0 ? 0x0a : index % 13 === 0 ? 0xc3 : 0x41 + (index % 26); + } + bytes[8191] = 0xf0; // a torn four-byte lead right at the sniff boundary + bytes[65_535 + 8192] = 0xe2; // and one at a chunk boundary + await fs.writeFile(join(scratch, "latin.txt"), bytes); + for (const offset of [undefined, 10, 1500]) { + const actual = await read(env, "latin.txt", offset); + const expected = wholeFileRead(bytes, "latin.txt", offset); + expect(text(actual)).toBe(text(expected)); + // Counted in the file's bytes, not in U+FFFDs: the one figure that differs. + expect(actual.details?.truncation).toEqual({ + ...expected.details?.truncation, + totalBytes: bytes.length - lineStart(bytes, offset ?? 1), + }); + } + }); + + it("selects nothing for a negative limit instead of counting lines from the end", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + await fs.writeFile(join(scratch, "file.txt"), "a\nb\nc"); + expect(text(await read(env, "file.txt", 1, -1))).toBe( + "\n\n[4 more lines in file. Use offset=0 to continue.]", + ); + expect(text(await read(env, "file.txt", 3, -1))).toBe( + "\n\n[2 more lines in file. Use offset=2 to continue.]", + ); + }); +}); + +describe("read refuses what is not a regular file at once (VC-801)", () => { + // A FIFO or device named directly never reached the read: the path + // resolution's `exists` already refused its file type. A symlink to one did, + // and read it to an end that never comes. + it("refuses /dev/zero, named directly or through a symlink, instead of reading forever", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + await expect(read(env, "/dev/zero")).rejects.toMatchObject({ code: "invalid" }); + await fs.symlink("/dev/zero", join(scratch, "zeros")); + await expect(read(env, "zeros")).rejects.toMatchObject({ + code: "invalid", + message: expect.stringContaining("Not a regular file"), + }); + }); + + it.each([ + ["directly", "pipe"], + ["through a symlink", "pipe-link"], + ])("refuses a FIFO named %s without waiting for a writer", async (_how, name) => { + const env = new NodeExecutionEnv({ cwd: scratch }); + const fifo = join(scratch, "pipe"); + execFileSync("mkfifo", [fifo]); + await fs.symlink(fifo, join(scratch, "pipe-link")); + const pending = read(env, name); + try { + const outcome = await Promise.race([ + pending.then( + () => "returned", + (error: FileError) => error.code, + ), + new Promise((settle) => setTimeout(() => settle("still waiting"), 2_000)), + ]); + expect(outcome).toBe("invalid"); + } finally { + // Whatever happened, release a reader still blocked on the open. + await fs + .open(fifo, constants.O_WRONLY | constants.O_NONBLOCK) + .then((writer) => writer.close()) + .catch(() => undefined); + await pending.catch(() => undefined); + } + }); + + it("refuses a directory", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + await fs.mkdir(join(scratch, "dir")); + await expect(read(env, "dir")).rejects.toMatchObject({ + code: "is_directory", + message: "Path is a directory", + }); + }); + + it("still reads through a symlink to a regular file", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + await fs.writeFile(join(scratch, "target.txt"), "through the link"); + await fs.symlink(join(scratch, "target.txt"), join(scratch, "alias")); + expect(text(await read(env, "alias"))).toBe("through the link"); + }); +}); + +describe("read names a binary file instead of decoding it (VC-801)", () => { + it("answers a file with a NUL in its first 8 KB with a binary notice", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + const bytes = Buffer.alloc(20_000, 0x41); + bytes[8191] = 0; + await fs.writeFile(join(scratch, "data.sqlite"), bytes); + expect(await read(env, "data.sqlite")).toEqual({ + content: [ + { + type: "text", + text: `[Binary file (19.5KB), not shown as text. Use bash: file data.sqlite, or xxd data.sqlite | head -c ${DEFAULT_MAX_BYTES}]`, + }, + ], + details: undefined, + }); + }); + + it("still decodes a file whose first NUL is past 8 KB", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + const bytes = Buffer.alloc(9000, 0x41); + bytes[8192] = 0; + await fs.writeFile(join(scratch, "late.txt"), bytes); + expect(await read(env, "late.txt")).toEqual(wholeFileRead(bytes, "late.txt")); + }); + + it("calls an animated PNG binary even when its acTL is past the sniffed head", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const png = Buffer.concat([ + signature, + pngChunk("IHDR", 13), + pngChunk("tEXt", 10_000), + pngChunk("acTL", 8), + pngChunk("IDAT", 4), + ]); + await fs.writeFile(join(scratch, "anim.png"), png); + expect(text(await read(env, "anim.png"))).toBe( + `[Binary file (${formatSize(png.length)}), not shown as text. Use bash: file anim.png, or xxd anim.png | head -c ${DEFAULT_MAX_BYTES}]`, + ); + }); +}); + +describe("openFileReader (VC-801)", () => { + async function mockedEnv(files: Record) { + vi.resetModules(); + vi.doMock("node:fs/promises", () => ({ ...fs, ...files })); + const { NodeExecutionEnv: Env } = await import("./vendor/pi-harness/env/nodejs"); + return new Env({ cwd: scratch }); + } + + it("reads positionally, short only at end of file, and refuses use after close", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + await fs.writeFile(join(scratch, "f"), "0123456789"); + const opened = await env.openFileReader("f", context); + if (!opened.ok) throw opened.error; + const reader = opened.value; + expect(reader.size).toBe(10); + expect(await reader.read(3, 4, context)).toEqual({ ok: true, value: Buffer.from("3456") }); + expect(await reader.read(8, 64, context)).toEqual({ ok: true, value: Buffer.from("89") }); + expect(await reader.read(10, 64, context)).toEqual({ ok: true, value: Buffer.alloc(0) }); + failure(await reader.read(0, -1, context), "unknown"); + await reader.close(context); + await reader.close(context); + failure(await reader.read(0, 1, context), "invalid"); + failure(await env.openFileReader("missing", context), "not_found"); + }); + + it("honours cancellation before opening, after opening and around each read", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + await fs.writeFile(join(scratch, "f"), "data"); + const stopped = withAbortSignal(AbortSignal.abort(), context); + failure(await env.openFileReader("f", stopped), "aborted"); + const opened = await env.openFileReader("f", context); + if (!opened.ok) throw opened.error; + failure(await opened.value.read(0, 4, stopped), "aborted"); + await opened.value.close(context); + + const controller = new AbortController(); + let closed = false; + const aborting = await mockedEnv({ + open: async () => { + controller.abort(); + return { + close: async () => { + closed = true; + throw new Error("already closed"); + }, + }; + }, + }); + failure( + await aborting.openFileReader("f", withAbortSignal(controller.signal, context)), + "aborted", + ); + expect(closed).toBe(true); + }); + + it("returns stat, read and close failures instead of rejecting", async () => { + const io = Object.assign(new Error("fixture EIO"), { code: "EIO" }); + const statFails = await mockedEnv({ + open: async () => ({ + stat: async () => { + throw io; + }, + close: async () => undefined, + }), + }); + failure(await statFails.openFileReader("f", context), "unknown"); + + const controller = new AbortController(); + let onRead: () => { bytesRead: number } = () => { + throw io; + }; + const reads = await mockedEnv({ + open: async () => ({ + stat: async () => ({ isFile: () => true, isDirectory: () => false, size: 4 }), + read: async () => onRead(), + close: async () => { + throw io; + }, + }), + }); + const opened = await reads.openFileReader("f", context); + if (!opened.ok) throw opened.error; + failure(await opened.value.read(0, 4, context), "unknown"); + onRead = () => { + controller.abort(); + return { bytesRead: 1 }; + }; + failure(await opened.value.read(0, 4, withAbortSignal(controller.signal, context)), "aborted"); + await expect(opened.value.close(context)).resolves.toBeUndefined(); + }); + + it("is guarded like readBinaryFile in the scoped environment and refused for credential files", async () => { + await fs.writeFile(join(scratch, "inside.txt"), "inside"); + const scoped = await ScopedExecutionEnv.create(scratch); + const opened = await scoped.openFileReader("inside.txt"); + if (!opened.ok) throw opened.error; + expect(await opened.value.read(0, 64, context)).toEqual({ + ok: true, + value: Buffer.from("inside"), + }); + await opened.value.close(context); + failure( + await scoped.openFileReader(join(scratchRoot, "outside.txt"), context), + "permission_denied", + ); + + await fs.writeFile(join(scratch, ".env"), "SECRET=1"); + const guarded = refusingCredentialReads(new NodeExecutionEnv({ cwd: scratch }), scratch); + await expect(guarded.openFileReader(".env", context)).rejects.toThrow( + "refuses credential-file reads", + ); + await expect(read(guarded, ".env")).rejects.toThrow("refuses credential-file reads"); + }); +}); diff --git a/packages/agent-runtime/src/pi/scoped-execution-env.ts b/packages/agent-runtime/src/pi/scoped-execution-env.ts index 786d808ef..1eb1c91a3 100644 --- a/packages/agent-runtime/src/pi/scoped-execution-env.ts +++ b/packages/agent-runtime/src/pi/scoped-execution-env.ts @@ -595,6 +595,12 @@ export class ScopedExecutionEnv implements ExecutionEnv { return guarded.ok ? this.#delegate.readBinaryFile(guarded.value, context) : guarded; } + /** `read`'s bounded way in (VC-801): the same guard as {@link readBinaryFile}. */ + async openFileReader(path: string, context: Context = BACKGROUND_CONTEXT) { + const guarded = await this.#guard(path, context); + return guarded.ok ? this.#delegate.openFileReader(guarded.value, context) : guarded; + } + async fileInfo(path: string, context: Context = BACKGROUND_CONTEXT) { const guarded = await this.#guard(path, context); return guarded.ok ? this.#delegate.fileInfo(guarded.value, context) : guarded; diff --git a/packages/agent-runtime/src/pi/vendor/pi-harness/README.md b/packages/agent-runtime/src/pi/vendor/pi-harness/README.md index 9cb8fb634..d200cd04f 100644 --- a/packages/agent-runtime/src/pi/vendor/pi-harness/README.md +++ b/packages/agent-runtime/src/pi/vendor/pi-harness/README.md @@ -122,6 +122,20 @@ branch-summary execution. `Agent`/agent loop are **not** vendored. (upstream `applyEditsToNormalizedContent`) takes the original text, and the reported `patch` is built from the original and written text so it applies to the file's bytes. `detectLineEnding` and `restoreLineEndings` are removed. +7. **Bounded read (VC-801):** `read` no longer calls `readBinaryFile`. It opens + the file through a new `FileSystem.openFileReader` (Node: opened + `O_NONBLOCK` and judged by `fstat` on the open descriptor, so a directory, + FIFO, device or socket is refused at once, symlinks followed) and reads the + first 8 KiB. An image signature there still reads the whole file into the + unchanged image path; a NUL byte there answers a binary notice instead of + decoded text; anything else streams in 256 KiB reads into one reused + buffer, finding lines by their LF byte and decoding only the prefix the + 2,000-line / 50 KiB cut can show. Text, continuation offsets and truncation + wording are unchanged; `details.truncation.totalBytes` and an oversized + first line's size are counted in the file's bytes (less a leading BOM), + which differs from decoding everything only for invalid UTF-8. A negative + `limit` now selects no lines where `Array.prototype.slice` counted from the + end. ## Compatibility evidence diff --git a/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts b/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts index fbbaa5cfe..b6b002741 100644 --- a/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts +++ b/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts @@ -24,6 +24,7 @@ import { type ForegroundShellExecutor, ExecutionError, err, + type FileByteReader, FileError, type FileInfo, type FileKind, @@ -464,6 +465,86 @@ class NodeTextLineReader implements TextLineReader { } } +/** + * Positional reads over a regular file opened by {@link NodeExecutionEnv.openFileReader} + * (VC-801). Each read is bounded by its caller; nothing here buffers the file. + */ +class NodeFileByteReader implements FileByteReader { + readonly size: number; + private readonly file: Awaited>; + private readonly path: string; + private closed = false; + + constructor(file: Awaited>, path: string, size: number) { + this.file = file; + this.path = path; + this.size = size; + } + + async read( + position: number, + length: number, + context: Context, + into?: Uint8Array, + ): Promise> { + const aborted = abortResult(context.abortSignal, this.path); + if (aborted) return aborted; + if (this.closed) return err(new FileError("invalid", "File reader is closed", this.path)); + try { + const buffer = into ?? Buffer.allocUnsafe(length); + let filled = 0; + while (filled < length) { + const { bytesRead } = await this.file.read( + buffer, + filled, + length - filled, + position + filled, + ); + const afterReadAbort = abortResult(context.abortSignal, this.path); + if (afterReadAbort) return afterReadAbort; + if (bytesRead === 0) break; + filled += bytesRead; + } + return ok(buffer.subarray(0, filled)); + } catch (error) { + return err(toFileError(error, this.path)); + } + } + + async close(_context: Context): Promise { + if (this.closed) return; + this.closed = true; + try { + await this.file.close(); + } catch { + // Closing is best-effort, including after cancellation or an earlier I/O failure. + } + } +} + +const NOT_A_REGULAR_FILE = + "Not a regular file (a FIFO, device or socket); only regular files can be read"; + +async function regularFileReader( + file: Awaited>, + path: string, + context: Context, +): Promise> { + const aborted = abortResult(context.abortSignal, path); + if (aborted) return aborted; + try { + const stats = await file.stat(); + if (stats.isFile()) return ok(new NodeFileByteReader(file, path, stats.size)); + return err( + stats.isDirectory() + ? new FileError("is_directory", "Path is a directory", path) + : new FileError("invalid", NOT_A_REGULAR_FILE, path), + ); + } catch (error) { + return err(toFileError(error, path)); + } +} + export class NodeExecutionEnv implements ExecutionEnv { cwd: string; private shellPath?: string; @@ -887,6 +968,26 @@ export class NodeExecutionEnv implements ExecutionEnv { } } + /** + * Opened non-blocking and judged on the open descriptor, so a FIFO with no + * writer cannot hold the open and a path swapped after a check cannot slip a + * device past it (VC-801). Non-blocking changes nothing for a regular file. + */ + async openFileReader(path: string, context: Context): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(context.abortSignal, resolved); + if (aborted) return aborted; + let file: Awaited>; + try { + file = await openFile(resolved, constants.O_RDONLY | constants.O_NONBLOCK); + } catch (error) { + return err(toFileError(error, resolved)); + } + const opened = await regularFileReader(file, resolved, context); + if (!opened.ok) await file.close().catch(() => undefined); + return opened; + } + async writeFile( path: string, content: string | Uint8Array, diff --git a/packages/agent-runtime/src/pi/vendor/pi-harness/tools/read.ts b/packages/agent-runtime/src/pi/vendor/pi-harness/tools/read.ts index 98b87e4ab..fae449319 100644 --- a/packages/agent-runtime/src/pi/vendor/pi-harness/tools/read.ts +++ b/packages/agent-runtime/src/pi/vendor/pi-harness/tools/read.ts @@ -1,7 +1,7 @@ import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import { type Static, Type } from "typebox"; import type { Context } from "../context"; -import type { AgentHarnessTool } from "../types"; +import type { AgentHarnessTool, FileByteReader } from "../types"; import { getOrThrow } from "../types"; import { DEFAULT_MAX_BYTES, @@ -9,6 +9,7 @@ import { formatSize, type TruncationResult, truncateHead, + utf8ByteLength, } from "../utils/truncate"; import { detectSupportedImageMimeType, encodeBase64 } from "./image"; import { resolveReadToolPath } from "./path-utils"; @@ -56,98 +57,230 @@ export function createReadTool 0 ? `\n${processed.hints.join("\n")}` : ""; - return { - content: [ - { type: "text", text: `Read image file [${processed.mimeType}]${hints}` }, - { type: "image", data: processed.data, mimeType: processed.mimeType }, - ] satisfies Array, - details: undefined, - }; + const image = await readImage(bytes, options, context); + if (image) return image; } - if (mimeType === "image/bmp") { + if (head.includes(0)) { return { content: [ { type: "text", - text: "Read image file [image/bmp]\n[Image omitted: configure an imageProcessor to convert BMP images.]", + text: `[Binary file (${formatSize(reader.size)}), not shown as text. Use bash: file ${path}, or xxd ${path} | head -c ${DEFAULT_MAX_BYTES}]`, }, ], details: undefined, }; } - return { - content: [ - { type: "text", text: `Read image file [${mimeType}]` }, - { type: "image", data: encodeBase64(bytes), mimeType }, - ] satisfies Array, - details: undefined, - }; + return await readText(reader, head, { path, offset, limit }, context); + } finally { + await reader.close(context); } + }, + }; +} - const textContent = new TextDecoder().decode(bytes); - const allLines = textContent.split("\n"); - const totalFileLines = allLines.length; - const startLine = offset ? Math.max(0, offset - 1) : 0; - const startLineDisplay = startLine + 1; - if (startLine >= allLines.length) { - throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`); - } +/** How much of a file is judged for an image signature and for NUL bytes (VC-801). */ +const SNIFF_BYTES = 8 * 1024; +/** How much of a text file one streamed read takes (VC-801). */ +const CHUNK_BYTES = 256 * 1024; - let selectedContent: string; - let userLimitedLines: number | undefined; - if (limit !== undefined) { - const endLine = Math.min(startLine + limit, allLines.length); - selectedContent = allLines.slice(startLine, endLine).join("\n"); - userLimitedLines = endLine - startLine; - } else { - selectedContent = allLines.slice(startLine).join("\n"); - } +type ReadToolResult = { + content: Array; + details: ReadToolDetails | undefined; +}; - const truncation = truncateHead(selectedContent); - let outputText: string; - let details: ReadToolDetails | undefined; - if (truncation.firstLineExceedsLimit) { - const firstLineSize = formatSize(new TextEncoder().encode(allLines[startLine]).byteLength); - outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`; - details = { truncation }; - } else if (truncation.truncated) { - const endLineDisplay = startLineDisplay + truncation.outputLines - 1; - const nextOffset = endLineDisplay + 1; - outputText = truncation.content; - if (truncation.truncatedBy === "lines") { - outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; - } else { - outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`; - } - details = { truncation }; - } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) { - const remaining = allLines.length - (startLine + userLimitedLines); - const nextOffset = startLine + userLimitedLines + 1; - outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; - } else { - outputText = truncation.content; +/** The image path, unchanged: the whole file, as it always was, once its signature says image. */ +async function readImage( + bytes: Uint8Array, + options: ReadToolOptions | undefined, + context: Context, +): Promise { + const mimeType = detectSupportedImageMimeType(bytes); + if (!mimeType) return undefined; + if (options?.imageProcessor) { + const processed = await options.imageProcessor( + bytes, + mimeType, + { autoResizeImages: options.autoResizeImages ?? true }, + context, + ); + if (!processed.ok) { + return { + content: [{ type: "text", text: `Read image file [${mimeType}]\n${processed.message}` }], + details: undefined, + }; + } + const hints = processed.hints.length > 0 ? `\n${processed.hints.join("\n")}` : ""; + return { + content: [ + { type: "text", text: `Read image file [${processed.mimeType}]${hints}` }, + { type: "image", data: processed.data, mimeType: processed.mimeType }, + ] satisfies Array, + details: undefined, + }; + } + if (mimeType === "image/bmp") { + return { + content: [ + { + type: "text", + text: "Read image file [image/bmp]\n[Image omitted: configure an imageProcessor to convert BMP images.]", + }, + ], + details: undefined, + }; + } + return { + content: [ + { type: "text", text: `Read image file [${mimeType}]` }, + { type: "image", data: encodeBase64(bytes), mimeType }, + ] satisfies Array, + details: undefined, + }; +} + +/** + * A text file streamed from its first byte (VC-801), giving what decoding the + * whole file, splitting it on LF and slicing out the requested lines gave, + * without holding more than the cut and one chunk. + * + * Every byte is still read, because the continuation hint names the file's line + * count, but only bytes: lines are found by their LF byte, which UTF-8 never + * uses inside a character and the decoder never swallows. Only what the cut can + * show is decoded. Keeping stops once the kept prefix of the selection is past + * both limits (more than `DEFAULT_MAX_BYTES + 1` bytes or more than + * `DEFAULT_MAX_LINES` newlines): `truncateHead` judges that prefix exactly as it + * judges the whole selection, and only its totals are replaced. Those totals, + * and the size of an oversized first line, are counted in the file's own bytes, + * less a leading BOM the decoder drops: the same number for valid UTF-8, where + * decoding everything counted each invalid byte as a 3-byte U+FFFD. + */ +async function readText( + reader: FileByteReader, + head: Uint8Array, + { path, offset, limit }: ReadToolInput, + context: Context, +): Promise { + const startLine = offset ? Math.max(0, offset - 1) : 0; + const startLineDisplay = startLine + 1; + // The selection is lines [first, end), as Array.prototype.slice truncated + // them. A negative end selects nothing. + const first = Math.trunc(startLine); + const end = limit === undefined ? Infinity : Math.trunc(startLine + limit); + const bom = + first === 0 && end > 0 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf ? 3 : 0; + + // Only the file's own first BOM is dropped; one opening a later line is text. + const decoder = new TextDecoder("utf-8", { ignoreBOM: first > 0 }); + let lineIndex = 0; + let newlines = 0; + let selectedBytes = -bom; + let selectionEndsWithNewline = false; + let firstLineBytes = -bom; + let firstLineOpen = true; + const kept: string[] = []; + let keeping = true; + let keptBytes = 0; + let keptNewlines = 0; + + const consume = (bytes: Uint8Array): void => { + let selectionStart = lineIndex >= first && lineIndex < end ? 0 : -1; + let selectionEnd = bytes.length; + for ( + let newline = bytes.indexOf(0x0a); + newline !== -1; + newline = bytes.indexOf(0x0a, newline + 1) + ) { + newlines++; + lineIndex++; + if (lineIndex === first && first < end) selectionStart = newline + 1; + else if (lineIndex === end && selectionStart !== -1) selectionEnd = newline; + } + if (selectionStart === -1 || selectionStart >= selectionEnd) return; + const selected = bytes.subarray(selectionStart, selectionEnd); + selectedBytes += selected.length; + selectionEndsWithNewline = selected[selected.length - 1] === 0x0a; + if (firstLineOpen) { + const firstNewline = selected.indexOf(0x0a); + firstLineOpen = firstNewline === -1; + firstLineBytes += firstLineOpen ? selected.length : firstNewline; + } + if (keeping) { + const text = decoder.decode(selected, { stream: true }); + kept.push(text); + keptBytes += utf8ByteLength(text); + for ( + let newline = selected.indexOf(0x0a); + newline !== -1; + newline = selected.indexOf(0x0a, newline + 1) + ) { + keptNewlines++; } + keeping = keptBytes <= DEFAULT_MAX_BYTES + 1 && keptNewlines <= DEFAULT_MAX_LINES; + } + }; - return { content: [{ type: "text", text: outputText }], details }; - }, + // One buffer for every chunk: a fresh one per read would pile up as external + // memory between collections, which is the growth this exists to avoid. + const buffer = new Uint8Array(CHUNK_BYTES); + let chunk = head; + let position = 0; + while (chunk.length > 0) { + consume(chunk); + position += chunk.length; + chunk = getOrThrow(await reader.read(position, CHUNK_BYTES, context, buffer)); + } + // A torn final character decodes as U+FFFD, as it did when the whole file was decoded. + if (keeping) kept.push(decoder.decode()); + + const totalFileLines = newlines + 1; + if (startLine >= totalFileLines) { + throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`); + } + let userLimitedLines: number | undefined; + if (limit !== undefined) { + const endLine = Math.min(startLine + limit, totalFileLines); + userLimitedLines = endLine - startLine; + } + + const selectedLines = Math.max(0, Math.min(end, totalFileLines) - first); + const truncation: TruncationResult = { + ...truncateHead(kept.join("")), + totalLines: selectedBytes === 0 ? 0 : selectedLines - (selectionEndsWithNewline ? 1 : 0), + totalBytes: selectedBytes, }; + let outputText: string; + let details: ReadToolDetails | undefined; + if (truncation.firstLineExceedsLimit) { + const firstLineSize = formatSize(firstLineBytes); + outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`; + details = { truncation }; + } else if (truncation.truncated) { + const endLineDisplay = startLineDisplay + truncation.outputLines - 1; + const nextOffset = endLineDisplay + 1; + outputText = truncation.content; + if (truncation.truncatedBy === "lines") { + outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; + } else { + outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`; + } + details = { truncation }; + } else if (userLimitedLines !== undefined && startLine + userLimitedLines < totalFileLines) { + const remaining = totalFileLines - (startLine + userLimitedLines); + const nextOffset = startLine + userLimitedLines + 1; + outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; + } else { + outputText = truncation.content; + } + + return { content: [{ type: "text", text: outputText }], details }; } diff --git a/packages/agent-runtime/src/pi/vendor/pi-harness/types.ts b/packages/agent-runtime/src/pi/vendor/pi-harness/types.ts index b0d3173bd..eb9a64ab3 100644 --- a/packages/agent-runtime/src/pi/vendor/pi-harness/types.ts +++ b/packages/agent-runtime/src/pi/vendor/pi-harness/types.ts @@ -190,6 +190,30 @@ export interface TextLineReader { close(context: Context): Promise; } +/** + * Positional byte reader over one open regular file (VC-801, a local + * divergence): `read` reads only what it is asked for, where it is asked, so a + * caller can look at a file of any size without holding it. + */ +export interface FileByteReader { + /** Size in bytes when the file was opened. */ + readonly size: number; + /** + * Read up to `length` bytes from byte `position`; fewer only at end of file. + * With `into` (at least `length` bytes) the bytes land there and the result + * is a view of it, valid until the next read into it; without it, a new + * buffer is allocated. + */ + read( + position: number, + length: number, + context: Context, + into?: Uint8Array, + ): Promise>; + /** Release the open file. Must be best-effort and must not throw or reject. */ + close(context: Context): Promise; +} + /** * Filesystem capability used by the harness. * @@ -219,6 +243,12 @@ export interface FileSystem { ): Promise>; /** Read a binary file. */ readBinaryFile(path: string, context: Context): Promise>; + /** + * Open a regular file for positional byte reads, following symlinks. Anything + * else — a directory, FIFO, device or socket — is refused at once, without + * waiting on a writer or reading a byte. + */ + openFileReader(path: string, context: Context): Promise>; /** Create or overwrite a file, creating parent directories when supported. */ writeFile( path: string, From 5a7d5a902bea368202c19c0a8586b11b2df7f729 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Mon, 19 Oct 2026 14:59:06 +0300 Subject: [PATCH 2/4] fix(agent-runtime): judge read's target before opening it; virtual 500 MB test (VC-801 review) - openFileReader stats the path (following links) and refuses a directory, FIFO, device or socket before open; opens O_NONBLOCK | O_NOCTTY and keeps the fstat on the descriptor as the swap guard. - read answers a NUL-bearing head that opens with a UTF-16 BOM with an iconv hint instead of the binary notice; countLf replaces the duplicated scan. - The large-file tests drop on-disk fixtures and RSS sampling for a virtual 500 MB FileByteReader: no read exceeds the chunk, one buffer is reused, and truncateHead only ever sees the bounded kept prefix. --- .../agent-runtime/src/pi/read-bounded.test.ts | 207 ++++++++++++++---- .../src/pi/vendor/pi-harness/README.md | 29 +-- .../src/pi/vendor/pi-harness/env/nodejs.ts | 37 +++- .../src/pi/vendor/pi-harness/tools/read.ts | 30 ++- 4 files changed, 217 insertions(+), 86 deletions(-) diff --git a/packages/agent-runtime/src/pi/read-bounded.test.ts b/packages/agent-runtime/src/pi/read-bounded.test.ts index 233caf21a..60f98c8cf 100644 --- a/packages/agent-runtime/src/pi/read-bounded.test.ts +++ b/packages/agent-runtime/src/pi/read-bounded.test.ts @@ -1,6 +1,8 @@ import { execFileSync } from "node:child_process"; import { constants } from "node:fs"; import * as fs from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { refusingCredentialReads } from "./credential-env"; @@ -13,9 +15,28 @@ import { withAbortSignal, } from "./harness-env"; import { ScopedExecutionEnv } from "./scoped-execution-env"; -import type { ExecutionEnv, FileError, Result } from "./vendor/pi-harness/types"; +import { + ok, + type ExecutionEnv, + type FileByteReader, + type FileError, + type Result, +} from "./vendor/pi-harness/types"; import { formatSize, truncateHead } from "./vendor/pi-harness/utils/truncate"; +/** The length of every string `read` hands `truncateHead`: what it kept and decoded. */ +const truncated = vi.hoisted(() => ({ inputs: [] as number[] })); +vi.mock("./vendor/pi-harness/utils/truncate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + truncateHead: (...args: Parameters) => { + truncated.inputs.push(args[0].length); + return actual.truncateHead(...args); + }, + }; +}); + /** * VC-801: `read` runs in the host process, so it must never read a file whole * before its 2,000-line / 50 KB cut, never wait on a FIFO or read a device to @@ -104,6 +125,11 @@ function pngChunk(type: string, length: number): Buffer { return data; } +/** A path `stat` that says regular file, for mocks that serve their own handle. */ +async function regularPath() { + return { isFile: () => true, isDirectory: () => false }; +} + function failure(result: Result, code: string) { expect(result).toMatchObject({ ok: false, error: { name: "FileError", code } }); } @@ -115,42 +141,50 @@ function lineStart(bytes: Uint8Array, line: number): number { return index; } -/** Run `work`, sampling RSS while it yields; resolves with its result and the peak growth. */ -async function peakRssGrowth(work: () => Promise): Promise<{ value: T; growth: number }> { - const baseline = process.memoryUsage.rss(); - let peak = baseline; - const sample = setInterval(() => { - peak = Math.max(peak, process.memoryUsage.rss()); - }, 1); - try { - const value = await work(); - peak = Math.max(peak, process.memoryUsage.rss()); - return { value, growth: peak - baseline }; - } finally { - clearInterval(sample); - } +/** The streamed read's buffer, which no single read may exceed. */ +const CHUNK_BYTES = 256 * 1024; +const VIRTUAL_BYTES = 500 * 1024 * 1024; + +/** + * A 500 MB file that exists only as a repeating pattern: every read is served + * from one small block and recorded, so the test sees exactly what `read` asked + * for without anything that size touching disk or memory. + */ +function virtualFile(pattern: string, size = VIRTUAL_BYTES) { + const unit = Buffer.from(pattern); + const block = Buffer.from(pattern.repeat(Math.ceil((CHUNK_BYTES * 2) / unit.length))); + const reads: Array<{ position: number; length: number; into?: Uint8Array }> = []; + const reader: FileByteReader = { + size, + async read(position, length, _context, into) { + reads.push({ position, length, into }); + const count = Math.max(0, Math.min(length, size - position)); + const target = into ?? new Uint8Array(length); + const start = position % unit.length; + target.set(block.subarray(start, start + count)); + return ok(target.subarray(0, count)); + }, + async close() {}, + }; + return { reader, reads }; } -/** Write `total` bytes of `block` repeated, without holding them. */ -async function writeRepeated(path: string, block: string, total: number): Promise { - const bytes = Buffer.from(block); - const file = await fs.open(path, "w"); - try { - for (let written = 0; written < total; written += bytes.length) await file.write(bytes); - } finally { - await file.close(); - } +/** A real env whose `openFileReader` hands `read` the virtual file for an existing placeholder. */ +async function servingVirtual(name: string, reader: FileByteReader) { + const env = new NodeExecutionEnv({ cwd: scratch }); + await fs.writeFile(join(scratch, name), "placeholder"); + vi.spyOn(env, "openFileReader").mockResolvedValue(ok(reader)); + return env; } describe("read streams text instead of loading the file (VC-801)", () => { - it("returns the first 2,000 lines of a large log with its continuation hint and bounded memory", async () => { - const env = new NodeExecutionEnv({ cwd: scratch }); + it("returns the first 2,000 lines of a 500 MB log with its hint, one bounded read at a time", async () => { const line = "log entry 123456789\n"; // 20 bytes: 2,000 lines fit under 50 KB - const block = line.repeat(50_000); // 1,000,000 bytes - const total = 128 * block.length; - await writeRepeated(join(scratch, "big.log"), block, total); - const { value: result, growth } = await peakRssGrowth(() => read(env, "big.log")); - const lines = total / line.length; + const lines = VIRTUAL_BYTES / line.length; + const { reader, reads } = virtualFile(line); + const env = await servingVirtual("big.log", reader); + truncated.inputs.length = 0; + const result = await read(env, "big.log"); expect(text(result)).toBe( `${line.repeat(DEFAULT_MAX_LINES - 1)}${line.slice(0, -1)}\n\n[Showing lines 1-2000 of ${lines + 1}. Use offset=2001 to continue.]`, ); @@ -158,33 +192,40 @@ describe("read streams text instead of loading the file (VC-801)", () => { truncated: true, truncatedBy: "lines", totalLines: lines, - totalBytes: total, + totalBytes: VIRTUAL_BYTES, outputLines: DEFAULT_MAX_LINES, }); - // The whole-file read held the 128 MB buffer, a 128 M-character string and - // its line array at once. - expect(growth).toBeLessThan(24 * 1024 * 1024); - const middle = await read(env, "big.log", 1_000_001, 2); + // Every byte is read (the hint names the line count), but never more than + // one chunk at a time, always into the same buffer after the sniffed head. + expect(Math.max(...reads.map((call) => call.length))).toBeLessThanOrEqual(CHUNK_BYTES); + expect(new Set(reads.slice(1).map((call) => call.into)).size).toBe(1); + expect(reads.at(-1)?.position).toBe(VIRTUAL_BYTES); + // And only the prefix the cut can show was kept and decoded. + expect(truncated.inputs).toHaveLength(1); + expect(truncated.inputs[0]).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 1 + CHUNK_BYTES); + + const middle = await read(env, "big.log", 10_000_001, 2); expect(text(middle)).toBe( - `${line}${line.slice(0, -1)}\n\n[${lines + 1 - 1_000_002} more lines in file. Use offset=1000003 to continue.]`, + `${line}${line.slice(0, -1)}\n\n[${lines + 1 - 10_000_002} more lines in file. Use offset=10000003 to continue.]`, ); - }, 60_000); + }); - it("measures a single enormous line without holding it", async () => { - const env = new NodeExecutionEnv({ cwd: scratch }); - const block = "x".repeat(1024 * 1024); - await writeRepeated(join(scratch, "minified.json"), block, 64 * block.length); - const { value: result, growth } = await peakRssGrowth(() => read(env, "minified.json")); + it("measures a single 500 MB line without holding it", async () => { + const { reader, reads } = virtualFile("x"); + const env = await servingVirtual("minified.json", reader); + truncated.inputs.length = 0; + const result = await read(env, "minified.json"); expect(text(result)).toBe( - `[Line 1 is 64.0MB, exceeds 50.0KB limit. Use bash: sed -n '1p' minified.json | head -c ${DEFAULT_MAX_BYTES}]`, + `[Line 1 is 500.0MB, exceeds 50.0KB limit. Use bash: sed -n '1p' minified.json | head -c ${DEFAULT_MAX_BYTES}]`, ); expect(result.details?.truncation).toMatchObject({ firstLineExceedsLimit: true, totalLines: 1, - totalBytes: 64 * block.length, + totalBytes: VIRTUAL_BYTES, }); - expect(growth).toBeLessThan(24 * 1024 * 1024); - }, 60_000); + expect(Math.max(...reads.map((call) => call.length))).toBeLessThanOrEqual(CHUNK_BYTES); + expect(truncated.inputs[0]).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 1 + CHUNK_BYTES); + }); it.each([ ["empty", ""], @@ -310,6 +351,25 @@ describe("read refuses what is not a regular file at once (VC-801)", () => { } }); + it("refuses a socket behind a symlink before opening it", async () => { + const env = new NodeExecutionEnv({ cwd: scratch }); + // A short directory: a socket path must fit in sun_path. + const short = await fs.mkdtemp(join(tmpdir(), "vc801-")); + const server = createServer(); + try { + const socket = join(short, "s"); + await new Promise((listening) => server.listen(socket, listening)); + await fs.symlink(socket, join(scratch, "socket-link")); + await expect(read(env, "socket-link")).rejects.toMatchObject({ + code: "invalid", + message: expect.stringContaining("Not a regular file"), + }); + } finally { + await new Promise((closed) => server.close(closed)); + await fs.rm(short, { recursive: true, force: true }); + } + }); + it("refuses a directory", async () => { const env = new NodeExecutionEnv({ cwd: scratch }); await fs.mkdir(join(scratch, "dir")); @@ -344,6 +404,22 @@ describe("read names a binary file instead of decoding it (VC-801)", () => { }); }); + it.each([ + ["UTF-16LE", Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from("hi\n", "utf16le")]), true], + ["UTF-16BE", Buffer.from([0xfe, 0xff, 0x00, 0x68, 0x00, 0x69]), true], + ["0xFF then NUL", Buffer.from([0xff, 0x00, 0x41]), false], + ["0xFE then NUL", Buffer.from([0xfe, 0x00, 0x41]), false], + ])("tells %s text from binary", async (_name, bytes, utf16) => { + const env = new NodeExecutionEnv({ cwd: scratch }); + await fs.writeFile(join(scratch, "f.txt"), bytes); + const size = formatSize(bytes.length); + expect(text(await read(env, "f.txt"))).toBe( + utf16 + ? `[UTF-16 text (${size}), not shown as text. Use bash: iconv -f UTF-16 -t UTF-8 f.txt | head -c ${DEFAULT_MAX_BYTES}]` + : `[Binary file (${size}), not shown as text. Use bash: file f.txt, or xxd f.txt | head -c ${DEFAULT_MAX_BYTES}]`, + ); + }); + it("still decodes a file whose first NUL is past 8 KB", async () => { const env = new NodeExecutionEnv({ cwd: scratch }); const bytes = Buffer.alloc(9000, 0x41); @@ -407,6 +483,7 @@ describe("openFileReader (VC-801)", () => { const controller = new AbortController(); let closed = false; const aborting = await mockedEnv({ + stat: regularPath, open: async () => { controller.abort(); return { @@ -427,6 +504,7 @@ describe("openFileReader (VC-801)", () => { it("returns stat, read and close failures instead of rejecting", async () => { const io = Object.assign(new Error("fixture EIO"), { code: "EIO" }); const statFails = await mockedEnv({ + stat: regularPath, open: async () => ({ stat: async () => { throw io; @@ -441,6 +519,7 @@ describe("openFileReader (VC-801)", () => { throw io; }; const reads = await mockedEnv({ + stat: regularPath, open: async () => ({ stat: async () => ({ isFile: () => true, isDirectory: () => false, size: 4 }), read: async () => onRead(), @@ -460,6 +539,40 @@ describe("openFileReader (VC-801)", () => { await expect(opened.value.close(context)).resolves.toBeUndefined(); }); + it("opens without a controlling terminal and judges the open descriptor again", async () => { + const flags: number[] = []; + let closes = 0; + let kind = { isFile: () => false, isDirectory: () => false, size: 0 }; + const swapped = await mockedEnv({ + stat: regularPath, + open: async (_path: string, flag: number) => { + flags.push(flag); + return { + stat: async () => kind, + close: async () => { + closes++; + }, + }; + }, + }); + const refused = await swapped.openFileReader("f", context); + failure(refused, "invalid"); + expect(refused).toMatchObject({ error: { message: expect.stringContaining("Not a regular") } }); + kind = { isFile: () => false, isDirectory: () => true, size: 0 }; + failure(await swapped.openFileReader("f", context), "is_directory"); + expect(closes).toBe(2); + expect(flags[0]).toBe(constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOCTTY); + + const denied = Object.assign(new Error("fixture EACCES"), { code: "EACCES" }); + const unopenable = await mockedEnv({ + stat: regularPath, + open: async () => { + throw denied; + }, + }); + failure(await unopenable.openFileReader("f", context), "permission_denied"); + }); + it("is guarded like readBinaryFile in the scoped environment and refused for credential files", async () => { await fs.writeFile(join(scratch, "inside.txt"), "inside"); const scoped = await ScopedExecutionEnv.create(scratch); diff --git a/packages/agent-runtime/src/pi/vendor/pi-harness/README.md b/packages/agent-runtime/src/pi/vendor/pi-harness/README.md index d200cd04f..986f530fc 100644 --- a/packages/agent-runtime/src/pi/vendor/pi-harness/README.md +++ b/packages/agent-runtime/src/pi/vendor/pi-harness/README.md @@ -123,19 +123,22 @@ branch-summary execution. `Agent`/agent loop are **not** vendored. reported `patch` is built from the original and written text so it applies to the file's bytes. `detectLineEnding` and `restoreLineEndings` are removed. 7. **Bounded read (VC-801):** `read` no longer calls `readBinaryFile`. It opens - the file through a new `FileSystem.openFileReader` (Node: opened - `O_NONBLOCK` and judged by `fstat` on the open descriptor, so a directory, - FIFO, device or socket is refused at once, symlinks followed) and reads the - first 8 KiB. An image signature there still reads the whole file into the - unchanged image path; a NUL byte there answers a binary notice instead of - decoded text; anything else streams in 256 KiB reads into one reused - buffer, finding lines by their LF byte and decoding only the prefix the - 2,000-line / 50 KiB cut can show. Text, continuation offsets and truncation - wording are unchanged; `details.truncation.totalBytes` and an oversized - first line's size are counted in the file's bytes (less a leading BOM), - which differs from decoding everything only for invalid UTF-8. A negative - `limit` now selects no lines where `Array.prototype.slice` counted from the - end. + the file through a new `FileSystem.openFileReader` (Node: the path is + `stat`ed, following symlinks, and a directory, FIFO, device or socket is + refused before anything is opened; the file is then opened + `O_NONBLOCK | O_NOCTTY` and judged again by `fstat` on the descriptor, so a + path swapped to another file type in between is still refused) and reads + the first 8 KiB. An image signature there still reads the whole file into + the unchanged image path; a NUL byte there answers a binary notice (a + UTF-16 notice when a UTF-16 BOM leads) instead of decoded text; anything + else streams in 256 KiB reads into one reused buffer, finding lines by + their LF byte and decoding only the prefix the 2,000-line / 50 KiB cut can + show. Every byte is still read, so the hint can name the line count. Text, + continuation offsets and truncation wording are unchanged; + `details.truncation.totalBytes` and an oversized first line's size are + counted in the file's bytes (less a leading BOM), which differs from + decoding everything only for invalid UTF-8. A negative `limit` now selects + no lines where `Array.prototype.slice` counted from the end. ## Compatibility evidence diff --git a/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts b/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts index b6b002741..1da7534df 100644 --- a/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts +++ b/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts @@ -13,6 +13,7 @@ import { realpath, rename, rm, + stat, writeFile, } from "node:fs/promises"; import { homedir, constants as osConstants, tmpdir } from "node:os"; @@ -525,6 +526,18 @@ class NodeFileByteReader implements FileByteReader { const NOT_A_REGULAR_FILE = "Not a regular file (a FIFO, device or socket); only regular files can be read"; +/** Why a file of this kind cannot be read, or nothing when it is a regular file. */ +function notRegularFile( + stats: { isFile(): boolean; isDirectory(): boolean }, + path: string, +): FileError | undefined { + if (stats.isFile()) return undefined; + return stats.isDirectory() + ? new FileError("is_directory", "Path is a directory", path) + : new FileError("invalid", NOT_A_REGULAR_FILE, path); +} + +/** The descriptor judged again: the path's kind is checked before open, this catches a swap in between. */ async function regularFileReader( file: Awaited>, path: string, @@ -534,12 +547,8 @@ async function regularFileReader( if (aborted) return aborted; try { const stats = await file.stat(); - if (stats.isFile()) return ok(new NodeFileByteReader(file, path, stats.size)); - return err( - stats.isDirectory() - ? new FileError("is_directory", "Path is a directory", path) - : new FileError("invalid", NOT_A_REGULAR_FILE, path), - ); + const refusal = notRegularFile(stats, path); + return refusal ? err(refusal) : ok(new NodeFileByteReader(file, path, stats.size)); } catch (error) { return err(toFileError(error, path)); } @@ -969,9 +978,12 @@ export class NodeExecutionEnv implements ExecutionEnv { } /** - * Opened non-blocking and judged on the open descriptor, so a FIFO with no - * writer cannot hold the open and a path swapped after a check cannot slip a - * device past it (VC-801). Non-blocking changes nothing for a regular file. + * The path's kind is judged before anything is opened, so a device, FIFO or + * socket behind a symlink is refused without an open's side effects, and + * judged again on the open descriptor, so a path swapped to one in between is + * still refused (VC-801). Opened non-blocking, so a FIFO swapped in cannot hold + * the open, and without a controlling terminal, so a terminal swapped in + * cannot become one. Neither flag changes anything for a regular file. */ async openFileReader(path: string, context: Context): Promise> { const resolved = resolvePath(this.cwd, path); @@ -979,7 +991,12 @@ export class NodeExecutionEnv implements ExecutionEnv { if (aborted) return aborted; let file: Awaited>; try { - file = await openFile(resolved, constants.O_RDONLY | constants.O_NONBLOCK); + const refusal = notRegularFile(await stat(resolved), resolved); + if (refusal) return err(refusal); + file = await openFile( + resolved, + constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOCTTY, + ); } catch (error) { return err(toFileError(error, resolved)); } diff --git a/packages/agent-runtime/src/pi/vendor/pi-harness/tools/read.ts b/packages/agent-runtime/src/pi/vendor/pi-harness/tools/read.ts index fae449319..7f123ebe6 100644 --- a/packages/agent-runtime/src/pi/vendor/pi-harness/tools/read.ts +++ b/packages/agent-runtime/src/pi/vendor/pi-harness/tools/read.ts @@ -70,15 +70,12 @@ export function createReadTool; details: ReadToolDetails | undefined; @@ -218,13 +222,7 @@ async function readText( const text = decoder.decode(selected, { stream: true }); kept.push(text); keptBytes += utf8ByteLength(text); - for ( - let newline = selected.indexOf(0x0a); - newline !== -1; - newline = selected.indexOf(0x0a, newline + 1) - ) { - keptNewlines++; - } + keptNewlines += countLf(selected); keeping = keptBytes <= DEFAULT_MAX_BYTES + 1 && keptNewlines <= DEFAULT_MAX_LINES; } }; From c4c997ddfa3694c57ed9fcfa2e9484a500e797b8 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Mon, 19 Oct 2026 15:21:05 +0300 Subject: [PATCH 3/4] fix(agent-runtime): keep read's file-type check on the descriptor only (VC-801) A stat before open drew CodeQL js/file-system-race (high), and docs/development/codeql-triage.md prescribes the descriptor check. open stays O_NONBLOCK | O_NOCTTY with fstat as the one check; a socket, which cannot be opened and fails with a platform-specific errno, is named from a stat taken after the failure, to choose the words only. --- .../agent-runtime/src/pi/read-bounded.test.ts | 21 ++++++------ .../src/pi/vendor/pi-harness/README.md | 10 +++--- .../src/pi/vendor/pi-harness/env/nodejs.ts | 32 +++++++++++++------ 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/packages/agent-runtime/src/pi/read-bounded.test.ts b/packages/agent-runtime/src/pi/read-bounded.test.ts index 60f98c8cf..6cea7abcd 100644 --- a/packages/agent-runtime/src/pi/read-bounded.test.ts +++ b/packages/agent-runtime/src/pi/read-bounded.test.ts @@ -125,11 +125,6 @@ function pngChunk(type: string, length: number): Buffer { return data; } -/** A path `stat` that says regular file, for mocks that serve their own handle. */ -async function regularPath() { - return { isFile: () => true, isDirectory: () => false }; -} - function failure(result: Result, code: string) { expect(result).toMatchObject({ ok: false, error: { name: "FileError", code } }); } @@ -351,7 +346,7 @@ describe("read refuses what is not a regular file at once (VC-801)", () => { } }); - it("refuses a socket behind a symlink before opening it", async () => { + it("refuses a socket behind a symlink as not a regular file", async () => { const env = new NodeExecutionEnv({ cwd: scratch }); // A short directory: a socket path must fit in sun_path. const short = await fs.mkdtemp(join(tmpdir(), "vc801-")); @@ -483,7 +478,6 @@ describe("openFileReader (VC-801)", () => { const controller = new AbortController(); let closed = false; const aborting = await mockedEnv({ - stat: regularPath, open: async () => { controller.abort(); return { @@ -504,7 +498,6 @@ describe("openFileReader (VC-801)", () => { it("returns stat, read and close failures instead of rejecting", async () => { const io = Object.assign(new Error("fixture EIO"), { code: "EIO" }); const statFails = await mockedEnv({ - stat: regularPath, open: async () => ({ stat: async () => { throw io; @@ -519,7 +512,6 @@ describe("openFileReader (VC-801)", () => { throw io; }; const reads = await mockedEnv({ - stat: regularPath, open: async () => ({ stat: async () => ({ isFile: () => true, isDirectory: () => false, size: 4 }), read: async () => onRead(), @@ -539,12 +531,11 @@ describe("openFileReader (VC-801)", () => { await expect(opened.value.close(context)).resolves.toBeUndefined(); }); - it("opens without a controlling terminal and judges the open descriptor again", async () => { + it("opens without a controlling terminal and judges the open descriptor", async () => { const flags: number[] = []; let closes = 0; let kind = { isFile: () => false, isDirectory: () => false, size: 0 }; const swapped = await mockedEnv({ - stat: regularPath, open: async (_path: string, flag: number) => { flags.push(flag); return { @@ -563,14 +554,20 @@ describe("openFileReader (VC-801)", () => { expect(closes).toBe(2); expect(flags[0]).toBe(constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOCTTY); + await fs.writeFile(join(scratch, "f"), "regular, but the open is refused"); const denied = Object.assign(new Error("fixture EACCES"), { code: "EACCES" }); const unopenable = await mockedEnv({ - stat: regularPath, open: async () => { throw denied; }, }); failure(await unopenable.openFileReader("f", context), "permission_denied"); + const odd = await mockedEnv({ + open: async () => { + throw new Error("no code at all"); + }, + }); + failure(await odd.openFileReader("f", context), "unknown"); }); it("is guarded like readBinaryFile in the scoped environment and refused for credential files", async () => { diff --git a/packages/agent-runtime/src/pi/vendor/pi-harness/README.md b/packages/agent-runtime/src/pi/vendor/pi-harness/README.md index 986f530fc..6f9eec9fa 100644 --- a/packages/agent-runtime/src/pi/vendor/pi-harness/README.md +++ b/packages/agent-runtime/src/pi/vendor/pi-harness/README.md @@ -123,11 +123,11 @@ branch-summary execution. `Agent`/agent loop are **not** vendored. reported `patch` is built from the original and written text so it applies to the file's bytes. `detectLineEnding` and `restoreLineEndings` are removed. 7. **Bounded read (VC-801):** `read` no longer calls `readBinaryFile`. It opens - the file through a new `FileSystem.openFileReader` (Node: the path is - `stat`ed, following symlinks, and a directory, FIFO, device or socket is - refused before anything is opened; the file is then opened - `O_NONBLOCK | O_NOCTTY` and judged again by `fstat` on the descriptor, so a - path swapped to another file type in between is still refused) and reads + the file through a new `FileSystem.openFileReader` (Node: opened + `O_NONBLOCK | O_NOCTTY` and judged by `fstat` on the descriptor, so a + directory, FIFO, device or socket is refused at once without a pathname + check/use window, symlinks followed; see + `docs/development/codeql-triage.md`) and reads the first 8 KiB. An image signature there still reads the whole file into the unchanged image path; a NUL byte there answers a binary notice (a UTF-16 notice when a UTF-16 BOM leads) instead of decoded text; anything diff --git a/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts b/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts index 1da7534df..bc65e5f1c 100644 --- a/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts +++ b/packages/agent-runtime/src/pi/vendor/pi-harness/env/nodejs.ts @@ -537,7 +537,21 @@ function notRegularFile( : new FileError("invalid", NOT_A_REGULAR_FILE, path); } -/** The descriptor judged again: the path's kind is checked before open, this catches a swap in between. */ +/** + * Why an open failed. A socket cannot be opened at all, and its errno differs + * by platform (ENXIO, or macOS's EOPNOTSUPP, which libuv leaves unnamed), so + * the path's kind is looked up after the failure, to choose the words only. + */ +async function openFailure(error: unknown, path: string): Promise { + try { + const refusal = notRegularFile(await stat(path), path); + if (refusal) return refusal; + } catch { + // The open's own error says it better. + } + return toFileError(error, path); +} + async function regularFileReader( file: Awaited>, path: string, @@ -978,12 +992,12 @@ export class NodeExecutionEnv implements ExecutionEnv { } /** - * The path's kind is judged before anything is opened, so a device, FIFO or - * socket behind a symlink is refused without an open's side effects, and - * judged again on the open descriptor, so a path swapped to one in between is - * still refused (VC-801). Opened non-blocking, so a FIFO swapped in cannot hold - * the open, and without a controlling terminal, so a terminal swapped in - * cannot become one. Neither flag changes anything for a regular file. + * Judged on the open descriptor rather than on the path, so there is no + * check-then-use window for a swap to slip a device past (VC-801; see + * docs/development/codeql-triage.md). Opened non-blocking, so a FIFO cannot + * hold the open, and without a controlling terminal, so a terminal cannot + * become one; neither flag changes anything for a regular file. A socket + * cannot be opened at all, and is refused in the same words. */ async openFileReader(path: string, context: Context): Promise> { const resolved = resolvePath(this.cwd, path); @@ -991,14 +1005,12 @@ export class NodeExecutionEnv implements ExecutionEnv { if (aborted) return aborted; let file: Awaited>; try { - const refusal = notRegularFile(await stat(resolved), resolved); - if (refusal) return err(refusal); file = await openFile( resolved, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOCTTY, ); } catch (error) { - return err(toFileError(error, resolved)); + return err(await openFailure(error, resolved)); } const opened = await regularFileReader(file, resolved, context); if (!opened.ok) await file.close().catch(() => undefined); From f301fbb2c8afdbb6e5e0ecaeb67b1d89b44a1c39 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Mon, 19 Oct 2026 15:39:10 +0300 Subject: [PATCH 4/4] test(agent-runtime): give the 500 MB virtual reads room under coverage (VC-801) The middle-offset check moves to its own 16 MB virtual file; the two 500 MB scans get a 120 s budget, since coverage instrumentation slows the 26 M-line LF scan past the 5 s default. --- packages/agent-runtime/src/pi/read-bounded.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/agent-runtime/src/pi/read-bounded.test.ts b/packages/agent-runtime/src/pi/read-bounded.test.ts index 6cea7abcd..43b12710e 100644 --- a/packages/agent-runtime/src/pi/read-bounded.test.ts +++ b/packages/agent-runtime/src/pi/read-bounded.test.ts @@ -198,10 +198,16 @@ describe("read streams text instead of loading the file (VC-801)", () => { // And only the prefix the cut can show was kept and decoded. expect(truncated.inputs).toHaveLength(1); expect(truncated.inputs[0]).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 1 + CHUNK_BYTES); - - const middle = await read(env, "big.log", 10_000_001, 2); + }, 120_000); // 26 M lines are scanned, slowly under coverage instrumentation + + it("reads an offset and limit from the middle of a large file", async () => { + const line = "log entry 123456789\n"; + const size = 800_000 * line.length; + const lines = size / line.length; + const env = await servingVirtual("big.log", virtualFile(line, size).reader); + const middle = await read(env, "big.log", 500_001, 2); expect(text(middle)).toBe( - `${line}${line.slice(0, -1)}\n\n[${lines + 1 - 10_000_002} more lines in file. Use offset=10000003 to continue.]`, + `${line}${line.slice(0, -1)}\n\n[${lines + 1 - 500_002} more lines in file. Use offset=500003 to continue.]`, ); }); @@ -220,7 +226,7 @@ describe("read streams text instead of loading the file (VC-801)", () => { }); expect(Math.max(...reads.map((call) => call.length))).toBeLessThanOrEqual(CHUNK_BYTES); expect(truncated.inputs[0]).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 1 + CHUNK_BYTES); - }); + }, 120_000); it.each([ ["empty", ""],