feat(omp): list ssh:// directories via read

- `read ssh://host/dir` lists a remote directory one level deep; `ssh://host/` lists the remote root
- add statRemotePath + listRemoteDir; resolve reads first and classifies on error (directory -> one-level listing, dirs-first, dotfiles included)
- directory resources carry isDirectory + immutable and expose no sourcePath
- search refuses a virtual (no-sourcePath) directory resource instead of grepping the listing text
- writeRemoteFile refuses a directory destination and cleans up its temp on that path
This commit is contained in:
Tommaso Fontana
2026-06-26 20:41:35 +02:00
parent f9ece90853
commit e4ceebb258
10 changed files with 557 additions and 44 deletions
@@ -69,10 +69,15 @@ describe("SshProtocolHandler", () => {
expect(spy.mock.calls[0]?.[0]).toMatchObject({ name: "bob@h1:2222", host: "h1", username: "bob", port: 2222 });
});
it("rejects a host-only URL with no file path", async () => {
it("lists the remote root directory for ssh://host/", async () => {
mockHosts();
mockReadBytes("x");
await expect(handler.resolve(parseInternalUrl("ssh://icaro/"))).rejects.toThrow(/absolute file path/);
vi.spyOn(fileTransfer, "readRemoteFile").mockRejectedValue(new Error("Is a directory"));
vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("directory");
const listSpy = vi.spyOn(fileTransfer, "listRemoteDir").mockResolvedValue([{ name: "etc", isDirectory: true }]);
const res = await handler.resolve(parseInternalUrl("ssh://icaro/"));
expect(res.isDirectory).toBe(true);
expect(res.content).toBe("etc/");
expect(listSpy.mock.calls[0]?.[1]).toBe("/");
});
it("rejects a binary / non-UTF-8 file instead of returning a resource", async () => {
@@ -111,4 +116,42 @@ describe("SshProtocolHandler", () => {
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0]?.[2]).toEqual(new TextEncoder().encode("hi\n\t!\n"));
});
it("lists a remote directory when the path is not a readable file", async () => {
mockHosts();
vi.spyOn(fileTransfer, "readRemoteFile").mockRejectedValue(
new Error("head: error reading '/etc': Is a directory"),
);
vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("directory");
const listSpy = vi.spyOn(fileTransfer, "listRemoteDir").mockResolvedValue([
{ name: "conf.d", isDirectory: true },
{ name: "hosts", isDirectory: false },
]);
const res = await handler.resolve(parseInternalUrl("ssh://icaro/etc"));
expect(res.isDirectory).toBe(true);
expect(res.immutable).toBe(true);
expect(res.sourcePath).toBeUndefined();
expect(res.content).toBe("conf.d/\nhosts");
// read fail → stat → list must target the same remote path, not a peeled/normalized variant.
expect(listSpy.mock.calls[0]?.[1]).toBe("/etc");
});
it("renders an empty remote directory", async () => {
mockHosts();
vi.spyOn(fileTransfer, "readRemoteFile").mockRejectedValue(new Error("Is a directory"));
vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("directory");
vi.spyOn(fileTransfer, "listRemoteDir").mockResolvedValue([]);
const res = await handler.resolve(parseInternalUrl("ssh://icaro/empty"));
expect(res.content).toBe("(empty directory)");
expect(res.isDirectory).toBe(true);
});
it("rethrows the original read error when the path is missing, not a directory", async () => {
mockHosts();
vi.spyOn(fileTransfer, "readRemoteFile").mockRejectedValue(
new Error("head: cannot open '/nope': No such file or directory"),
);
vi.spyOn(fileTransfer, "statRemotePath").mockResolvedValue("missing");
await expect(handler.resolve(parseInternalUrl("ssh://icaro/nope"))).rejects.toThrow(/No such file or directory/);
});
});
@@ -1,15 +1,16 @@
/**
* Protocol handler for `ssh://host/path` URLs.
*
* Resolves a single remote text file on a pre-configured SSH host — or any
* Resolves a remote text file or directory listing on a pre-configured SSH host — or any
* destination OpenSSH can resolve itself (e.g. a `~/.ssh/config` alias) — for
* the read, search, and write tools, reusing the shared ControlMaster
* connection in `../ssh/connection-manager`.
*
* Text only. Binary/non-UTF-8 files and files larger than 1 MiB are rejected
* with an explicit error rather than returned as a resource: this handler
* exposes no `sourcePath`, so a note-style resource would make `search` grep the
* note text and report "No matches found", hiding the real condition.
* A remote path resolves to a UTF-8 text file (≤ 1 MiB) or, when it is a
* directory, a one-level listing. Binary/non-UTF-8 or oversized files are
* rejected with an explicit error. This handler exposes no `sourcePath`;
* directory listings carry `isDirectory` so `search` refuses to grep the
* listing text instead of the directory's real contents.
*
* `loadCapability` is imported from `../capability` (not the `../discovery`
* barrel) on purpose — pulling the barrel here would route
@@ -21,7 +22,14 @@
import * as capability from "../capability";
import { type SSHHost, sshCapability } from "../capability/ssh";
import type { SSHConnectionTarget } from "../ssh/connection-manager";
import { readRemoteFile, writeRemoteFile } from "../ssh/file-transfer";
import {
listRemoteDir,
type RemoteDirEntry,
type RemotePathKind,
readRemoteFile,
statRemotePath,
writeRemoteFile,
} from "../ssh/file-transfer";
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, WriteContext } from "./types";
/** Largest remote text file `ssh://` will materialize (mirrors the local:// cap). */
@@ -62,8 +70,10 @@ function remotePathFromUrl(url: InternalUrl): string {
} catch {
throw new Error(`Invalid URL encoding in ssh:// path: ${url.href}`);
}
if (!decoded || decoded === "/") {
throw new Error("ssh:// requires an absolute file path, e.g. ssh://host/etc/hosts");
if (!decoded) {
throw new Error(
"ssh:// requires an absolute path, e.g. ssh://host/etc/hosts or ssh://host/ for the root directory",
);
}
return decoded;
}
@@ -107,6 +117,12 @@ async function resolveTarget(url: InternalUrl, cwd?: string): Promise<SSHConnect
return { name, host, username, port };
}
/** Format a one-level remote directory listing — mirrors buildDirectoryResource's plain `name/` lines. */
function formatDirListing(entries: readonly RemoteDirEntry[]): string {
if (entries.length === 0) return "(empty directory)";
return entries.map(entry => `${entry.name}${entry.isDirectory ? "/" : ""}`).join("\n");
}
export class SshProtocolHandler implements ProtocolHandler {
readonly scheme = "ssh";
readonly immutable = false;
@@ -114,16 +130,31 @@ export class SshProtocolHandler implements ProtocolHandler {
async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
const target = await resolveTarget(url, context?.cwd);
const remotePath = remotePathFromUrl(url);
const { bytes, truncated } = await readRemoteFile(target, remotePath, {
maxBytes: SSH_TEXT_MAX_BYTES,
signal: context?.signal,
});
if (truncated) {
let fileResult: { bytes: Uint8Array; truncated: boolean };
try {
fileResult = await readRemoteFile(target, remotePath, {
maxBytes: SSH_TEXT_MAX_BYTES,
signal: context?.signal,
});
} catch (err) {
// `head` fails on a directory (and on a missing/unreadable path). Re-classify:
// only a real directory becomes a listing; anything else rethrows the original
// error, which carries the remote stderr (e.g. "No such file or directory").
let kind: RemotePathKind | undefined;
try {
kind = await statRemotePath(target, remotePath, { signal: context?.signal });
} catch {
// Re-stat failed too (host/connection issue) — the original read error is clearer.
}
if (kind === "directory") return this.#resolveDirectory(target, remotePath, url, context?.signal);
throw err;
}
if (fileResult.truncated) {
throw new Error(
`ssh://: ${remotePath} exceeds the 1 MiB limit; ssh:// supports text files up to 1 MiB — use an sshfs mount for larger files`,
);
}
const content = decodeUtf8Text(bytes);
const content = decodeUtf8Text(fileResult.bytes);
if (content === null) {
throw new Error(
`ssh://: ${remotePath} is a binary or non-UTF-8 file; ssh:// supports UTF-8 text only — use the ssh tool or an sshfs mount`,
@@ -135,7 +166,25 @@ export class SshProtocolHandler implements ProtocolHandler {
url: url.href,
content,
contentType: contentTypeFor(remotePath),
size: bytes.length,
size: fileResult.bytes.length,
};
}
/** Resolve a remote directory to a one-level listing (no `sourcePath`; `isDirectory` so search refuses it; immutable). */
async #resolveDirectory(
target: SSHConnectionTarget,
remotePath: string,
url: InternalUrl,
signal?: AbortSignal,
): Promise<InternalResource> {
const content = formatDirListing(await listRemoteDir(target, remotePath, { signal }));
return {
url: url.href,
content,
contentType: "text/plain",
size: Buffer.byteLength(content, "utf-8"),
immutable: true,
isDirectory: true,
};
}
@@ -33,6 +33,13 @@ export interface InternalResource {
* resources. Mutable resources (e.g. local://) behave like editable files.
*/
immutable?: boolean;
/**
* True when the resource is a directory listing rather than file content.
* `search` refuses to grep such a resource when it has no `sourcePath` — a
* remote `ssh://` listing has no local path to recurse, so its listing text
* must never be mistaken for the directory's contents.
*/
isDirectory?: boolean;
}
/**
@@ -69,7 +69,7 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
All URI schemes take the same line selectors. `artifact://<id>` recovers full output a bash/eval/tool result spilled or truncated. `history://<agentId>` = agent transcript; bare `history://` lists agents.
`ssh://host/<absolute-path>` reads a single text file on a pre-configured SSH host or `~/.ssh/config` alias (UTF-8 text only, ≤1 MiB; no binary/recursive/glob). Also writable via `write` and searchable via `search`. A literal `:` in the remote path must be percent-encoded as `%3A`, since a trailing `:sel` is read as a line selector.
`ssh://host/<absolute-path>` reads a remote text file (UTF-8, ≤1 MiB) or lists a directory one level deep, on a pre-configured SSH host or `~/.ssh/config` alias; `ssh://host/` lists the remote root. Files are also writable via `write` and searchable via `search`; a directory only lists (`search` refuses a directory, `write` refuses to overwrite one). A literal `:` in the remote path must be percent-encoded as `%3A`, since a trailing `:sel` is read as a line selector.
<critical>
- Line ranges go in the selector: `path="src/foo.ts:50-200"`.
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import type { SSHConnectionTarget } from "../connection-manager";
import * as connectionManager from "../connection-manager";
import { readRemoteFile, writeRemoteFile } from "../file-transfer";
describe("ssh file-transfer POSIX guard", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("rejects a confirmed Windows remote before running any POSIX command", async () => {
// Stub BOTH the connection and the host-info probe so the guard is reached
// without opening a real SSH connection and before any command is spawned.
const ensureConnectionSpy = vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
const ensureHostInfoSpy = vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
version: 2,
os: "windows",
shell: "powershell",
compatEnabled: false,
});
const target: SSHConnectionTarget = { name: "winbox", host: "winbox" };
await expect(readRemoteFile(target, "C:/x.txt", { maxBytes: 1024 })).rejects.toThrow(/Windows host/);
await expect(writeRemoteFile(target, "C:/x.txt", new Uint8Array([1]), {})).rejects.toThrow(/Windows host/);
// Prove the guard ran through the stubbed transport rather than failing early
// for an unrelated reason (e.g. a future import refactor bypassing the mocks).
expect(ensureConnectionSpy).toHaveBeenCalled();
expect(ensureHostInfoSpy).toHaveBeenCalled();
});
});
+120 -10
View File
@@ -7,12 +7,26 @@
* and final newlines are preserved.
*/
import { ptree } from "@oh-my-pi/pi-utils";
import { buildRemoteCommand, ensureConnection, type SSHConnectionTarget } from "./connection-manager";
import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
import { quotePosixPath } from "./utils";
/** Per-operation timeout for remote transfers (matches the ssh tool's grep window). */
const DEFAULT_TIMEOUT_MS = 30_000;
/**
* Ensure the ControlMaster connection and reject non-POSIX (Windows) remotes,
* which the POSIX `head`/`cat`/`mv`/`ls` commands in this module cannot drive.
*/
async function ensurePosixRemote(target: SSHConnectionTarget): Promise<void> {
await ensureConnection(target);
const info = await ensureHostInfo(target);
if (info.os === "windows") {
throw new Error(
`ssh://: ${target.name} is a Windows host; ssh:// supports POSIX remotes only (head/cat/mv) — use the ssh tool for Windows hosts`,
);
}
}
export interface RemoteFileReadOptions {
/** Maximum bytes to materialize; the helper fetches one extra byte to detect truncation. */
maxBytes: number;
@@ -44,7 +58,7 @@ export async function readRemoteFile(
remotePath: string,
opts: RemoteFileReadOptions,
): Promise<RemoteFileReadResult> {
await ensureConnection(target);
await ensurePosixRemote(target);
const command = `head -c ${opts.maxBytes + 1} ${quotePosixPath(remotePath)}`;
const args = await buildRemoteCommand(target, command);
using child = ptree.spawn(["ssh", ...args], {
@@ -58,12 +72,26 @@ export async function readRemoteFile(
}
/**
* Write `content` to a remote file byte-exact. Streams stdin into a uniquely
* named temp file in the destination directory, then atomically renames it into
* place so a partial transfer never clobbers the destination and concurrent
* writers cannot collide on the temp name. The rename REPLACES a symlink at the
* destination with a regular file rather than writing through it (resolving the
* link target is not portable across the macOS/Linux hosts this stack supports).
* Write `content` to a remote file byte-exact. Stdin is always staged first into
* a uniquely named temp in the destination directory (so the remote never blocks
* on an unread pipe and a dropped connection lands in the temp, never the
* destination). The destination then dictates the commit:
* - a directory — or a symlink to one, since the `-d` test follows links — is
* refused (a plain `mv tmp dir` would move the temp INTO it).
* - an existing non-symlink regular file is rewritten IN PLACE from the staged
* temp, preserving its inode and therefore its ordinary permission bits (a
* `0600` secret stays `0600` on overwrite), ACLs, xattrs, and hardlinks. The
* setuid/setgid bits may be cleared by the write (per POSIX). This commit is
* not fully atomic — a remote-side failure during the local temp->dest copy
* (e.g. the disk filling) can truncate the destination — but the slow network
* transfer has already landed in the temp, and the temp is removed on failure.
* It also needs write permission on the file itself (a read-only file is
* refused, not silently replaced).
* - an existing special file (FIFO/socket/device) is refused, not replaced.
* - anything else (a new path, a symlink to a non-directory, a dangling symlink)
* is committed with an atomic rename, which REPLACES a symlink with a regular
* file rather than writing through it (resolving the link target is not
* portable across the macOS/Linux hosts this stack supports).
* Throws `ptree.NonZeroExitError` when the remote path is unwritable or the host
* is unreachable.
*/
@@ -73,10 +101,27 @@ export async function writeRemoteFile(
content: Uint8Array,
opts: RemoteFileWriteOptions,
): Promise<void> {
await ensureConnection(target);
await ensurePosixRemote(target);
const dest = quotePosixPath(remotePath);
const tmp = quotePosixPath(`${remotePath}.omp-tmp.${crypto.randomUUID()}`);
const command = `cat > ${tmp} && mv ${tmp} ${dest}`;
// Stage stdin into the temp first (so the remote never blocks on an unread
// pipe and a dropped connection lands in the temp, never the destination).
// An EXIT trap removes the staged temp on every exit path (staging failure,
// in-place success, refuse branches, or a failed rename). Commit by
// destination kind: a directory (or symlink to one; `-d` follows links) is
// refused; an existing non-symlink regular file is rewritten IN PLACE
// (preserving inode, permission bits, ACLs, xattrs, hardlinks; setuid/setgid
// may clear); an existing special file (FIFO/socket/device) is refused;
// anything else (a new path or a symlink to a non-directory) uses temp+rename,
// replacing such a symlink rather than writing through it.
const command =
`t=${tmp}; trap 'rm -f -- "$t"' 0; ` +
`cat > "$t" && { ` +
`if [ -d ${dest} ]; then echo 'ssh://: destination is a directory' >&2; exit 1; ` +
`elif [ -f ${dest} ] && [ ! -L ${dest} ]; then cat "$t" > ${dest} || exit 1; ` +
`elif [ -e ${dest} ] && [ ! -L ${dest} ]; then echo 'ssh://: destination is a special file (not a regular file)' >&2; exit 1; ` +
`else mv "$t" ${dest}; fi; ` +
`}`;
const args = await buildRemoteCommand(target, command, { allowStdin: true });
using child = ptree.spawn(["ssh", ...args], {
stdin: content,
@@ -84,3 +129,68 @@ export async function writeRemoteFile(
});
await child.exitedCleanly;
}
/** Classification of a remote path, used by the read handler's directory dispatch. */
export type RemotePathKind = "file" | "directory" | "other" | "missing";
/**
* Classify a remote path with POSIX `test` (portable across Linux/BSD/macOS):
* `directory`, regular `file`, `other` (special file), or `missing`.
*/
export async function statRemotePath(
target: SSHConnectionTarget,
remotePath: string,
opts: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<RemotePathKind> {
await ensurePosixRemote(target);
const p = quotePosixPath(remotePath);
const command = `if [ -d ${p} ]; then echo directory; elif [ -f ${p} ]; then echo file; elif [ -e ${p} ]; then echo other; else echo missing; fi`;
const args = await buildRemoteCommand(target, command);
using child = ptree.spawn(["ssh", ...args], {
signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
const out = new TextDecoder().decode(await child.bytes()).trim();
await child.exitedCleanly;
return out === "directory" || out === "file" || out === "other" ? out : "missing";
}
/** A single entry in a remote directory listing. */
export interface RemoteDirEntry {
/** Entry name (no path component), trailing `/` stripped. */
name: string;
/** True when the entry is a directory. */
isDirectory: boolean;
}
/**
* List a remote directory one level deep with `ls -1Ap` (one per line; all
* entries incl. dotfiles but not `.`/`..`; trailing `/` marks directories).
* Plain `ls` (no `| head`) so a permission/race failure surfaces as a non-zero
* exit instead of being masked as an empty listing. Entries are returned in
* full, sorted directories-first then by name to mirror the local
* directory-resource contract, so the read tool can paginate the listing.
*/
export async function listRemoteDir(
target: SSHConnectionTarget,
remotePath: string,
opts: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<RemoteDirEntry[]> {
await ensurePosixRemote(target);
const command = `LC_ALL=C ls -1Ap -- ${quotePosixPath(remotePath)}`;
const args = await buildRemoteCommand(target, command);
using child = ptree.spawn(["ssh", ...args], {
signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
const text = new TextDecoder().decode(await child.bytes());
await child.exitedCleanly;
const entries = text
.split("\n")
.filter(line => line.length > 0)
.map(line => {
const isDirectory = line.endsWith("/");
return { name: isDirectory ? line.slice(0, -1) : line, isDirectory };
});
// JS sort is the order contract (mirrors buildDirectoryResource): dirs first, then by name.
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name));
return entries;
}
+101 -14
View File
@@ -123,6 +123,27 @@ interface SearchPathSpec {
ranges?: [LineRange, ...LineRange[]];
}
/**
* Mirror of read's `parseSel` selector grammar (`read.ts`) so `search` accepts
* exactly the internal-URL selectors `read`/`write` do: a single chunk that is a
* line range, `raw`, or `conflicts`; or a two-chunk compound of exactly one `raw`
* plus one line range. Everything else (`:-10`, `:1-1:1-2`, `:conflicts:1-1`,
* `:raw:conflicts`) is rejected. Keep in sync with read.parseSel.
*/
function isReadSelectorGrammar(sel: string): boolean {
if (sel.includes(":")) {
const chunks = sel.split(":");
if (chunks.length !== 2) return false;
const [a, b] = chunks as [string, string];
const aIsRaw = a.toLowerCase() === "raw";
const bIsRaw = b.toLowerCase() === "raw";
const rangeChunk = aIsRaw ? b : bIsRaw ? a : null;
return rangeChunk !== null && parseLineRanges(rangeChunk) !== null;
}
const lower = sel.toLowerCase();
return lower === "raw" || lower === "conflicts" || parseLineRanges(sel) !== null;
}
function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] {
const specs: SearchPathSpec[] = [];
for (const entry of rawEntries) {
@@ -134,11 +155,14 @@ function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] {
// still honor any embedded line range as a match filter.
const internalSplit = splitInternalUrlSel(entry);
if (internalSplit.sel !== undefined) {
specs.push({
original: entry,
clean: internalSplit.path,
ranges: selectorLineRanges(internalSplit.sel),
});
// Reject selectors read's parseSel would reject (`:-10`, `:1-1:1-2`,
// `:conflicts:1-1`) instead of silently widening the search or dropping a chunk.
if (!isReadSelectorGrammar(internalSplit.sel)) {
throw new ToolError(
`paths entry "${entry}" has an invalid selector ":${internalSplit.sel}" — use ":N-M" line ranges, ":raw"/":conflicts", a range plus ":raw", or percent-encode a literal ":" as %3A`,
);
}
specs.push({ original: entry, clean: internalSplit.path, ranges: selectorLineRanges(internalSplit.sel) });
continue;
}
const split = splitPathAndSel(entry);
@@ -162,6 +186,43 @@ function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] {
return specs;
}
/**
* Validate a search pattern against the native RE2 dialect by probing a scratch
* byte with `grep`. Used before a pure-virtual search (where no native grep ran
* to validate it), so an RE2-unsupported pattern (lookbehind, backreference) is
* rejected consistently instead of silently matching via JS `RegExp`. Throws the
* native `regex parse error`, which the caller normalizes to a `ToolError`.
*/
async function probeRegexDialect(
pattern: string,
ignoreCase: boolean,
multiline: boolean,
signal?: AbortSignal,
): Promise<void> {
const dir = await mkdtemp(path.join(tmpdir(), "omp-search-probe-"));
try {
const probeFile = path.join(dir, "probe");
await writeFile(probeFile, "\n");
await grep(
{
pattern,
path: probeFile,
ignoreCase,
multiline,
hidden: true,
gitignore: false,
maxCount: 1,
mode: GrepOutputMode.Content,
signal,
timeoutMs: SEARCH_GREP_TIMEOUT_MS,
},
undefined,
);
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
function mergeRangesInto(map: Map<string, LineRange[]>, absKey: string, ranges: readonly LineRange[]): void {
// Concat-without-merge is correct: `isLineInRanges` scans linearly, so
// duplicates/overlaps only cost a few extra comparisons per match.
@@ -612,6 +673,14 @@ async function resolveInternalSearchInputs(opts: {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath, context);
// A directory listing with no backing local path (e.g. a remote ssh:// dir)
// has no real contents to grep — searching its listing text would be
// misleading. Local/skill/vault dir resources set `sourcePath` and skip this.
if (resource.isDirectory && !resource.sourcePath) {
throw new ToolError(
`search cannot recurse the directory listing at ${rawPath}; search a specific file under it (e.g. ${rawPath.replace(/\/+$/, "")}/<file>) or read ${rawPath} to list its entries`,
);
}
if (resource.sourcePath) {
paths[idx] = resource.sourcePath;
if (resource.immutable) {
@@ -965,15 +1034,33 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
}
throw err;
}
const virtualResult = searchVirtualResources(
virtualResources,
normalizedPattern,
ignoreCase,
effectiveMultiline,
normalizedContextBefore,
normalizedContextAfter,
INTERNAL_TOTAL_CAP,
);
let virtualResult: GrepResult;
try {
// A purely virtual scope skipped native grep, so the pattern was
// never validated against the RE2 dialect. Probe it so an
// RE2-unsupported regex is rejected consistently instead of
// silently matching through JS `RegExp`.
if (searchablePaths.length === 0 && virtualResources.length > 0) {
await probeRegexDialect(normalizedPattern, ignoreCase, effectiveMultiline, signal);
}
virtualResult = searchVirtualResources(
virtualResources,
normalizedPattern,
ignoreCase,
effectiveMultiline,
normalizedContextBefore,
normalizedContextAfter,
INTERNAL_TOTAL_CAP,
);
} catch (err) {
if (err instanceof Error && /^regex(?: parse)? error/i.test(err.message)) {
throw new ToolError(err.message.replace(/^regex(?: parse)? error:?\s*/i, "Invalid regex: "));
}
if (err instanceof SyntaxError) {
throw new ToolError(`Invalid regex: ${err.message}`);
}
throw err;
}
result = mergeGrepResults(result, virtualResult, INTERNAL_TOTAL_CAP);
if (rangesByAbsPath.size > 0) {
const filteredMatches: GrepMatch[] = [];