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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user