fix(pi-ssh): harden remote execution and search

This commit is contained in:
云服务部-叶林立
2026-08-24 23:04:29 +08:00
parent a7891f18bf
commit aff91b0972
30 changed files with 1063 additions and 222 deletions
+145 -48
View File
@@ -15,6 +15,7 @@ export interface RemoteGrepInput {
literal?: boolean;
caseSensitive?: boolean;
include?: string;
includeHidden?: boolean;
limit?: number;
}
@@ -30,6 +31,8 @@ const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
const MAX_LINE_CHARS = 800;
const MAX_CAPTURE_CHARS = 512_000;
const SEARCH_TIMEOUT_SECONDS = 30;
const MAX_DIAGNOSTIC_CHARS = 16_000;
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
@@ -55,6 +58,7 @@ export function resolveRemoteSearchPath(path: string | undefined, remoteCwd: str
validateField(value, "path");
if (value === "~") return remoteHome;
if (value.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, value.slice(2)));
if (value.startsWith("~")) throw new Error("path supports only ~ or ~/... remote HOME expansion");
if (value.startsWith("/")) return posixPath.normalize(value);
return posixPath.normalize(posixPath.join(remoteCwd, value));
}
@@ -70,6 +74,27 @@ const STATUS_HELPER = [
`}`,
].join("\n");
const GREP_RECORD_HELPER = [
`pi_ssh_limit_colon_records() {`,
` local path match line content count=0`,
` while IFS= read -r -d '' path && IFS= read -r match; do`,
' line="${match%%:*}"',
' content="${match#*:}"',
' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"',
` count=$((count + 1))`,
` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`,
` done`,
`}`,
`pi_ssh_limit_git_records() {`,
` local path line content count=0`,
` while IFS= read -r -d '' path && IFS= read -r -d '' line && IFS= read -r content; do`,
' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"',
` count=$((count + 1))`,
` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`,
` done`,
`}`,
] .join("\n");
function findPipeline(input: RemoteFindInput, root: string, limit: number): string {
const pattern = validateField(input.pattern, "pattern") as string;
const fdCase = input.caseSensitive ? "--case-sensitive" : "--ignore-case";
@@ -81,24 +106,24 @@ function findPipeline(input: RemoteFindInput, root: string, limit: number): stri
STATUS_HELPER,
`if command -v fd >/dev/null 2>&1; then`,
` printf '${MARKER}fd\\n'`,
` fd --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` fd --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
`elif command -v fdfind >/dev/null 2>&1; then`,
` printf '${MARKER}fdfind\\n'`,
` fdfind --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` fdfind --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
`elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`,
` printf '${MARKER}git-ls-files\\n'`,
` git -C ${shellQuote(root)} ls-files -co --exclude-standard 2>&1 | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` git -C ${shellQuote(root)} ls-files -co --exclude-standard | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${statuses[2]}" 0 1 141 || exit $?',
`else`,
` printf '${MARKER}find\\n'`,
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' 2>&1 | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
@@ -110,29 +135,64 @@ function findPipeline(input: RemoteFindInput, root: string, limit: number): stri
function grepPipeline(input: RemoteGrepInput, root: string, limit: number): string {
const pattern = validateField(input.pattern, "pattern") as string;
const include = validateField(input.include, "include", true);
const fixed = input.literal === false ? "" : "-F";
if (include?.includes("/")) throw new Error("include must be a basename glob without /");
const rgMode = input.literal === false ? "" : "-F";
const grepMode = input.literal === false ? "-E" : "-F";
const insensitive = input.caseSensitive ? "" : "-i";
const rgHidden = input.includeHidden
? "--hidden"
: "--glob '!.*' --glob '!**/.*' --glob '!**/.*/**'";
const rgGlob = include ? `-g ${shellQuote(include)}` : "";
const gitPath = include ? `-- ${shellQuote(include)}` : "";
const gitPathspecs = [
...(include ? [`:(glob)**/${include}`] : []),
...(input.includeHidden ? [] : [":(exclude,glob)**/.*", ":(exclude,glob)**/.*/**"]),
];
const gitPath = gitPathspecs.length > 0 ? `-- ${gitPathspecs.map(shellQuote).join(" ")}` : "";
const findName = include ? `-name ${shellQuote(include)}` : "";
const includeHidden = input.includeHidden ? 1 : 0;
const take = limit + 1;
return [
STATUS_HELPER,
GREP_RECORD_HELPER,
`PI_SSH_TAKE=${take}`,
`PI_SSH_ROOT=${shellQuote(root)}`,
`PI_SSH_INCLUDE_HIDDEN=${includeHidden}`,
`if command -v rg >/dev/null 2>&1; then`,
` printf '${MARKER}ripgrep\\n'`,
` rg --line-number --no-heading --color never --with-filename --max-columns 500 --max-columns-preview ${fixed} ${insensitive} ${rgGlob} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` rg --null --line-number --no-heading --color never --with-filename --max-columns ${MAX_LINE_CHARS} --max-columns-preview ${rgMode} ${insensitive} ${rgHidden} ${rgGlob} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} | pi_ssh_limit_colon_records`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?',
`elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`,
` printf '${MARKER}git-grep\\n'`,
` git -C ${shellQuote(root)} grep --untracked --exclude-standard -n -I ${fixed} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` git -C ${shellQuote(root)} grep --untracked --exclude-standard -z -n -I ${grepMode} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} | pi_ssh_limit_git_records`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?',
`else`,
` printf '${MARKER}grep\\n'`,
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -exec grep -nH -I ${fixed} ${insensitive} -- ${shellQuote(pattern)} {} + 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -print0 | (`,
` while IFS= read -r -d '' file; do`,
` if [ "$PI_SSH_INCLUDE_HIDDEN" -eq 0 ]; then`,
` if [ -d "$PI_SSH_ROOT" ]; then`,
' relative=${file#"$PI_SSH_ROOT"/}',
` else`,
' relative=${file##*/}',
` fi`,
' case "$relative" in .*|*/.*) continue ;; esac',
` fi`,
` grep -n -I ${grepMode} ${insensitive} -- ${shellQuote(pattern)} "$file" | while IFS= read -r match; do`,
` printf '%s\\0%s\\n' "$file" "$match"`,
` done`,
' grep_statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${grep_statuses[0]}" 0 1 141 || exit $?',
' pi_ssh_accept_status "${grep_statuses[1]}" 0 1 141 || exit $?',
` done`,
` ) | pi_ssh_limit_colon_records`,
' statuses=("${PIPESTATUS[@]}")',
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[1]}" 0 141 || exit $?',
' pi_ssh_accept_status "${statuses[2]}" 0 || exit $?',
`fi`,
].join("\n");
}
@@ -149,30 +209,54 @@ export function buildRemoteGrepCommand(input: RemoteGrepInput, root: string): {
return { command: grepPipeline(input, root, limit), limit };
}
function prefixGitPath(line: string, root: string, grep: boolean): string {
if (line.startsWith("/") || line.startsWith("../")) return line;
if (!grep) return posixPath.join(root, line);
const separator = line.indexOf(":");
if (separator < 1) return line;
return `${posixPath.join(root, line.slice(0, separator))}${line.slice(separator)}`;
function prefixGitPath(path: string, root: string): string {
return path.startsWith("/") || path.startsWith("../") ? path : posixPath.join(root, path);
}
function visibleField(value: string): string {
return value.replace(/\r/gu, "\\r").replace(/\n/gu, "\\n");
}
function normalizeDiagnostic(raw: Buffer): string {
return raw
.toString("utf8")
.replace(/\0/gu, " ")
.replace(new RegExp(`${MARKER}[^\\n]*`, "gu"), "")
.replace(/\s+/gu, " ")
.trim()
.slice(0, 300);
}
export function formatRemoteSearchOutput(
raw: string,
raw: string | Buffer,
root: string,
limit: number,
kind: "find" | "grep",
): RemoteSearchResult {
const lines = raw.replace(/\r\n?/gu, "\n").split("\n");
const markerIndex = lines.findIndex((line) => line.startsWith(MARKER));
if (markerIndex < 0) throw new Error(`remote ${kind} did not report a search backend`);
const backend = lines[markerIndex].slice(MARKER.length).trim() || "unknown";
const sourceRows = lines.slice(markerIndex + 1).filter((line) => line.length > 0);
const output = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
const markerIndex = output.indexOf(MARKER);
const markerEnd = markerIndex < 0 ? -1 : output.indexOf("\n", markerIndex);
if (markerIndex < 0 || markerEnd < 0) throw new Error(`remote ${kind} did not report a search backend`);
const backend = output.slice(markerIndex + MARKER.length, markerEnd).trim() || "unknown";
const payload = output.slice(markerEnd + 1);
let sourceRows: string[];
if (kind === "grep") {
const fields = payload.length === 0 ? [] : payload.split("\0");
if (fields.at(-1) === "") fields.pop();
if (fields.length % 3 !== 0) throw new Error("remote grep returned a malformed or truncated NUL-delimited result");
sourceRows = [];
for (let index = 0; index < fields.length; index += 3) {
const path = backend.startsWith("git-") ? prefixGitPath(fields[index], root) : fields[index];
sourceRows.push(`${visibleField(path)}:${visibleField(fields[index + 1])}:${visibleField(fields[index + 2])}`);
}
} else {
sourceRows = payload.replace(/\r\n?/gu, "\n").split("\n").filter((line) => line.length > 0);
if (backend.startsWith("git-")) sourceRows = sourceRows.map((line) => prefixGitPath(line, root));
}
const truncated = sourceRows.length > limit;
const rows = sourceRows.slice(0, limit).map((line) => {
const normalized = backend.startsWith("git-") ? prefixGitPath(line, root, kind === "grep") : line;
return normalized.length > MAX_LINE_CHARS ? `${normalized.slice(0, MAX_LINE_CHARS - 1)}…` : normalized;
});
const rows = sourceRows.slice(0, limit).map((line) =>
line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS - 1)}…` : line,
);
const header = `Remote ${kind}: ${rows.length} result${rows.length === 1 ? "" : "s"} (backend: ${backend}, root: ${root}, truncated: ${truncated ? "yes" : "no"})`;
return {
text: rows.length > 0 ? `${header}\n\n${rows.join("\n")}` : `${header}\n\nNo matches found.`,
@@ -185,37 +269,50 @@ export function formatRemoteSearchOutput(
async function runRemoteSearch(
transport: RemoteTransport,
command: string,
executionCwd: string,
root: string,
limit: number,
kind: "find" | "grep",
signal?: AbortSignal,
): Promise<RemoteSearchResult> {
const chunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let captured = 0;
const result = await transport.exec(command, root, {
signal,
timeout: 30,
onData(data) {
if (captured >= MAX_CAPTURE_CHARS) return;
const remaining = MAX_CAPTURE_CHARS - captured;
const chunk = data.length > remaining ? data.subarray(0, remaining) : data;
chunks.push(chunk);
captured += chunk.length;
},
});
const output = Buffer.concat(chunks).toString("utf8");
let stderrCaptured = 0;
const capture = (target: Buffer[], data: Buffer, stderr = false) => {
const current = stderr ? stderrCaptured : captured;
const maximum = stderr ? MAX_DIAGNOSTIC_CHARS : MAX_CAPTURE_CHARS;
if (current >= maximum) return;
const remaining = maximum - current;
const chunk = data.length > remaining ? data.subarray(0, remaining) : data;
target.push(chunk);
if (stderr) stderrCaptured += chunk.length;
else captured += chunk.length;
};
let result: { exitCode: number | null };
try {
result = await transport.exec(command, executionCwd, {
signal,
timeout: SEARCH_TIMEOUT_SECONDS,
onData(data) { capture(chunks, data); },
onStderr(data) { capture(stderrChunks, data, true); },
});
} catch (error) {
if (!signal?.aborted && /timed out/iu.test(error instanceof Error ? error.message : String(error))) {
throw new Error(`remote ${kind} timed out after ${SEARCH_TIMEOUT_SECONDS}s (root: ${root}); narrow the remote path with ssh_find before retrying`);
}
throw error;
}
const output = Buffer.concat(chunks);
const stderr = Buffer.concat(stderrChunks);
if (result.exitCode !== 0) {
const detail = output
.replace(/\r\n?/gu, "\n")
.split("\n")
.filter((line) => !line.startsWith(MARKER))
.join(" ")
.replace(/\s+/gu, " ")
.trim()
.slice(0, 300);
const detail = normalizeDiagnostic(stderr.length > 0 ? stderr : output);
throw new Error(`remote ${kind} failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
}
return formatRemoteSearchOutput(output, root, limit, kind);
const formatted = formatRemoteSearchOutput(output, root, limit, kind);
const warning = normalizeDiagnostic(stderr);
if (warning) formatted.text += `\n\nRemote warning: ${warning}`;
return formatted;
}
export function runRemoteFind(
@@ -227,7 +324,7 @@ export function runRemoteFind(
): Promise<RemoteSearchResult> {
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
const built = buildRemoteFindCommand(input, root);
return runRemoteSearch(transport, built.command, root, built.limit, "find", signal);
return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "find", signal);
}
export function runRemoteGrep(
@@ -239,5 +336,5 @@ export function runRemoteGrep(
): Promise<RemoteSearchResult> {
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
const built = buildRemoteGrepCommand(input, root);
return runRemoteSearch(transport, built.command, root, built.limit, "grep", signal);
return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "grep", signal);
}