From cf68d7caf2285ff533de26abf0186b4319df322d Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 30 Oct 2026 11:37:24 +0800 Subject: [PATCH 1/4] perf: batch remote filesystem walks --- .changeset/batch-remote-filesystem-walks.md | 5 + .../acp-server/src/acp-fs/acpFsService.ts | 6 + .../os/backends/node-local/hostFsService.ts | 13 +- .../os/backends/remote/remoteFileSystem.ts | 51 +++++ .../src/os/interface/hostFileSystem.ts | 23 +++ .../src/os/interface/hostFsWalk.ts | 127 +++++++++++++ packages/agent-core-v2/src/remote/README.md | 7 +- .../client/remoteEnvironmentProvider.ts | 1 + .../src/remote/protocol/methods.ts | 33 ++++ .../src/remote/server/fsHandler.ts | 58 ++++++ .../src/remote/server/stdioHost.ts | 6 +- .../src/workspace/workspaceFs/fsService.ts | 126 +++++++----- .../tools/os/rgUnavailableGuidance.test.ts | 1 + packages/agent-core-v2/test/harness/agent.ts | 1 + .../backends/node-local/hostFsService.test.ts | 179 ++++++++++++++++++ .../os/backends/node-local/tools/grep.test.ts | 1 + .../test/program/program.test.ts | 1 + packages/agent-core-v2/test/remote/fs.test.ts | 171 ++++++++++++++++- .../test/tools/fixtures/fake-exec.ts | 1 + .../workspace/workspaceFs/fsService.test.ts | 98 +++++++++- 20 files changed, 858 insertions(+), 51 deletions(-) create mode 100644 .changeset/batch-remote-filesystem-walks.md create mode 100644 packages/agent-core-v2/src/os/interface/hostFsWalk.ts diff --git a/.changeset/batch-remote-filesystem-walks.md b/.changeset/batch-remote-filesystem-walks.md new file mode 100644 index 00000000000..bdf6664659a --- /dev/null +++ b/.changeset/batch-remote-filesystem-walks.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Speed up file completion in remote environments without ripgrep. diff --git a/packages/acp-server/src/acp-fs/acpFsService.ts b/packages/acp-server/src/acp-fs/acpFsService.ts index 5dd422e72b6..5bd7aa8224d 100644 --- a/packages/acp-server/src/acp-fs/acpFsService.ts +++ b/packages/acp-server/src/acp-fs/acpFsService.ts @@ -20,6 +20,8 @@ import { HostFileSystem, type HostDirEntry, type HostFileStat, + type HostWalkOptions, + type HostWalkResult, IHostFileSystem, ISessionContext, LifecycleScope, @@ -151,6 +153,10 @@ export class AcpHostFileSystem implements IHostFileSystem { return this.inner.readdir(path); } + walk(path: string, options: HostWalkOptions): Promise { + return this.inner.walk(path, options); + } + mkdir(path: string, options?: { readonly recursive?: boolean }): Promise { return this.inner.mkdir(path, options); } diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts index e141543c10d..214f2e68e94 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts @@ -14,8 +14,15 @@ import { } from 'node:fs/promises'; import { decodeTextWithErrors, readUtf8Lines, type TextDecodeErrors } from '#/_base/execEnv/decodeText'; -import { type HostDirEntry, type HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + type HostDirEntry, + type HostFileStat, + type HostWalkOptions, + type HostWalkResult, + IHostFileSystem, +} from '#/os/interface/hostFileSystem'; import { toHostFsError } from '#/os/interface/hostFsErrors'; +import { walkHostDirectory } from '#/os/interface/hostFsWalk'; const READ_CHUNK_SIZE = 64 * 1024; @@ -192,6 +199,10 @@ export class HostFileSystem implements IHostFileSystem { } } + async walk(path: string, options: HostWalkOptions): Promise { + return walkHostDirectory(this, path, options); + } + async mkdir(path: string, options?: { readonly recursive?: boolean }): Promise { try { await mkdir(path, { recursive: options?.recursive ?? false }); diff --git a/packages/agent-core-v2/src/os/backends/remote/remoteFileSystem.ts b/packages/agent-core-v2/src/os/backends/remote/remoteFileSystem.ts index f74e978faf7..9a9855c28af 100644 --- a/packages/agent-core-v2/src/os/backends/remote/remoteFileSystem.ts +++ b/packages/agent-core-v2/src/os/backends/remote/remoteFileSystem.ts @@ -1,3 +1,5 @@ +import * as posixPath from 'node:path/posix'; + import { decodeTextWithErrors, readUtf8Lines, @@ -7,8 +9,11 @@ import { splitLinesKeepingTerminator } from '#/_base/text/line-endings'; import type { HostDirEntry, HostFileStat, + HostWalkOptions, + HostWalkResult, IHostFileSystem, } from '#/os/interface/hostFileSystem'; +import { HOST_WALK_MAX_IGNORE_BYTES, walkHostDirectory } from '#/os/interface/hostFsWalk'; import { HostFsError, OsFsErrors, @@ -28,16 +33,23 @@ import { FS_READ_FILE_METHOD, FS_REMOVE_METHOD, FS_RENAME_METHOD, + FS_WALK_METHOD, FS_WRITE_FILE_CHUNK_BYTES, FS_WRITE_FILE_METHOD, type FsGetMetadataResult, type FsReadDirectoryResult, type FsReadFileResult, + type FsWalkResult, type FsWriteMode, } from '#/remote/protocol/methods'; import { rpcDomainError } from './rpcDomainError'; const HOST_FS_CODES: ReadonlySet = new Set(Object.values(OsFsErrors.codes)); +const REMOTE_WALK_PATH = { + join: (...paths: readonly string[]) => posixPath.join(...paths), + relative: (from: string, to: string) => posixPath.relative(from, to), + separator: posixPath.sep, +}; export function toRemoteFsError(error: unknown): Error { if (!(error instanceof RpcError)) { @@ -62,6 +74,7 @@ function isUtf8Encoding(encoding: BufferEncoding): boolean { export class RemoteFileSystem implements IHostFileSystem { declare readonly _serviceBrand: undefined; + private walkSupported: boolean | undefined; constructor(private readonly connection: ExecChannel) {} @@ -232,6 +245,44 @@ export class RemoteFileSystem implements IHostFileSystem { })); } + async walk(path: string, options: HostWalkOptions): Promise { + if (this.walkSupported === false) return this.walkFallback(path, options); + if (options.ignore !== undefined && Buffer.byteLength(JSON.stringify(options.ignore)) > HOST_WALK_MAX_IGNORE_BYTES) { + return this.walkFallback(path, options); + } + try { + const result = await this.connection.call(FS_WALK_METHOD, { + path, + maxDepth: options.maxDepth, + maxDirectories: options.maxDirectories, + maxEntries: options.maxEntries, + maxResponseBytes: options.maxResponseBytes, + ignore: options.ignore, + timeoutMs: options.timeoutMs, + }) as FsWalkResult; + this.walkSupported = true; + return { + entries: result.entries, + errors: result.errors.map(({ path: errorPath, error }) => ({ + path: errorPath, + error: toRemoteFsError(RpcError.fromErrorBody(error)), + })), + truncated: result.truncated, + responseBytes: result.responseBytes, + }; + } catch (error) { + if (error instanceof RpcError && error.code === RpcErrorCode.MethodNotFound) { + this.walkSupported = false; + return this.walkFallback(path, options); + } + throw toRemoteFsError(error); + } + } + + private async walkFallback(path: string, options: HostWalkOptions): Promise { + return walkHostDirectory(this, path, options, { path: REMOTE_WALK_PATH }); + } + async mkdir(path: string, options?: { readonly recursive?: boolean }): Promise { await this.call(FS_CREATE_DIRECTORY_METHOD, { path, diff --git a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts index 0199b65263d..82e955bfa5b 100644 --- a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts +++ b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts @@ -18,6 +18,28 @@ export interface HostDirEntry { readonly isSymbolicLink?: boolean; } +export interface HostWalkOptions { + readonly maxDepth: number; + readonly maxDirectories: number; + readonly maxEntries: number; + readonly maxResponseBytes: number; + readonly ignore?: readonly string[]; + readonly timeoutMs?: number; + readonly signal?: AbortSignal; +} + +export interface HostWalkError { + readonly path: string; + readonly error: Error; +} + +export interface HostWalkResult { + readonly entries: readonly HostDirEntry[]; + readonly errors: readonly HostWalkError[]; + readonly truncated: boolean; + readonly responseBytes: number; +} + export interface IHostFileSystem { readonly _serviceBrand: undefined; @@ -37,6 +59,7 @@ export interface IHostFileSystem { stat(path: string): Promise; lstat(path: string): Promise; readdir(path: string): Promise; + walk(path: string, options: HostWalkOptions): Promise; mkdir(path: string, options?: { readonly recursive?: boolean }): Promise; remove(path: string): Promise; rename?(from: string, to: string): Promise; diff --git a/packages/agent-core-v2/src/os/interface/hostFsWalk.ts b/packages/agent-core-v2/src/os/interface/hostFsWalk.ts new file mode 100644 index 00000000000..29b1c077503 --- /dev/null +++ b/packages/agent-core-v2/src/os/interface/hostFsWalk.ts @@ -0,0 +1,127 @@ +import { join, relative, sep } from 'node:path'; + +import ignore from 'ignore'; + +import type { HostDirEntry, HostWalkOptions, HostWalkResult } from './hostFileSystem'; +import { HostFsError, OsFsErrors } from './hostFsErrors'; + +export const HOST_WALK_MAX_DEPTH = 64; +export const HOST_WALK_MAX_DIRECTORIES = 10_000; +export const HOST_WALK_MAX_ENTRIES = 100_000; +export const HOST_WALK_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +export const HOST_WALK_MAX_IGNORE_BYTES = 1024 * 1024; +export const HOST_WALK_MAX_TIMEOUT_MS = 60_000; +export const HOST_WALK_MAX_DIRECTORY_ENTRIES = 50_000; + +interface HostWalkerFs { + readdir(path: string): Promise; +} + +interface HostWalkPath { + readonly separator: string; + join(...paths: readonly string[]): string; + relative(from: string, to: string): string; +} + +interface HostWalkHooks { + readonly path?: HostWalkPath; + readonly serializeError?: (path: string, error: Error) => unknown; +} + +export async function walkHostDirectory( + fs: HostWalkerFs, + root: string, + options: HostWalkOptions, + hooks: HostWalkHooks = {}, + readDirectory: (path: string) => Promise = fs.readdir.bind(fs), +): Promise { + validateLimit('maxDepth', options.maxDepth, 0, HOST_WALK_MAX_DEPTH); + validateLimit('maxDirectories', options.maxDirectories, 1, HOST_WALK_MAX_DIRECTORIES); + validateLimit('maxEntries', options.maxEntries, 1, HOST_WALK_MAX_ENTRIES); + validateLimit('maxResponseBytes', options.maxResponseBytes, 1, HOST_WALK_MAX_RESPONSE_BYTES); + if (options.timeoutMs !== undefined) { + validateLimit('timeoutMs', options.timeoutMs, 1, HOST_WALK_MAX_TIMEOUT_MS); + } + const matcher = options.ignore === undefined ? undefined : ignore().add(options.ignore); + const serializeError = hooks.serializeError ?? ((_path, error) => ({ + name: error.name, + message: error.message, + })); + const pathApi = hooks.path ?? { join, relative, separator: sep }; + const timeout = options.timeoutMs === undefined ? undefined : AbortSignal.timeout(options.timeoutMs); + const signal = options.signal === undefined + ? timeout + : timeout === undefined + ? options.signal + : AbortSignal.any([options.signal, timeout]); + const entries: HostDirEntry[] = []; + const errors: { path: string; error: Error }[] = []; + const pending = [{ path: root, depth: 0 }]; + let responseBytes = 64; + let truncated = false; + for (let index = 0; index < pending.length && !truncated; index += 1) { + if (signal?.aborted === true) { + truncated = true; + break; + } + const { path: directory, depth } = pending[index]!; + let children: readonly HostDirEntry[]; + try { + children = await readDirectory(directory); + if (children.length > HOST_WALK_MAX_DIRECTORY_ENTRIES) { + throw new HostFsError( + OsFsErrors.codes.OS_FS_DIRECTORY_TOO_LARGE, + `readdir ${directory} exceeds the ${HOST_WALK_MAX_DIRECTORY_ENTRIES}-entry limit`, + { details: { path: directory, op: 'readdir', limit: HOST_WALK_MAX_DIRECTORY_ENTRIES } }, + ); + } + } catch (error) { + const errorBytes = Buffer.byteLength(JSON.stringify({ + path: directory, + error: serializeError(directory, asError(error)), + })) + 1; + if (responseBytes + errorBytes > options.maxResponseBytes) { + truncated = true; + break; + } + responseBytes += errorBytes; + errors.push({ path: directory, error: asError(error) }); + continue; + } + for (const child of children) { + if (signal?.aborted) { + truncated = true; + break; + } + const path = pathApi.join(directory, child.name); + const isDir = child.isDirectory && child.isSymbolicLink !== true; + const relPath = pathApi.relative(root, path).split(pathApi.separator).join('/'); + if (matcher?.ignores(isDir ? `${relPath}/` : relPath) === true) continue; + const entryBytes = Buffer.byteLength(JSON.stringify({ ...child, name: path })) + 1; + if (entries.length >= options.maxEntries || responseBytes + entryBytes > options.maxResponseBytes) { + truncated = true; + break; + } + responseBytes += entryBytes; + entries.push({ ...child, name: path }); + if (isDir && depth < options.maxDepth) { + if (pending.length >= options.maxDirectories) { + truncated = true; + break; + } + pending.push({ path, depth: depth + 1 }); + } + } + } + return { entries, errors, truncated, responseBytes }; +} + +function validateLimit(name: string, value: number, min: number, max: number): void { + if (!Number.isInteger(value) || value < min || value > max) { + throw new RangeError(`${name} must be an integer in [${min}, ${max}]`); + } +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/agent-core-v2/src/remote/README.md b/packages/agent-core-v2/src/remote/README.md index 555debb269f..d03d798dc28 100644 --- a/packages/agent-core-v2/src/remote/README.md +++ b/packages/agent-core-v2/src/remote/README.md @@ -67,6 +67,11 @@ deviations per the design spec, plus one forced addition: - `fs/readDirectory` preserves the symlink marker on each entry. The client rejects a truncated listing with `os.fs.directory_too_large` rather than exposing an incomplete directory; the current limit is 50,000 entries. +- `fs/walk` performs a bounded recursive listing in one call. It accepts the + root ignore rules needed to prune ignored directories before traversal and + reports entries, recoverable directory errors, and truncation. Clients fall + back to per-directory reads when an older executor does not implement it or + when the JSON-encoded ignore rules exceed 1MiB. - Added `fs/rename` (no codex counterpart). - `fs/remove` takes only `path` and always removes recursively and ignores a missing path, matching `IHostFileSystem.remove` (codex has `recursive`/`force`). @@ -89,7 +94,7 @@ deviations per the design spec, plus one forced addition: - `environment` adds `osArch`/`osVersion`/`pathClass` over codex (rg artifact selection needs arch), plus `cwd`/`tempDir`. - No ws transport, no `resumeSessionId`, no sandbox parameter family, no - `fs/walk`/`fs/copy`/`capabilityRoots/discoverV1`/`environmentConfig/read`. + `fs/copy`/`capabilityRoots/discoverV1`/`environmentConfig/read`. Process output is delivered through notifications (`process/output`, `process/exited`, `process/closed`). The executor retains process handles until diff --git a/packages/agent-core-v2/src/remote/client/remoteEnvironmentProvider.ts b/packages/agent-core-v2/src/remote/client/remoteEnvironmentProvider.ts index 10950441377..cc1131748c5 100644 --- a/packages/agent-core-v2/src/remote/client/remoteEnvironmentProvider.ts +++ b/packages/agent-core-v2/src/remote/client/remoteEnvironmentProvider.ts @@ -61,6 +61,7 @@ class DeclaredRemoteEnvironment implements Environment { stat: async (path) => this.connected().fs.stat(path), lstat: async (path) => this.connected().fs.lstat(path), readdir: async (path) => this.connected().fs.readdir(path), + walk: async (path, options) => this.connected().fs.walk(path, options), mkdir: async (path, options) => this.connected().fs.mkdir(path, options), remove: async (path) => this.connected().fs.remove(path), rename: async (from, to) => this.connected().fs.rename(from, to), diff --git a/packages/agent-core-v2/src/remote/protocol/methods.ts b/packages/agent-core-v2/src/remote/protocol/methods.ts index d7a9d121451..478cfee3285 100644 --- a/packages/agent-core-v2/src/remote/protocol/methods.ts +++ b/packages/agent-core-v2/src/remote/protocol/methods.ts @@ -7,6 +7,7 @@ export const FS_CREATE_DIRECTORY_METHOD = 'fs/createDirectory'; export const FS_GET_METADATA_METHOD = 'fs/getMetadata'; export const FS_CANONICALIZE_METHOD = 'fs/canonicalize'; export const FS_READ_DIRECTORY_METHOD = 'fs/readDirectory'; +export const FS_WALK_METHOD = 'fs/walk'; export const FS_REMOVE_METHOD = 'fs/remove'; export const FS_RENAME_METHOD = 'fs/rename'; @@ -105,8 +106,40 @@ export interface FsReadDirectoryEntry { export interface FsReadDirectoryResult { readonly entries: FsReadDirectoryEntry[]; + readonly truncated: boolean; +} + +export interface FsWalkParams { + readonly path: string; + readonly maxDepth: number; + readonly maxDirectories: number; + readonly maxEntries: number; + readonly maxResponseBytes: number; + readonly ignore?: readonly string[]; + readonly timeoutMs?: number; +} + +export interface FsWalkEntry { + readonly name: string; + readonly isFile: boolean; + readonly isDirectory: boolean; + readonly isSymbolicLink?: boolean; +} + +export interface FsWalkError { + readonly path: string; + readonly error: { + readonly code: number; + readonly message: string; + readonly data?: unknown; + }; +} +export interface FsWalkResult { + readonly entries: readonly FsWalkEntry[]; + readonly errors: readonly FsWalkError[]; readonly truncated: boolean; + readonly responseBytes: number; } export interface FsRemoveParams { diff --git a/packages/agent-core-v2/src/remote/server/fsHandler.ts b/packages/agent-core-v2/src/remote/server/fsHandler.ts index 5326bfe9682..7bd3b9c080c 100644 --- a/packages/agent-core-v2/src/remote/server/fsHandler.ts +++ b/packages/agent-core-v2/src/remote/server/fsHandler.ts @@ -12,7 +12,17 @@ import { type FsGetMetadataResult, type FsReadDirectoryResult, type FsReadFileResult, + type FsWalkResult, } from '#/remote/protocol/methods'; +import { + HOST_WALK_MAX_DEPTH, + HOST_WALK_MAX_DIRECTORIES, + HOST_WALK_MAX_ENTRIES, + HOST_WALK_MAX_IGNORE_BYTES, + HOST_WALK_MAX_RESPONSE_BYTES, + HOST_WALK_MAX_TIMEOUT_MS, + walkHostDirectory, +} from '#/os/interface/hostFsWalk'; type Params = Record; @@ -62,6 +72,15 @@ function optionalInteger( return value; } +function optionalStringArray(params: Params, name: string): readonly string[] | undefined { + const value = params[name]; + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new RpcError(RpcErrorCode.InvalidParams, `${name} must be an array of strings`); + } + return value; +} + function fsDomainError(error: unknown, ctx: { path: string; op: string }): RpcError { const hostError = toHostFsError(error, ctx); const code = @@ -196,6 +215,45 @@ export class FsHandler { } } + async walk(rawParams: unknown): Promise { + const params = requireParams(rawParams); + const path = requireAbsolutePath(params, 'path'); + const maxDepth = optionalInteger(params, 'maxDepth', 0, HOST_WALK_MAX_DEPTH) ?? HOST_WALK_MAX_DEPTH; + const maxDirectories = optionalInteger(params, 'maxDirectories', 1, HOST_WALK_MAX_DIRECTORIES) ?? HOST_WALK_MAX_DIRECTORIES; + const maxEntries = optionalInteger(params, 'maxEntries', 1, HOST_WALK_MAX_ENTRIES) ?? HOST_WALK_MAX_ENTRIES; + const maxResponseBytes = optionalInteger(params, 'maxResponseBytes', 1, HOST_WALK_MAX_RESPONSE_BYTES) ?? HOST_WALK_MAX_RESPONSE_BYTES; + const ignore = optionalStringArray(params, 'ignore'); + if (ignore !== undefined && Buffer.byteLength(JSON.stringify(ignore)) > HOST_WALK_MAX_IGNORE_BYTES) { + throw new RpcError( + RpcErrorCode.InvalidParams, + `ignore must not exceed ${HOST_WALK_MAX_IGNORE_BYTES} bytes when encoded as JSON`, + ); + } + const timeoutMs = optionalInteger(params, 'timeoutMs', 1, HOST_WALK_MAX_TIMEOUT_MS); + const result = await walkHostDirectory(this.fs, path, { + maxDepth, + maxDirectories, + maxEntries, + maxResponseBytes, + ignore, + timeoutMs, + }, { + serializeError: (errorPath, error) => fsDomainError(error, { + path: errorPath, + op: 'readdir', + }).toErrorBody(), + }); + return { + entries: result.entries, + errors: result.errors.map(({ path: errorPath, error }) => ({ + path: errorPath, + error: fsDomainError(error, { path: errorPath, op: 'readdir' }).toErrorBody(), + })), + truncated: result.truncated, + responseBytes: result.responseBytes, + }; + } + async remove(rawParams: unknown): Promise { const params = requireParams(rawParams); const path = requireAbsolutePath(params, 'path'); diff --git a/packages/agent-core-v2/src/remote/server/stdioHost.ts b/packages/agent-core-v2/src/remote/server/stdioHost.ts index 4acfe2e0aff..78515f49214 100644 --- a/packages/agent-core-v2/src/remote/server/stdioHost.ts +++ b/packages/agent-core-v2/src/remote/server/stdioHost.ts @@ -19,6 +19,7 @@ import { FS_READ_FILE_METHOD, FS_REMOVE_METHOD, FS_RENAME_METHOD, + FS_WALK_METHOD, FS_WRITE_FILE_METHOD, INITIALIZE_METHOD, MAX_IN_FLIGHT_CALLS, @@ -166,6 +167,7 @@ export class StdioHost { [FS_GET_METADATA_METHOD, (p) => fs.getMetadata(p)], [FS_CANONICALIZE_METHOD, (p) => fs.canonicalize(p)], [FS_READ_DIRECTORY_METHOD, (p) => fs.readDirectory(p)], + [FS_WALK_METHOD, (p) => fs.walk(p)], [FS_REMOVE_METHOD, (p) => fs.remove(p)], [FS_RENAME_METHOD, (p) => fs.rename(p)], [PROCESS_START_METHOD, (p) => pm.start(p)], @@ -283,7 +285,9 @@ export class StdioHost { ); return; } - const lane: Lane = message.method === FS_READ_FILE_METHOD ? 'data' : 'control'; + const lane: Lane = message.method === FS_READ_FILE_METHOD || message.method === FS_WALK_METHOD + ? 'data' + : 'control'; if (CONTROL_METHODS.has(message.method)) { void this.runHandler(message.id, handler, message.params, lane, false); return; diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index ef36d69da56..e940cf7264f 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -49,6 +49,12 @@ import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostFileSystem, type HostDirEntry, type HostFileStat } from '#/os/interface/hostFileSystem'; import { isHostFsNotDirectory, isHostFsNotFound } from '#/os/interface/hostFsErrors'; +import { + HOST_WALK_MAX_DEPTH, + HOST_WALK_MAX_DIRECTORIES, + HOST_WALK_MAX_ENTRIES, + HOST_WALK_MAX_RESPONSE_BYTES, +} from '#/os/interface/hostFsWalk'; import { type EnvironmentPath } from '#/environment/environment'; import { POSIX_ENVIRONMENT_PATH } from '#/environment/environmentDefaults'; import { IEnvironmentService, type EnvironmentResolver } from '#/app/environment/environment'; @@ -84,7 +90,7 @@ const PROBE_MAX_ENTRIES = 500; const GREP_TIMEOUT_MS = 30_000; const SUGGEST_TIMEOUT_MS = 10_000; const SUGGEST_WALK_ABORTED = new Error('suggest walk aborted'); -const WALK_MAX_DEPTH = 64; +const WALK_DEFAULT_IGNORE = ['.git/']; interface SuggestRoot { readonly dir: string; @@ -100,7 +106,10 @@ const MACOS_NOISE = new Set(['.DS_Store', '.AppleDouble', '.LSOverride']); export class WorkspaceFsService implements IWorkspaceFsService { declare readonly _serviceBrand: undefined; - private readonly gitignoreCache = new Map(); + private readonly gitignoreCache = new Map(); private rgResolution: RgResolution | null | undefined = undefined; private realRootsCache: | { readonly key: string; readonly roots: readonly { dir: string; real: string }[] } @@ -679,9 +688,10 @@ export class WorkspaceFsService implements IWorkspaceFsService { const candidates: FsSearchHit[] = []; const queryLower = req.query.toLowerCase(); const seenPaths = new Set(); + let inventoryTruncated = false; for (const root of roots) { - const matcher = req.follow_gitignore ? await this.matcherFor(root.dir) : undefined; - await this.walk(root.dir, '', matcher, async (relPath, name, kind) => { + const ignoreRules = req.follow_gitignore ? await this.walkIgnoreFor(root.dir) : WALK_DEFAULT_IGNORE; + inventoryTruncated = await this.walk(root.dir, ignoreRules, async (relPath, name, kind) => { const pathKey = `${root.real}/${relPath}`; if (seenPaths.has(pathKey)) return; seenPaths.add(pathKey); @@ -701,7 +711,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { score, match_positions: computeMatchPositions(path, queryLower), }); - }); + }) || inventoryTruncated; } candidates.sort((a, b) => { @@ -711,7 +721,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { const effectiveCap = Math.min(req.limit, SEARCH_HARD_CAP); const truncated = candidates.length > effectiveCap; - return { items: candidates.slice(0, effectiveCap), truncated }; + return { items: candidates.slice(0, effectiveCap), truncated: truncated || inventoryTruncated }; } async suggest(req: FsSuggestRequest): Promise { @@ -1017,10 +1027,11 @@ export class WorkspaceFsService implements IWorkspaceFsService { const top = new SuggestTopHeap(cap); const seenPaths = new Set(); let matched = 0; + let inventoryTruncated = false; try { for (const root of roots) { - const matcher = query.followGitignore ? await this.matcherFor(root.dir) : undefined; - await this.walk(root.dir, '', matcher, async (relPath, _name, kind) => { + const ignoreRules = query.followGitignore ? await this.walkIgnoreFor(root.dir) : WALK_DEFAULT_IGNORE; + inventoryTruncated = await this.walk(root.dir, ignoreRules, async (relPath, _name, kind) => { if (signal.aborted) throw SUGGEST_WALK_ABORTED; if (multi) { const pathKey = `${root.real}/${relPath}`; @@ -1031,7 +1042,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { if (candidate === null) return; matched += 1; top.push(this.displayCandidate(root, candidate)); - }); + }, { signal, timeoutMs: SUGGEST_TIMEOUT_MS }) || inventoryTruncated; } } catch (error) { if (error !== SUGGEST_WALK_ABORTED) throw error; @@ -1043,7 +1054,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { score: candidate.score, match_positions: [...candidate.positions], })); - return { items, truncated: matched > cap || signal.aborted }; + return { items, truncated: matched > cap || signal.aborted || inventoryTruncated }; } async grep(req: FsGrepRequest): Promise { @@ -1181,8 +1192,8 @@ export class WorkspaceFsService implements IWorkspaceFsService { const seenPaths = new Set(); const roots = await this.suggestRoots(); for (const root of roots) { - const matcher = req.follow_gitignore ? await this.matcherFor(root.dir) : undefined; - await this.walk(root.dir, '', matcher, async (rel, _name, kind) => { + const ignoreRules = req.follow_gitignore ? await this.walkIgnoreFor(root.dir) : WALK_DEFAULT_IGNORE; + truncated = await this.walk(root.dir, ignoreRules, async (rel, _name, kind) => { if (kind !== 'file') return; if (req.include_globs && !matchesAnyGlob(rel, req.include_globs)) return; if (req.exclude_globs && matchesAnyGlob(rel, req.exclude_globs)) return; @@ -1193,7 +1204,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { path: this.suggestDisplayPath(root, rel), absolute: this.path.join(root.dir, rel), }); - }); + }, { signal, timeoutMs: GREP_TIMEOUT_MS }) || truncated; } for (const file of filePaths) { @@ -1249,58 +1260,83 @@ export class WorkspaceFsService implements IWorkspaceFsService { private async walk( baseAbs: string, - rootRel: string, - matcher: Ignore | undefined, + ignoreRules: readonly string[] | undefined, visit: ( relPath: string, name: string, kind: 'file' | 'directory' | 'symlink', ) => Promise, - depth = 0, - ): Promise { - if (depth > WALK_MAX_DEPTH) return; - let entries: readonly HostDirEntry[]; - try { - entries = await this.hostFs.readdir(rootRel === '' ? baseAbs : this.path.join(baseAbs, rootRel)); - } catch (error) { + options: { signal?: AbortSignal; timeoutMs?: number } = {}, + ): Promise { + const result = await this.hostFs.walk(baseAbs, { + maxDepth: HOST_WALK_MAX_DEPTH, + maxDirectories: HOST_WALK_MAX_DIRECTORIES, + maxEntries: HOST_WALK_MAX_ENTRIES, + maxResponseBytes: HOST_WALK_MAX_RESPONSE_BYTES, + ignore: ignoreRules, + timeoutMs: options.timeoutMs, + signal: options.signal, + }); + for (const { path, error } of result.errors) { if (error instanceof Error2 && error.code === ErrorCodes.OS_FS_DIRECTORY_TOO_LARGE) { - throw mapFsError(error, rootRel); + throw mapFsError(error, path); } - return; } - for (const entry of entries) { - const { name } = entry; - if (name === '.git') continue; - const childRel = rootRel === '' ? name : `${rootRel}/${name}`; - const isDir = entry.isDirectory && entry.isSymbolicLink !== true; - if (matcher) { - const probe = isDir ? `${childRel}/` : childRel; - if (matcher.ignores(probe)) continue; - } - const kind: 'file' | 'directory' | 'symlink' = entry.isSymbolicLink - ? 'symlink' - : isDir - ? 'directory' - : 'file'; - await visit(childRel, name, kind); - if (isDir) { - await this.walk(baseAbs, childRel, matcher, visit, depth + 1); - } + const byParent = new Map(); + for (const entry of result.entries) { + const relPath = this.path.relative(baseAbs, entry.name).split(this.path.separator).join('/'); + if (relPath === '' || relPath === '..' || relPath.startsWith('../')) continue; + const slash = relPath.lastIndexOf('/'); + const parent = slash < 0 ? '' : relPath.slice(0, slash); + const name = slash < 0 ? relPath : relPath.slice(slash + 1); + const siblings = byParent.get(parent) ?? []; + siblings.push({ entry: { ...entry, name }, relPath }); + byParent.set(parent, siblings); } + const visitDirectory = async (parent: string): Promise => { + for (const { entry, relPath } of byParent.get(parent) ?? []) { + const { name } = entry; + if (name === '.git') continue; + const isDir = entry.isDirectory && entry.isSymbolicLink !== true; + const kind: 'file' | 'directory' | 'symlink' = entry.isSymbolicLink + ? 'symlink' + : isDir + ? 'directory' + : 'file'; + await visit(relPath, name, kind); + if (isDir) await visitDirectory(relPath); + } + }; + await visitDirectory(''); + return result.truncated; } private async matcherFor(rootDir: string): Promise { + return (await this.ignoreFor(rootDir)).matcher; + } + + private async walkIgnoreFor(rootDir: string): Promise { + return (await this.ignoreFor(rootDir)).walkRules; + } + + private async ignoreFor(rootDir: string): Promise<{ + readonly matcher: Ignore; + readonly walkRules: readonly string[]; + }> { const cached = this.gitignoreCache.get(rootDir); if (cached !== undefined) return cached; const ig = ignore(); ig.add('.git/'); + let contents = ''; try { - const contents = await this.hostFs.readText(this.path.join(rootDir, '.gitignore')); + contents = await this.hostFs.readText(this.path.join(rootDir, '.gitignore')); ig.add(contents); } catch { } - this.gitignoreCache.set(rootDir, ig); - return ig; + const rules = ['.git/', ...contents.split(/\r?\n/)]; + const result = { matcher: ig, walkRules: rules }; + this.gitignoreCache.set(rootDir, result); + return result; } private async resolveRg(): Promise { diff --git a/packages/agent-core-v2/test/agent/tools/os/rgUnavailableGuidance.test.ts b/packages/agent-core-v2/test/agent/tools/os/rgUnavailableGuidance.test.ts index 8998335b8a2..6d0811ab750 100644 --- a/packages/agent-core-v2/test/agent/tools/os/rgUnavailableGuidance.test.ts +++ b/packages/agent-core-v2/test/agent/tools/os/rgUnavailableGuidance.test.ts @@ -56,6 +56,7 @@ function createBackend(options: { stat: async () => directoryStat(), lstat: async () => directoryStat(), readdir: () => notImplemented('readdir'), + walk: () => notImplemented('walk'), mkdir: () => notImplemented('mkdir'), remove: () => notImplemented('remove'), realpath: async (path: string) => path, diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 399a6bb3cab..f52efda2b97 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -590,6 +590,7 @@ function isFullHostFs(input: unknown): boolean { 'realpath', 'stat', 'readdir', + 'walk', 'mkdir', 'remove', ]; diff --git a/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts index 6d56f42ab85..7f60fff54aa 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts @@ -5,6 +5,7 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { walkHostDirectory } from '#/os/interface/hostFsWalk'; let dir: string; let fs: HostFileSystem; @@ -53,6 +54,184 @@ describe('HostFileSystem streamed writes', () => { }); }); +describe('HostFileSystem walk', () => { + const options = { + maxDepth: 64, + maxDirectories: 10_000, + maxEntries: 100_000, + maxResponseBytes: 8 * 1024 * 1024, + }; + + it('lists nested entries without following directory symlinks', async () => { + await mkdir(join(dir, 'a/b'), { recursive: true }); + await writeFile(join(dir, 'a/b/file.ts'), ''); + await writeFile(join(dir, 'root.ts'), ''); + await symlink(join(dir, 'a'), join(dir, 'link')); + + const result = await fs.walk(dir, options); + + const names = result.entries.map((entry) => entry.name.slice(dir.length + 1)); + expect(names).toHaveLength(5); + expect(names).toEqual(expect.arrayContaining([ + 'a', + 'root.ts', + 'link', + 'a/b', + 'a/b/file.ts', + ])); + expect(result.entries.find((entry) => entry.name === join(dir, 'link'))?.isSymbolicLink).toBe(true); + expect(result.errors).toEqual([]); + expect(result.truncated).toBe(false); + }); + + it('prunes ignored directories before traversing them', async () => { + await mkdir(join(dir, 'node_modules/pkg'), { recursive: true }); + await mkdir(join(dir, 'src'), { recursive: true }); + await writeFile(join(dir, 'node_modules/pkg/index.ts'), ''); + await writeFile(join(dir, 'src/app.ts'), ''); + + const result = await fs.walk(dir, { ...options, ignore: ['node_modules/'] }); + + expect(result.entries.map((entry) => entry.name.slice(dir.length + 1))).toEqual(['src', 'src/app.ts']); + expect(result.truncated).toBe(false); + }); + + it('returns descendants at maxDepth but does not traverse below them', async () => { + await mkdir(join(dir, 'a/b/c'), { recursive: true }); + await writeFile(join(dir, 'a/b/c/deep.ts'), ''); + + const result = await fs.walk(dir, { ...options, maxDepth: 1 }); + + expect(result.entries.map((entry) => entry.name.slice(dir.length + 1))).toEqual(['a', 'a/b']); + expect(result.truncated).toBe(false); + }); + + it('reports missing roots as recoverable errors', async () => { + const result = await fs.walk(join(dir, 'missing'), options); + + expect(result.entries).toEqual([]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatchObject({ + path: join(dir, 'missing'), + error: { code: 'os.fs.not_found' }, + }); + }); + + it('bounds directories and entries without reporting an exact fit as truncated', async () => { + const tree = new Map([ + ['/root', [ + { name: 'a', isFile: false, isDirectory: true }, + { name: 'b', isFile: false, isDirectory: true }, + ]], + ['/root/a', [{ name: 'one.ts', isFile: true, isDirectory: false }]], + ['/root/b', [{ name: 'two.ts', isFile: true, isDirectory: false }]], + ]); + const backend = { + readdir: async (path: string) => tree.get(path) ?? [], + }; + + const exact = await walkHostDirectory(backend, '/root', { + ...options, + maxDirectories: 3, + maxEntries: 4, + }); + const boundedDirectories = await walkHostDirectory(backend, '/root', { + ...options, + maxDirectories: 2, + maxEntries: 4, + }); + const boundedEntries = await walkHostDirectory(backend, '/root', { + ...options, + maxDirectories: 3, + maxEntries: 3, + }); + + expect(exact.truncated).toBe(false); + expect(exact.entries).toHaveLength(4); + expect(boundedDirectories.truncated).toBe(true); + expect(boundedDirectories.entries.map((entry) => entry.name)).toEqual(['/root/a', '/root/b']); + expect(boundedEntries.truncated).toBe(true); + expect(boundedEntries.entries.map((entry) => entry.name)).toEqual(['/root/a', '/root/b', '/root/a/one.ts']); + }); + + it('counts entry and error JSON in the response budget', async () => { + const backend = { + readdir: async (path: string) => { + if (path === '/root') return [{ name: 'a', isFile: true, isDirectory: false }]; + throw new Error('unreadable'); + }, + }; + const read = () => walkHostDirectory(backend, '/root', { + ...options, + maxResponseBytes: 256, + }); + const initial = await read(); + const result = await walkHostDirectory(backend, '/root', { + ...options, + maxResponseBytes: initial.responseBytes - 1, + }); + + expect(initial.truncated).toBe(false); + expect(result.entries).toEqual([]); + expect(result.truncated).toBe(true); + + const withError = await walkHostDirectory(backend, '/root', options, { + serializeError: (_path, error) => ({ message: error.message }), + }, async (path) => { + if (path === '/root') return [{ name: 'a', isFile: false, isDirectory: true }]; + throw new Error('unreadable'); + }); + const beforeError = await walkHostDirectory(backend, '/root', { + ...options, + maxResponseBytes: withError.responseBytes - 1, + }, { + serializeError: (_path, error) => ({ message: error.message }), + }, async (path) => { + if (path === '/root') return [{ name: 'a', isFile: false, isDirectory: true }]; + throw new Error('unreadable'); + }); + + expect(withError.errors).toHaveLength(1); + expect(beforeError.errors).toEqual([]); + expect(beforeError.truncated).toBe(true); + }); + + it('stops on cancellation and timeout', async () => { + const controller = new AbortController(); + const backend = { + readdir: async (path: string) => { + if (path === '/root') return [{ name: 'a', isFile: false, isDirectory: true }]; + controller.abort(); + return [{ name: 'file.ts', isFile: true, isDirectory: false }]; + }, + }; + const cancelled = await walkHostDirectory(backend, '/root', { + ...options, + signal: controller.signal, + }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const timedOut = walkHostDirectory(backend, '/root', { + ...options, + timeoutMs: 5, + }, {}, async (path) => { + if (path === '/root') return [{ name: 'a', isFile: false, isDirectory: true }]; + await gate; + return [{ name: 'file.ts', isFile: true, isDirectory: false }]; + }); + await new Promise((resolve) => setTimeout(resolve, 15)); + release(); + const afterTimeout = await timedOut; + + expect(cancelled.entries.map((entry) => entry.name)).toEqual(['/root/a']); + expect(cancelled.truncated).toBe(true); + expect(afterTimeout.entries.map((entry) => entry.name)).toEqual(['/root/a']); + expect(afterTimeout.truncated).toBe(true); + }); +}); + describe('HostFileSystem stat / lstat', () => { it('stat follows a symlink to a regular file while lstat stats the link', async () => { const target = join(dir, 'target.txt'); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts index 3c41eeb76ef..a547538d2be 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts @@ -160,6 +160,7 @@ function createTestFs(kaos: FakeKaos): IHostFileSystem { stat: (path) => kaos.stat(path), lstat: (path) => kaos.stat(path), readdir: () => notImplemented('readdir'), + walk: () => notImplemented('walk'), mkdir: () => notImplemented('mkdir'), remove: () => notImplemented('remove'), realpath: () => notImplemented('realpath'), diff --git a/packages/agent-core-v2/test/program/program.test.ts b/packages/agent-core-v2/test/program/program.test.ts index 0db0ad74b0e..1414585666b 100644 --- a/packages/agent-core-v2/test/program/program.test.ts +++ b/packages/agent-core-v2/test/program/program.test.ts @@ -353,6 +353,7 @@ function scopedFs(base: string, realBase: string, inner: IHostFileSystem, additi stat: (path) => { assert(path); return inner.stat(path); }, lstat: inner.lstat.bind(inner), readdir: (path) => { assert(path); return inner.readdir(path); }, + walk: (path, options) => { assert(path); return inner.walk(path, options); }, mkdir: (path, options) => { assert(path); return inner.mkdir(path, options); }, remove: (path) => { assert(path); return inner.remove(path); }, realpath: inner.realpath.bind(inner), diff --git a/packages/agent-core-v2/test/remote/fs.test.ts b/packages/agent-core-v2/test/remote/fs.test.ts index 1a671165b2d..9d29a74f03a 100644 --- a/packages/agent-core-v2/test/remote/fs.test.ts +++ b/packages/agent-core-v2/test/remote/fs.test.ts @@ -1,18 +1,29 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { HostFsError } from '#/os/interface/hostFsErrors'; +import { HOST_WALK_MAX_IGNORE_BYTES } from '#/os/interface/hostFsWalk'; import { RemoteExecConnection } from '#/remote/client/connection'; import { RemoteFileSystem } from '#/os/backends/remote/remoteFileSystem'; -import { FS_READ_DIRECTORY_MAX_ENTRIES, FS_READ_FILE_WHOLE_MAX_BYTES } from '#/remote/protocol/methods'; import { + FS_READ_DIRECTORY_MAX_ENTRIES, + FS_READ_DIRECTORY_METHOD, + FS_READ_FILE_WHOLE_MAX_BYTES, + FS_WALK_METHOD, +} from '#/remote/protocol/methods'; +import { RpcError, RpcErrorCode } from '#/remote/protocol/errors'; +import { + connectInProcess, connectSubprocess, createInProcessLoopback, + createScriptedServer, RawClient, + testInitializeResult, + type ScriptedFrame, type SpawnedExecutor, } from './helpers/loopback'; @@ -126,6 +137,162 @@ describe('fs group over a subprocess loopback', () => { }); describe('fs protocol semantics', () => { + it('walks nested directories in one rpc and prunes ignored subtrees', async () => { + const dir = await mkdtemp(join(tmpdir(), 'remote-exec-walk-')); + const { connection, loopback } = await connectInProcess(); + const methods: string[] = []; + loopback.clientInput.on('data', (chunk: Buffer) => { + for (const line of chunk.toString('utf8').trim().split('\n')) { + if (line.length > 0) methods.push((JSON.parse(line) as { method?: string }).method ?? ''); + } + }); + try { + await mkdir(join(dir, 'src/nested'), { recursive: true }); + await mkdir(join(dir, 'node_modules/pkg'), { recursive: true }); + await writeFile(join(dir, 'src/nested/app.ts'), ''); + await writeFile(join(dir, 'node_modules/pkg/index.ts'), ''); + const fs = new RemoteFileSystem(connection); + + const result = await fs.walk(dir, { + maxDepth: 64, + maxDirectories: 10_000, + maxEntries: 100_000, + maxResponseBytes: 8 * 1024 * 1024, + ignore: ['node_modules/'], + }); + + expect(result.entries.map((entry) => entry.name.slice(dir.length + 1)).toSorted()).toEqual([ + 'src', + 'src/nested', + 'src/nested/app.ts', + ]); + expect(result.errors).toEqual([]); + expect(result.truncated).toBe(false); + expect(methods.filter((method) => method === FS_WALK_METHOD)).toHaveLength(1); + expect(methods.filter((method) => method === 'fs/readDirectory')).toHaveLength(0); + } finally { + connection.close(); + await loopback.host.done; + await rm(dir, { recursive: true, force: true }); + } + }); + + it('falls back once for old executors and caches their walk capability', async () => { + const seen: ScriptedFrame[] = []; + const pipe = createScriptedServer((frame, reply) => { + seen.push(frame); + if (frame.method === 'initialize') { + reply({ id: frame.id, result: testInitializeResult() }); + return; + } + if (frame.method === FS_WALK_METHOD) { + reply({ + id: frame.id, + error: new RpcError(RpcErrorCode.MethodNotFound, 'unknown method fs/walk').toErrorBody(), + }); + return; + } + if (frame.method === FS_READ_DIRECTORY_METHOD) { + const path = (frame.params as { path: string }).path; + reply({ + id: frame.id, + result: { + entries: path === '/root' + ? [{ fileName: 'src', isDirectory: true, isFile: false, isSymlink: false }] + : [{ fileName: 'app.ts', isDirectory: false, isFile: true, isSymlink: false }], + truncated: false, + }, + }); + } + }); + const connection = await RemoteExecConnection.connect(pipe, { + clientName: 'remote-exec-test', + clientVersion: '0.0.0', + }); + try { + const fs = new RemoteFileSystem(connection); + const options = { + maxDepth: 64, + maxDirectories: 10_000, + maxEntries: 100_000, + maxResponseBytes: 8 * 1024 * 1024, + }; + + const first = await fs.walk('/root', options); + const second = await fs.walk('/root', options); + const third = await fs.walk('/root/child', options); + + expect(first.entries.map((entry) => entry.name)).toEqual(['/root/src', '/root/src/app.ts']); + expect(second.entries).toEqual(first.entries); + expect(third.entries).toEqual([ + { name: '/root/child/app.ts', isDirectory: false, isFile: true, isSymbolicLink: false }, + ]); + expect(seen.filter((frame) => frame.method === FS_WALK_METHOD)).toHaveLength(1); + expect(seen.filter((frame) => frame.method === FS_READ_DIRECTORY_METHOD)).toHaveLength(5); + } finally { + connection.close(); + } + }); + + it('falls back locally for oversized ignore rules without pruning them', async () => { + const dir = await mkdtemp(join(tmpdir(), 'remote-exec-walk-ignore-cap-')); + const { connection, loopback } = await connectInProcess(); + const methods: string[] = []; + loopback.clientInput.on('data', (chunk: Buffer) => { + for (const line of chunk.toString('utf8').trim().split('\n')) { + if (line.length > 0) methods.push((JSON.parse(line) as { method?: string }).method ?? ''); + } + }); + try { + await mkdir(join(dir, 'src')); + await mkdir(join(dir, 'ignored')); + await writeFile(join(dir, 'src/app.ts'), ''); + await writeFile(join(dir, 'ignored/app.ts'), ''); + const fs = new RemoteFileSystem(connection); + const unusedRule = `unused-${'x'.repeat(8 * 1024)}`; + const unusedRuleCount = Math.ceil(HOST_WALK_MAX_IGNORE_BYTES / Buffer.byteLength(JSON.stringify(unusedRule))); + + const result = await fs.walk(dir, { + maxDepth: 64, + maxDirectories: 10_000, + maxEntries: 100_000, + maxResponseBytes: 8 * 1024 * 1024, + ignore: ['ignored/', ...Array.from({ length: unusedRuleCount }, () => unusedRule)], + }); + + expect(result.entries.map((entry) => entry.name.slice(dir.length + 1)).toSorted()).toEqual([ + 'src', + 'src/app.ts', + ]); + expect(result.truncated).toBe(false); + expect(methods.filter((method) => method === FS_WALK_METHOD)).toHaveLength(0); + expect(methods.filter((method) => method === FS_READ_DIRECTORY_METHOD)).toHaveLength(2); + } finally { + connection.close(); + await loopback.host.done; + await rm(dir, { recursive: true, force: true }); + } + }); + + it('rejects oversized ignore rules on the wire', async () => { + const loopback = createInProcessLoopback(); + const raw = new RawClient(loopback); + await raw.handshake(); + raw.send({ + id: 1, + method: FS_WALK_METHOD, + params: { + path: '/root', + ignore: ['x'.repeat(HOST_WALK_MAX_IGNORE_BYTES)], + }, + }); + + const response = await raw.nextResponse(1); + expect(response['error']).toMatchObject({ code: RpcErrorCode.InvalidParams }); + loopback.clientInput.end(); + await loopback.host.done; + }); + it('reports capped directory listings on the wire and rejects them through the filesystem interface', async () => { const dir = await mkdtemp(join(tmpdir(), 'remote-exec-readdir-cap-')); const smallDir = await mkdtemp(join(tmpdir(), 'remote-exec-readdir-small-')); diff --git a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts index f8efa4c47d3..10ad7e060a3 100644 --- a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts +++ b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts @@ -28,6 +28,7 @@ export function createFakeHostFs(overrides: Partial = {}): IHos stat: () => notImplemented('FakeHostFs.stat'), lstat: () => notImplemented('FakeHostFs.lstat'), readdir: () => notImplemented('FakeHostFs.readdir'), + walk: () => notImplemented('FakeHostFs.walk'), mkdir: () => notImplemented('FakeHostFs.mkdir'), remove: () => notImplemented('FakeHostFs.remove'), realpath: () => notImplemented('FakeHostFs.realpath'), diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index 35a0132b743..188e007f852 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -13,6 +13,7 @@ import { IGitService } from '#/app/git/git'; import { ErrorCodes, Error2 } from '#/errors'; import { type HostDirEntry, IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFsError } from '#/os/interface/hostFsErrors'; +import { walkHostDirectory } from '#/os/interface/hostFsWalk'; import { IHostProcessService, type IHostProcess } from '#/os/interface/hostProcess'; import { IWorkspaceFsService } from '#/workspace/workspaceFs/fs'; import { WorkspaceFsService } from '#/workspace/workspaceFs/fsService'; @@ -151,7 +152,7 @@ function fakeFs( } throw enoent(p); }; - return { + const fs: IHostFileSystem = { _serviceBrand: undefined, readText: async (p) => { const c = fileMap.get(resolveSymlinks(p)); @@ -207,6 +208,7 @@ function fakeFs( for (const s of symlinkSet) visit(s, 'symlink'); return [...children.values()]; }, + walk: (path, options) => walkHostDirectory(fs, path, options), mkdir: async (p, options) => { const recursive = options?.recursive ?? false; const exists = isDir(p) || fileMap.has(p); @@ -259,6 +261,7 @@ function fakeFs( return current; }, }; + return fs; } function fakeProcess(stdout: string, stderr: string, exitCode: number): IHostProcess { @@ -566,6 +569,26 @@ describe('WorkspaceFsService.search', () => { expect(paths.some((p) => p.startsWith('src/link/'))).toBe(false); }); + it('keeps the original deep-directory boundary', async () => { + const files: Record = {}; + let current = 'root'; + files[`${current}/match.ts`] = ''; + for (let depth = 1; depth <= 66; depth += 1) { + current = `${current}/level-${depth}`; + if (depth === 63 || depth === 64 || depth === 65) files[`${current}/match.ts`] = ''; + } + const fs = makeSession(files, emptyHandler); + const deepPath = Array.from({ length: 63 }, (_, index) => `level-${index + 1}`).join('/'); + + const result = await fs.search({ query: 'match', limit: 50, follow_gitignore: false }); + + expect(result.items.map((item) => item.path).toSorted()).toEqual([ + 'root/match.ts', + `root/${deepPath}/match.ts`, + ].toSorted()); + expect(result.items.some((item) => item.path.includes('level-64/'))).toBe(false); + }); + it('lists the workspace root top-level entries when the query is empty', async () => { const fs = makeSession( { 'src/foo.ts': '', 'src/nested/deep.ts': '', 'README.md': '', '.hidden.ts': '' }, @@ -954,6 +977,79 @@ describe('WorkspaceFsService.suggest', () => { expect(on.items.map((i) => i.path)).toContain('.secret.ts'); }); + it('passes ignore rules to the walk and reports inventory truncation', async () => { + const backend = fakeFs({ + '.gitignore': 'node_modules/\n', + 'src/app.ts': '', + 'node_modules/pkg/index.ts': '', + }); + let receivedIgnore: readonly string[] | undefined; + backend.walk = async (_path, options) => { + receivedIgnore = options.ignore; + return { entries: [], errors: [], truncated: true, responseBytes: 64 }; + }; + const fs = makeRemoteSession( + { '.gitignore': 'node_modules/\n' }, + () => ({ stdout: '', exitCode: 127 }), + [], + 'ssh-dev', + '/home/target', + backend, + ); + + const result = await fs.suggest({ + query: 'app', + limit: 50, + show_hidden: false, + follow_gitignore: true, + }); + + expect(receivedIgnore).toEqual(['.git/', 'node_modules/', '']); + expect(result).toEqual({ items: [], truncated: true }); + }); + + it('keeps matching and file enumeration order when using batched walks', async () => { + const backend = fakeFs({ + '.git/config': 'hello\n', + 'a/first.ts': 'hello\n', + 'a/nested/second.ts': 'hello\n', + 'z.ts': 'hello\n', + }); + const readdir = backend.readdir.bind(backend); + const reads: string[] = []; + backend.readdir = async (path) => { + reads.push(path); + return readdir(path); + }; + const fs = makeRemoteSession( + {}, + () => ({ stdout: '', exitCode: 127 }), + [], + 'ssh-dev', + '/home/target', + backend, + ); + + const result = await fs.grep({ + pattern: 'hello', + regex: false, + case_sensitive: true, + follow_gitignore: false, + max_files: 200, + max_matches_per_file: 50, + max_total_matches: 5000, + context_lines: 0, + }); + + expect(result.files.map((file) => file.path)).toEqual([ + 'a/nested/second.ts', + 'a/first.ts', + 'z.ts', + ]); + expect(result.files.some((file) => file.path.startsWith('.git/'))).toBe(false); + expect(reads).toEqual(['/repo', '/repo/a', '/repo/a/nested']); + }); + it('falls back to the node walk when rg fails to spawn', async () => { const events: Array<{ event: string; properties: Record }> = []; const runner: IHostProcessService = { From 7d995fab9d01b2dd972b93ee386fcb4c01930356 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 30 Oct 2026 11:42:58 +0800 Subject: [PATCH 2/4] test: add walk to mapped filesystem stub --- packages/kap-server/test/workspaceFs.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kap-server/test/workspaceFs.test.ts b/packages/kap-server/test/workspaceFs.test.ts index 047201befef..061bc190aed 100644 --- a/packages/kap-server/test/workspaceFs.test.ts +++ b/packages/kap-server/test/workspaceFs.test.ts @@ -463,6 +463,7 @@ function mappingHostFs(remoteRoot: string): IHostFileSystem { stat: (path) => inner.stat(map(path)), lstat: (path) => inner.lstat(map(path)), readdir: (path) => inner.readdir(map(path)), + walk: (path, options) => inner.walk(map(path), options), mkdir: (path, options) => inner.mkdir(map(path), options), remove: (path) => inner.remove(map(path)), realpath: async (path) => unmap(await inner.realpath(map(path))), From 4beb5483153c4132f7aeb198b0b083e0a9d309a7 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 30 Oct 2026 11:52:41 +0800 Subject: [PATCH 3/4] fix: bound walks below the RPC deadline --- .../src/os/interface/hostFsWalk.ts | 7 ++--- packages/agent-core-v2/src/remote/README.md | 3 +- .../src/remote/server/fsHandler.ts | 3 +- .../backends/node-local/hostFsService.test.ts | 31 +++++++++++++++++-- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/agent-core-v2/src/os/interface/hostFsWalk.ts b/packages/agent-core-v2/src/os/interface/hostFsWalk.ts index 29b1c077503..323a5658326 100644 --- a/packages/agent-core-v2/src/os/interface/hostFsWalk.ts +++ b/packages/agent-core-v2/src/os/interface/hostFsWalk.ts @@ -10,6 +10,7 @@ export const HOST_WALK_MAX_DIRECTORIES = 10_000; export const HOST_WALK_MAX_ENTRIES = 100_000; export const HOST_WALK_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; export const HOST_WALK_MAX_IGNORE_BYTES = 1024 * 1024; +export const HOST_WALK_DEFAULT_TIMEOUT_MS = 55_000; export const HOST_WALK_MAX_TIMEOUT_MS = 60_000; export const HOST_WALK_MAX_DIRECTORY_ENTRIES = 50_000; @@ -48,12 +49,10 @@ export async function walkHostDirectory( message: error.message, })); const pathApi = hooks.path ?? { join, relative, separator: sep }; - const timeout = options.timeoutMs === undefined ? undefined : AbortSignal.timeout(options.timeoutMs); + const timeout = AbortSignal.timeout(options.timeoutMs ?? HOST_WALK_DEFAULT_TIMEOUT_MS); const signal = options.signal === undefined ? timeout - : timeout === undefined - ? options.signal - : AbortSignal.any([options.signal, timeout]); + : AbortSignal.any([options.signal, timeout]); const entries: HostDirEntry[] = []; const errors: { path: string; error: Error }[] = []; const pending = [{ path: root, depth: 0 }]; diff --git a/packages/agent-core-v2/src/remote/README.md b/packages/agent-core-v2/src/remote/README.md index d03d798dc28..c8edba47120 100644 --- a/packages/agent-core-v2/src/remote/README.md +++ b/packages/agent-core-v2/src/remote/README.md @@ -71,7 +71,8 @@ deviations per the design spec, plus one forced addition: root ignore rules needed to prune ignored directories before traversal and reports entries, recoverable directory errors, and truncation. Clients fall back to per-directory reads when an older executor does not implement it or - when the JSON-encoded ignore rules exceed 1MiB. + when the JSON-encoded ignore rules exceed 1MiB. Walks default to a 55-second + budget so they can return a truncated result before the 60-second RPC deadline. - Added `fs/rename` (no codex counterpart). - `fs/remove` takes only `path` and always removes recursively and ignores a missing path, matching `IHostFileSystem.remove` (codex has `recursive`/`force`). diff --git a/packages/agent-core-v2/src/remote/server/fsHandler.ts b/packages/agent-core-v2/src/remote/server/fsHandler.ts index 7bd3b9c080c..058a4f515ed 100644 --- a/packages/agent-core-v2/src/remote/server/fsHandler.ts +++ b/packages/agent-core-v2/src/remote/server/fsHandler.ts @@ -15,6 +15,7 @@ import { type FsWalkResult, } from '#/remote/protocol/methods'; import { + HOST_WALK_DEFAULT_TIMEOUT_MS, HOST_WALK_MAX_DEPTH, HOST_WALK_MAX_DIRECTORIES, HOST_WALK_MAX_ENTRIES, @@ -229,7 +230,7 @@ export class FsHandler { `ignore must not exceed ${HOST_WALK_MAX_IGNORE_BYTES} bytes when encoded as JSON`, ); } - const timeoutMs = optionalInteger(params, 'timeoutMs', 1, HOST_WALK_MAX_TIMEOUT_MS); + const timeoutMs = optionalInteger(params, 'timeoutMs', 1, HOST_WALK_MAX_TIMEOUT_MS) ?? HOST_WALK_DEFAULT_TIMEOUT_MS; const result = await walkHostDirectory(this.fs, path, { maxDepth, maxDirectories, diff --git a/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts index 7f60fff54aa..d459461ff6a 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts @@ -2,10 +2,10 @@ import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os'; import { join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; -import { walkHostDirectory } from '#/os/interface/hostFsWalk'; +import { HOST_WALK_DEFAULT_TIMEOUT_MS, walkHostDirectory } from '#/os/interface/hostFsWalk'; let dir: string; let fs: HostFileSystem; @@ -230,6 +230,33 @@ describe('HostFileSystem walk', () => { expect(afterTimeout.entries.map((entry) => entry.name)).toEqual(['/root/a']); expect(afterTimeout.truncated).toBe(true); }); + + it('uses the default timeout below the RPC deadline', async () => { + const controller = new AbortController(); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockImplementation((delay) => { + expect(delay).toBe(HOST_WALK_DEFAULT_TIMEOUT_MS); + return controller.signal; + }); + try { + const backend = { + readdir: async (path: string) => { + if (path === '/root') { + controller.abort(); + return [{ name: 'a', isFile: false, isDirectory: true }]; + } + return [{ name: 'file.ts', isFile: true, isDirectory: false }]; + }, + }; + + await expect(walkHostDirectory(backend, '/root', options)).resolves.toMatchObject({ + entries: [], + truncated: true, + }); + expect(timeout).toHaveBeenCalledOnce(); + } finally { + timeout.mockRestore(); + } + }); }); describe('HostFileSystem stat / lstat', () => { From 76f441c10dff8ce50ab86939b5348e71905ca8a0 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 30 Oct 2026 12:03:45 +0800 Subject: [PATCH 4/4] fix: keep remote walks on caller deadlines --- .../os/backends/remote/remoteFileSystem.ts | 29 ++++- .../src/os/interface/hostFsWalk.ts | 7 ++ .../src/workspace/workspaceFs/fsService.ts | 18 ++- packages/agent-core-v2/test/remote/fs.test.ts | 106 ++++++++++++++++++ 4 files changed, 155 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/os/backends/remote/remoteFileSystem.ts b/packages/agent-core-v2/src/os/backends/remote/remoteFileSystem.ts index 9a9855c28af..f4a8dc5700f 100644 --- a/packages/agent-core-v2/src/os/backends/remote/remoteFileSystem.ts +++ b/packages/agent-core-v2/src/os/backends/remote/remoteFileSystem.ts @@ -13,7 +13,7 @@ import type { HostWalkResult, IHostFileSystem, } from '#/os/interface/hostFileSystem'; -import { HOST_WALK_MAX_IGNORE_BYTES, walkHostDirectory } from '#/os/interface/hostFsWalk'; +import { EMPTY_HOST_WALK_RESULT, HOST_WALK_MAX_IGNORE_BYTES, walkHostDirectory } from '#/os/interface/hostFsWalk'; import { HostFsError, OsFsErrors, @@ -246,12 +246,13 @@ export class RemoteFileSystem implements IHostFileSystem { } async walk(path: string, options: HostWalkOptions): Promise { + if (options.signal?.aborted === true) return EMPTY_HOST_WALK_RESULT; if (this.walkSupported === false) return this.walkFallback(path, options); if (options.ignore !== undefined && Buffer.byteLength(JSON.stringify(options.ignore)) > HOST_WALK_MAX_IGNORE_BYTES) { return this.walkFallback(path, options); } try { - const result = await this.connection.call(FS_WALK_METHOD, { + const call = this.connection.call(FS_WALK_METHOD, { path, maxDepth: options.maxDepth, maxDirectories: options.maxDirectories, @@ -259,7 +260,9 @@ export class RemoteFileSystem implements IHostFileSystem { maxResponseBytes: options.maxResponseBytes, ignore: options.ignore, timeoutMs: options.timeoutMs, - }) as FsWalkResult; + }) as Promise; + const result = options.signal === undefined ? await call : await this.raceWalkAbort(call, options.signal); + if (result === undefined) return EMPTY_HOST_WALK_RESULT; this.walkSupported = true; return { entries: result.entries, @@ -283,6 +286,26 @@ export class RemoteFileSystem implements IHostFileSystem { return walkHostDirectory(this, path, options, { path: REMOTE_WALK_PATH }); } + private async raceWalkAbort( + call: Promise, + signal: AbortSignal, + ): Promise { + let onAbort: () => void; + const aborted = new Promise((resolve) => { + onAbort = () => resolve(undefined); + signal.addEventListener('abort', onAbort, { once: true }); + }); + try { + return await Promise.race([call, aborted]); + } catch (error) { + if (signal.aborted) return undefined; + throw error; + } finally { + signal.removeEventListener('abort', onAbort!); + call.catch(() => {}); + } + } + async mkdir(path: string, options?: { readonly recursive?: boolean }): Promise { await this.call(FS_CREATE_DIRECTORY_METHOD, { path, diff --git a/packages/agent-core-v2/src/os/interface/hostFsWalk.ts b/packages/agent-core-v2/src/os/interface/hostFsWalk.ts index 323a5658326..86e048847df 100644 --- a/packages/agent-core-v2/src/os/interface/hostFsWalk.ts +++ b/packages/agent-core-v2/src/os/interface/hostFsWalk.ts @@ -14,6 +14,13 @@ export const HOST_WALK_DEFAULT_TIMEOUT_MS = 55_000; export const HOST_WALK_MAX_TIMEOUT_MS = 60_000; export const HOST_WALK_MAX_DIRECTORY_ENTRIES = 50_000; +export const EMPTY_HOST_WALK_RESULT: HostWalkResult = { + entries: [], + errors: [], + truncated: true, + responseBytes: 0, +}; + interface HostWalkerFs { readdir(path: string): Promise; } diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index e940cf7264f..3b36804c3b9 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -1028,9 +1028,16 @@ export class WorkspaceFsService implements IWorkspaceFsService { const seenPaths = new Set(); let matched = 0; let inventoryTruncated = false; + const deadline = Date.now() + SUGGEST_TIMEOUT_MS; try { for (const root of roots) { + if (signal.aborted) break; const ignoreRules = query.followGitignore ? await this.walkIgnoreFor(root.dir) : WALK_DEFAULT_IGNORE; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + inventoryTruncated = true; + break; + } inventoryTruncated = await this.walk(root.dir, ignoreRules, async (relPath, _name, kind) => { if (signal.aborted) throw SUGGEST_WALK_ABORTED; if (multi) { @@ -1042,7 +1049,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { if (candidate === null) return; matched += 1; top.push(this.displayCandidate(root, candidate)); - }, { signal, timeoutMs: SUGGEST_TIMEOUT_MS }) || inventoryTruncated; + }, { signal, timeoutMs: Math.max(1, remainingMs) }) || inventoryTruncated; } } catch (error) { if (error !== SUGGEST_WALK_ABORTED) throw error; @@ -1191,8 +1198,15 @@ export class WorkspaceFsService implements IWorkspaceFsService { const filePaths: { path: string; absolute: string }[] = []; const seenPaths = new Set(); const roots = await this.suggestRoots(); + const deadline = startedAt + GREP_TIMEOUT_MS; for (const root of roots) { + if (signal.aborted) break; const ignoreRules = req.follow_gitignore ? await this.walkIgnoreFor(root.dir) : WALK_DEFAULT_IGNORE; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + truncated = true; + break; + } truncated = await this.walk(root.dir, ignoreRules, async (rel, _name, kind) => { if (kind !== 'file') return; if (req.include_globs && !matchesAnyGlob(rel, req.include_globs)) return; @@ -1204,7 +1218,7 @@ export class WorkspaceFsService implements IWorkspaceFsService { path: this.suggestDisplayPath(root, rel), absolute: this.path.join(root.dir, rel), }); - }, { signal, timeoutMs: GREP_TIMEOUT_MS }) || truncated; + }, { signal, timeoutMs: Math.max(1, remainingMs) }) || truncated; } for (const file of filePaths) { diff --git a/packages/agent-core-v2/test/remote/fs.test.ts b/packages/agent-core-v2/test/remote/fs.test.ts index 9d29a74f03a..aec0c69e809 100644 --- a/packages/agent-core-v2/test/remote/fs.test.ts +++ b/packages/agent-core-v2/test/remote/fs.test.ts @@ -137,6 +137,112 @@ describe('fs group over a subprocess loopback', () => { }); describe('fs protocol semantics', () => { + it('short-circuits aborted walks without sending an RPC', async () => { + const calls: string[] = []; + const fs = new RemoteFileSystem({ + call: async (method) => { + calls.push(method); + throw new Error('unexpected call'); + }, + notify: () => {}, + onNotification: () => () => {}, + onDidClose: () => {}, + }); + + const result = await fs.walk('/root', { + maxDepth: 64, + maxDirectories: 10_000, + maxEntries: 100_000, + maxResponseBytes: 8 * 1024 * 1024, + signal: AbortSignal.abort(), + }); + + expect(result).toEqual({ entries: [], errors: [], truncated: true, responseBytes: 0 }); + expect(calls).toEqual([]); + }); + + it('returns a truncated walk when the caller aborts during the RPC', async () => { + const controller = new AbortController(); + let finish!: (result: unknown) => void; + const fs = new RemoteFileSystem({ + call: () => new Promise((resolve) => { + finish = resolve; + }), + notify: () => {}, + onNotification: () => () => {}, + onDidClose: () => {}, + }); + + const walking = fs.walk('/root', { + maxDepth: 64, + maxDirectories: 10_000, + maxEntries: 100_000, + maxResponseBytes: 8 * 1024 * 1024, + signal: controller.signal, + }); + controller.abort(); + + await expect(walking).resolves.toEqual({ + entries: [], + errors: [], + truncated: true, + responseBytes: 0, + }); + finish({ entries: [], errors: [], truncated: false, responseBytes: 64 }); + }); + + it('falls back to bounded directory reads after an old executor aborts a walk probe', async () => { + const controller = new AbortController(); + const seen: ScriptedFrame[] = []; + const pipe = createScriptedServer((frame, reply) => { + seen.push(frame); + if (frame.method === 'initialize') { + reply({ id: frame.id, result: testInitializeResult() }); + return; + } + if (frame.method === FS_WALK_METHOD) { + reply({ + id: frame.id, + error: new RpcError(RpcErrorCode.MethodNotFound, 'unknown method fs/walk').toErrorBody(), + }); + controller.abort(); + return; + } + if (frame.method === FS_READ_DIRECTORY_METHOD) { + const path = (frame.params as { path: string }).path; + reply({ + id: frame.id, + result: { + entries: path === '/root' + ? [{ fileName: 'src', isDirectory: true, isFile: false, isSymlink: false }] + : [{ fileName: 'app.ts', isDirectory: false, isFile: true, isSymlink: false }], + truncated: false, + }, + }); + } + }); + const connection = await RemoteExecConnection.connect(pipe, { + clientName: 'remote-exec-test', + clientVersion: '0.0.0', + }); + try { + const fs = new RemoteFileSystem(connection); + const result = await fs.walk('/root', { + maxDepth: 64, + maxDirectories: 10_000, + maxEntries: 100_000, + maxResponseBytes: 8 * 1024 * 1024, + signal: controller.signal, + }); + + expect(result.entries).toEqual([]); + expect(result.truncated).toBe(true); + expect(seen.filter((frame) => frame.method === FS_READ_DIRECTORY_METHOD)).toEqual([]); + } finally { + connection.close(); + } + }); + it('walks nested directories in one rpc and prunes ignored subtrees', async () => { const dir = await mkdtemp(join(tmpdir(), 'remote-exec-walk-')); const { connection, loopback } = await connectInProcess();