The previous fix gated on a verified `transferShell` but still let
OpenSSH hand the snippet to whatever `$SHELL` happens to be on the
remote. On a fish/csh/tcsh host the new gate would accept the host,
then fail anyway because the login shell can't parse `if [ ... ]; then
...` (P1 from PR #3722 review).
Each transfer command (read, write, stat, list) is now wrapped in
`<transferShell> -c '...'` via a shared `wrapInPosixShell` helper, so
the snippet is parsed by the same shell OMP's capability probe verified
can run it. `ensurePosixRemote` returns the verified shell so each
call site can do the wrap. `ssh-executor.ts`'s identical Windows-compat
helper is consolidated onto the same primitive (`buildCompatCommand` /
`quoteForCompatShell` removed).
Stays POSIX-clean across all four call sites; `-c` (not `-lc`) since
the snippets only call absolute builtins and don't need login-profile
setup. Capability *probing* still uses `-lc` to mirror the user env.
Test additions: one case asserts every dispatch starts with
`bash -c '...'` and embeds the original POSIX snippet (read/write/stat/list)
when transferShell is bash and login shell is unknown; another covers
the `sh -c` happy path.
Replace the login-shell-name allowlist in `ensurePosixRemote` with a
capability check against a newly probed `transferShell`. The host probe
runs `sh -lc` / `bash -lc` / `zsh -lc` against the remote and records
the first candidate whose printf marker round-trips; `uname -s` from the
same probe also refines the OS classification when the first probe could
not resolve it.
Three compounding problems fixed:
- The host probe parsed only the first stdout line, so login-shell
banners or any startup noise would land ahead of the payload and
classify the host as `shell: "unknown"`. The probe now frames its
payload with a `PI_HOST_PROBE=` marker (see `extractProbePayload`)
and scans both streams for the marker line.
- `shouldRefreshHostInfo` did not treat `{os: "linux", shell: "unknown"}`
as stale, so a single bad classification stuck and kept failing
later `ssh://` operations. It now refreshes any non-Windows cache
entry without a verified `transferShell`.
- The transfer guard refused the host on the self-reported login-shell
name. It now gates on `info.transferShell`, which is the shell OMP
actually verified can run `head`/`cat`/`mv`/`test`/`ls`. The
refusal message names the capability we couldn't confirm.
`HOST_INFO_VERSION` bumped 3 → 4 so existing caches re-probe and pick
up `transferShell`. `parseHostInfo` exported so the cache round-trip
of `transferShell` is testable without touching disk.
Fixes#3719
remotePathFromUrl now rejects a URL query string or fragment, so a mistyped ssh://host/tmp/a?draft no longer silently operates on /tmp/a; a literal ?/# in a remote filename must be percent-encoded (%3F/%23).
ssh:// transfers now require a sh/bash/zsh login shell. fish can't parse the POSIX transfer snippets and csh/tcsh apply ! history expansion to the command line, so they're refused (use the ssh tool). Host-shell detection no longer misclassifies fish/csh/tcsh as sh (basename allowlist over endsWith), and HOST_INFO_VERSION is bumped to re-probe caches that stored the old classification.
dirname is an operand-only POSIX utility (no options), so 'dirname -- "$t"' is non-portable on macOS/BSD (the -- is treated as an operand). The temp path is always absolute, so the end-of-options guard is unnecessary; drop it. mkdir -p -- stays (mkdir conforms to the Utility Syntax Guidelines and supports --).
- search: native grep silently skips files above NATIVE_GREP_MAX_FILE_BYTES (4 MiB), so the RE2 virtual path dropped matches for large virtual resources (history://, big artifacts). searchVirtualResources now falls back to a JS RegExp matcher for oversized content (the pre-RE2 behavior) while keeping native parity for normal sizes; buildVirtualMatches still rebuilds context/ranges.
- ssh write: a trailing-slash target (ssh://h/dir/) is now rejected before staging, so the mkdir -p parent-creation no longer leaves a directory behind on a refused write.
- search: the native virtual probe capped matched-line detection at INTERNAL_TOTAL_CAP before range filtering, so a ranged virtual selector (ssh://h/log:5000-5100) over a file with >2000 earlier matches returned nothing. The probe now uses the line-count bound for ranged resources so range filtering sees every hit.
- ssh write: writeRemoteFile staged into a temp beside the destination before creating parents, so writing a new nested path failed with 'No such file or directory'. It now mkdir -p's the parent before staging, matching local write, keeping the directory/special-file refusal checks.
- `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
- buildSshTarget rejects destinations beginning with "-" (SSH argument-injection / local RCE guard)
- gate ssh:// read/search/write at the exec approval tier; substring scan covers search's pre-expansion delimited paths and write's hashline-wrapped paths
- validate the entire materialized buffer as UTF-8 instead of only the first 8 KiB prefix
- write peels read selectors (raw/conflicts) so it targets the same file read does, and rejects line-range/malformed selectors instead of silently stripping them
- write to a uniquely named remote temp; document symlink-replacement on write as a v1 limit