Compare commits

...
13 changed files with 370 additions and 35 deletions

No files matched your search

@@ -38,6 +38,21 @@ test("remote SFTP current-path duplication uses the requested path instead of st
assert.equal(state.cachedStartPath, "/var/www/app");
});
test("remote SFTP cache hits keep home provisional metadata for reconnect", () => {
const state = resolveRemoteSftpStartState({
filenameEncoding: "auto",
sharedHostCacheCandidate: {
...cached,
path: "/",
homeDir: "/",
provisionalHome: false,
},
});
assert.equal(state.sharedHostCache?.provisionalHome, false);
assert.equal(state.cachedStartPath, "/");
});
test("remote SFTP initial paths preserve meaningful whitespace", () => {
assert.equal(normalizeSftpInitialPath("/var/www/app "), "/var/www/app ");
@@ -5,6 +5,8 @@ export interface RemoteSftpStartCache {
homeDir: string;
files: SftpFileEntry[];
filenameEncoding: SftpFilenameEncoding;
/** When known, reconnects must not re-infer HOME=/ as a provisional realpath. */
provisionalHome?: boolean;
}
interface ResolveRemoteSftpStartStateParams {
@@ -0,0 +1,89 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildSftpHomeCandidates,
buildSftpListFallbackPaths,
isProvisionalSftpHomeDir,
isProvisionalSftpHomeDiscovery,
shouldSuppressSftpHomeCandidateProbe,
} from "./sftpHomeDiscovery.ts";
test("filesystem root from realpath is provisional and must not suppress candidate probing", () => {
assert.equal(isProvisionalSftpHomeDiscovery({ homeDir: "/", provisional: true }), true);
assert.equal(shouldSuppressSftpHomeCandidateProbe({ homeDir: "/", provisional: true }), false);
// Legacy path-only inference when bridge omits metadata
assert.equal(isProvisionalSftpHomeDir("/"), true);
assert.equal(shouldSuppressSftpHomeCandidateProbe({ homeDir: "/" }), false);
});
test("authoritative HOME=/ from exec must suppress candidate probing", () => {
assert.equal(isProvisionalSftpHomeDiscovery({ homeDir: "/", provisional: false }), false);
assert.equal(shouldSuppressSftpHomeCandidateProbe({ homeDir: "/", provisional: false }), true);
});
test("concrete homes from exec or realpath still suppress candidate probing", () => {
assert.equal(isProvisionalSftpHomeDiscovery({ homeDir: "/home/deploy", provisional: false }), false);
assert.equal(shouldSuppressSftpHomeCandidateProbe({ homeDir: "/home/deploy" }), true);
assert.equal(shouldSuppressSftpHomeCandidateProbe({ homeDir: "/root", provisional: false }), true);
});
test("home candidates prefer /home/<user> then /root", () => {
assert.deepEqual(buildSftpHomeCandidates("deploy"), ["/home/deploy", "/root"]);
assert.deepEqual(buildSftpHomeCandidates("root"), ["/root"]);
assert.deepEqual(buildSftpHomeCandidates(""), ["/root"]);
assert.deepEqual(buildSftpHomeCandidates(null), ["/root"]);
});
test("list fallback keeps virtual root behavior when start is already /", () => {
// JumpServer-style: start and home are /, no extra root retry.
assert.deepEqual(
buildSftpListFallbackPaths({ startPath: "/", homeDir: "/", username: "ops", provisionalHome: true }),
["/home/ops", "/root"],
);
});
test("list fallback for provisional root recovers /home/<user> after failed /", () => {
const paths = buildSftpListFallbackPaths({
startPath: "/",
homeDir: "/",
username: "deploy",
provisionalHome: true,
});
assert.deepEqual(paths, ["/home/deploy", "/root"]);
assert.ok(paths.includes("/home/deploy"));
});
test("list fallback for authoritative HOME=/ does not redirect to /home/<user>", () => {
assert.deepEqual(
buildSftpListFallbackPaths({
startPath: "/",
homeDir: "/",
username: "deploy",
provisionalHome: false,
}),
[],
);
});
test("list fallback infers provisional HOME=/ when reconnect metadata is missing", () => {
assert.deepEqual(
buildSftpListFallbackPaths({
startPath: "/",
homeDir: "/",
username: "deploy",
}),
["/home/deploy", "/root"],
);
});
test("list fallback for concrete home still tries / after homeDir", () => {
assert.deepEqual(
buildSftpListFallbackPaths({
startPath: "/var/stale",
homeDir: "/home/deploy",
username: "deploy",
}),
["/home/deploy", "/"],
);
});
@@ -0,0 +1,89 @@
/**
* Home discovery helpers for remote SFTP connect.
*
* When SSH exec is unavailable, `realpath('.')` may return `/`. That path is a
* valid virtual/chroot root when listable (#2934), but it is only provisional
* when the session merely starts at filesystem root — candidate homes like
* `/home/<user>` must still be probed (#2940).
*
* Authoritative sources (SSH `echo ~`, SCP `$HOME`) may also return `/` when
* HOME is intentionally set to `/`. Callers must use bridge `provisional`
* metadata rather than inferring solely from the path string.
*/
export type SftpHomeDiscoveryResult = {
homeDir: string;
/** When set by the bridge: true only for provisional realpath('.') === '/'. */
provisional?: boolean;
};
/**
* Whether discovery only knows a provisional filesystem root.
* Prefers bridge metadata; falls back to path-only inference for legacy results.
*/
export function isProvisionalSftpHomeDiscovery(result: SftpHomeDiscoveryResult): boolean {
if (typeof result.provisional === "boolean") {
return result.provisional;
}
return result.homeDir === "/";
}
/** @deprecated Prefer isProvisionalSftpHomeDiscovery with bridge metadata. */
export function isProvisionalSftpHomeDir(homeDir: string): boolean {
return isProvisionalSftpHomeDiscovery({ homeDir });
}
/**
* Whether a successful getSftpHomeDir result should suppress hardcoded
* `/home/<user>` / `/root` candidate probing.
*/
export function shouldSuppressSftpHomeCandidateProbe(result: SftpHomeDiscoveryResult): boolean {
return !isProvisionalSftpHomeDiscovery(result);
}
/** Hardcoded Unix home candidates used when protocol discovery is incomplete. */
export function buildSftpHomeCandidates(username?: string | null): string[] {
if (username === "root") {
return ["/root"];
}
if (username) {
return [`/home/${username}`, "/root"];
}
return ["/root"];
}
/**
* Paths to try after an initial remote list fails, in order.
* Skips duplicates of paths already attempted.
*/
export function buildSftpListFallbackPaths(params: {
startPath: string;
homeDir: string;
username?: string | null;
/** When known, overrides path-only provisional inference for homeDir. */
provisionalHome?: boolean;
}): string[] {
const tried = new Set<string>([params.startPath]);
const ordered: string[] = [];
const push = (path: string) => {
if (!path || tried.has(path)) return;
tried.add(path);
ordered.push(path);
};
push(params.homeDir);
push("/");
const provisional = isProvisionalSftpHomeDiscovery({
homeDir: params.homeDir,
provisional: params.provisionalHome,
});
if (provisional) {
for (const candidate of buildSftpHomeCandidates(params.username)) {
push(candidate);
}
}
return ordered;
}
@@ -55,3 +55,25 @@ test("shared host cache skips one listing larger than the global file budget", (
});
assert.equal(getSharedRemoteHostCache("oversized"), null);
});
test("shared host cache preserves authoritative HOME=/ provisional metadata", () => {
_resetSharedRemoteHostCacheForTests();
setSharedRemoteHostCache("host-root", {
...entry(1),
homeDir: "/",
provisionalHome: false,
});
assert.equal(getSharedRemoteHostCache("host-root")?.provisionalHome, false);
});
test("shared host cache preserves provisional realpath HOME=/ metadata", () => {
_resetSharedRemoteHostCacheForTests();
setSharedRemoteHostCache("host-provisional", {
...entry(1),
homeDir: "/",
provisionalHome: true,
});
assert.equal(getSharedRemoteHostCache("host-provisional")?.provisionalHome, true);
});
@@ -5,6 +5,8 @@ export interface SharedRemoteHostCacheEntry {
homeDir: string;
files: SftpFileEntry[];
filenameEncoding: SftpFilenameEncoding;
/** Bridge/discovery metadata; persist so reconnects do not infer HOME=/ as provisional. */
provisionalHome?: boolean;
updatedAt: number;
}
+47 -30
View File
@@ -8,6 +8,12 @@ import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
import { resolveRemoteSftpStartState } from "./sftpConnectStartPath";
import {
buildSftpHomeCandidates,
buildSftpListFallbackPaths,
isProvisionalSftpHomeDiscovery,
shouldSuppressSftpHomeCandidateProbe,
} from "./sftpHomeDiscovery";
import { normalizeSftpPaneNavigationPath } from "./utils";
import {
setDirectoryCacheEntry,
@@ -678,11 +684,16 @@ export const useSftpConnections = ({
let startPath = sharedHostCache?.path ?? "/";
let homeDir = sharedHostCache?.homeDir ?? startPath;
// Cache-hit reconnects skip getSftpHomeDir; restore metadata so
// authoritative HOME=/ is not inferred as a provisional realpath.
let provisionalHome: boolean | undefined = sharedHostCache?.provisionalHome;
if (!sharedHostCache) {
// Detect home directory: SSH exec `echo ~` → SFTP realpath('.') → hardcoded fallback
const bridge = netcattyBridge.get();
let detected = false;
// Detect home directory: SSH exec `echo ~` → SFTP realpath('.') → hardcoded fallback.
// Only realpath('.') === '/' is provisional (#2940): keep probing /home/<user> and /root.
// Authoritative exec/SCP HOME=/ must not be redirected (#2940 review).
// Listable virtual roots still win when those candidates are absent (#2934).
let suppressCandidates = false;
if (bridge?.getSftpHomeDir) {
try {
@@ -690,23 +701,22 @@ export const useSftpConnections = ({
if (result?.success && result.homeDir) {
startPath = result.homeDir;
homeDir = result.homeDir;
detected = true;
provisionalHome = isProvisionalSftpHomeDiscovery({
homeDir: result.homeDir,
provisional: result.provisional,
});
suppressCandidates = shouldSuppressSftpHomeCandidateProbe({
homeDir: result.homeDir,
provisional: result.provisional,
});
}
} catch {
// Fall through to hardcoded candidates
}
}
if (!detected) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
if (!suppressCandidates) {
const candidates = buildSftpHomeCandidates(credentials.username);
const statSftp = bridge?.statSftp;
if (statSftp) {
for (const candidate of candidates) {
@@ -715,6 +725,7 @@ export const useSftpConnections = ({
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
provisionalHome = false;
break;
}
} catch {
@@ -725,10 +736,11 @@ export const useSftpConnections = ({
// Fallback: probe candidates via listSftp when statSftp is unavailable
for (const candidate of candidates) {
try {
const files = await bridge?.listSftp(sftpId, candidate, filenameEncoding);
if (files) {
const listed = await bridge?.listSftp(sftpId, candidate, filenameEncoding);
if (listed) {
startPath = candidate;
homeDir = candidate;
provisionalHome = false;
break;
}
} catch {
@@ -763,25 +775,28 @@ export const useSftpConnections = ({
if (provisionalCacheKey) {
dirCacheRef.current.delete(provisionalCacheKey);
}
// Fall back to homeDir, then "/", chaining attempts.
// Fall back to homeDir, then "/", then provisional-root home candidates (#2940).
// Remembered/reconnect paths can be stale even when shared cache is gone.
const fallbackPaths = buildSftpListFallbackPaths({
startPath,
homeDir,
username: credentials.username,
provisionalHome,
});
let fallbackSucceeded = false;
if (startPath !== homeDir) {
for (const fallbackPath of fallbackPaths) {
try {
startPath = homeDir;
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
files = await listRemoteFiles(sftpId, fallbackPath, filenameEncoding);
startPath = fallbackPath;
// Prefer a concrete home when recovery lands on /home/<user> or /root.
if (fallbackPath !== "/") {
homeDir = fallbackPath;
provisionalHome = false;
}
fallbackSucceeded = true;
break;
} catch {
// homeDir also failed, try root
}
}
if (!fallbackSucceeded && startPath !== "/") {
try {
startPath = "/";
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
fallbackSucceeded = true;
} catch {
// root also failed
// try next fallback path
}
}
if (!fallbackSucceeded) {
@@ -801,6 +816,7 @@ export const useSftpConnections = ({
homeDir,
files,
filenameEncoding,
provisionalHome,
});
clearSideReconnecting();
@@ -813,6 +829,7 @@ export const useSftpConnections = ({
status: "connected",
currentPath: startPath,
homeDir,
provisionalHome,
reusedConnection: undefined,
}
: null,
@@ -225,6 +225,7 @@ export const useSftpPaneActions = ({
homeDir: pane.connection.homeDir ?? normalizedPath,
files: cached.files,
filenameEncoding: pane.filenameEncoding,
provisionalHome: pane.connection.provisionalHome,
});
}
return "reached";
@@ -391,6 +392,7 @@ export const useSftpPaneActions = ({
homeDir: pane.connection.homeDir ?? normalizedPath,
files,
filenameEncoding: pane.filenameEncoding,
provisionalHome: pane.connection.provisionalHome,
});
}
return "reached";
+2
View File
@@ -24,6 +24,8 @@ export interface SftpConnection {
error?: string;
currentPath: string;
homeDir?: string;
/** When set, overrides path-only inference for homeDir === "/". */
provisionalHome?: boolean;
/** True when this SFTP connection reuses an existing terminal SSH session */
reusedConnection?: boolean;
fileProtocol?: 'auto' | 'sftp' | 'scp';
+12 -3
View File
@@ -897,7 +897,8 @@ function createFileOpsApi(ctx) {
}
},
});
return { success: true, homeDir: home };
// SCP $HOME (including "/") is authoritative — never provisional.
return { success: true, homeDir: home, provisional: false };
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
@@ -919,7 +920,8 @@ function createFileOpsApi(ctx) {
throwIfAborted(signal);
const home = result.stdout?.trim();
if (home && home.startsWith("/")) {
return { success: true, homeDir: home };
// Authoritative shell HOME, including HOME=/ — do not treat as provisional.
return { success: true, homeDir: home, provisional: false };
}
} catch (err) {
// Timeout or error — kill the exec channel if still open
@@ -935,6 +937,9 @@ function createFileOpsApi(ctx) {
// Method 2: SFTP realpath('.'). A virtual/chroot SFTP server (including
// bastion products such as JumpServer) can legitimately expose '/' as
// the authenticated user's root even when SSH exec channels are denied.
// Only realpath('/') is provisional: callers must still probe
// /home/<user> / /root when listing '/' fails or before accepting it
// as the final browse root (#2940). Exec/SCP "/" is not provisional.
try {
const sftp = await requireSftpChannel(client, {
signal,
@@ -944,7 +949,11 @@ function createFileOpsApi(ctx) {
const absPath = await realpathAsync(sftp, ".");
throwIfAborted(signal);
if (absPath && absPath.startsWith("/")) {
return { success: true, homeDir: absPath };
return {
success: true,
homeDir: absPath,
provisional: absPath === "/",
};
}
} catch (err) {
if (signal?.aborted) {
+80 -1
View File
@@ -1,8 +1,21 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { EventEmitter } = require("node:events");
const { createFileOpsApi } = require("./fileOps.cjs");
function createExecStream(stdoutText) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
stream.destroy = () => {};
queueMicrotask(() => {
stream.emit("data", Buffer.from(stdoutText));
stream.emit("close");
});
return stream;
}
test("home discovery accepts a virtual SFTP root when SSH exec is unavailable", async () => {
const channel = {};
const api = createFileOpsApi({
@@ -18,5 +31,71 @@ test("home discovery accepts a virtual SFTP root when SSH exec is unavailable",
const result = await api.getSftpHomeDir(null, { sftpId: "jumpserver" });
assert.deepEqual(result, { success: true, homeDir: "/" });
assert.deepEqual(result, { success: true, homeDir: "/", provisional: true });
});
test("home discovery marks concrete realpath homes as non-provisional", async () => {
const channel = {};
const api = createFileOpsApi({
sftpClients: new Map([["sftp", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async () => "/home/deploy",
});
const result = await api.getSftpHomeDir(null, { sftpId: "sftp" });
assert.deepEqual(result, {
success: true,
homeDir: "/home/deploy",
provisional: false,
});
});
test("authoritative echo ~ of / is not provisional", async () => {
const sshClient = {
exec(_command, callback) {
callback(null, createExecStream("/\n"));
},
};
const api = createFileOpsApi({
sftpClients: new Map([["root-home", { client: sshClient }]]),
throwIfAborted() {},
requireSftpChannel: async () => {
throw new Error("realpath must not run when echo ~ succeeds");
},
realpathAsync: async () => {
throw new Error("realpath must not run when echo ~ succeeds");
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "root-home" });
assert.deepEqual(result, { success: true, homeDir: "/", provisional: false });
});
test("authoritative echo ~ of a concrete home is not provisional", async () => {
const sshClient = {
exec(_command, callback) {
callback(null, createExecStream("/home/deploy\n"));
},
};
const api = createFileOpsApi({
sftpClients: new Map([["user-home", { client: sshClient }]]),
throwIfAborted() {},
requireSftpChannel: async () => {
throw new Error("realpath must not run when echo ~ succeeds");
},
realpathAsync: async () => {
throw new Error("realpath must not run when echo ~ succeeds");
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "user-home" });
assert.deepEqual(result, {
success: true,
homeDir: "/home/deploy",
provisional: false,
});
});
@@ -202,6 +202,7 @@ describe("SCP-mode session ops via fileOps entry points (AI/UI shared path)", ()
const home = await api.getSftpHomeDir(null, { sftpId: "scp-session-1" });
assert.equal(home.success, true);
assert.equal(home.homeDir, "/home/agent");
assert.equal(home.provisional, false);
});
it("capability catalog still exposes stable sftp.* CLI verbs", () => {
+7 -1
View File
@@ -19,7 +19,13 @@ declare global {
renameSftp?(sftpId: string, oldPath: string, newPath: string, encoding?: SftpFilenameEncoding): Promise<void>;
statSftp?(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpStatResult>;
chmodSftp?(sftpId: string, path: string, mode: string, encoding?: SftpFilenameEncoding): Promise<void>;
getSftpHomeDir?(sftpId: string, encoding?: SftpFilenameEncoding): Promise<{ success: boolean; homeDir?: string; error?: string }>;
getSftpHomeDir?(sftpId: string, encoding?: SftpFilenameEncoding): Promise<{
success: boolean;
homeDir?: string;
/** True only when home came from realpath('.') === '/'; exec/SCP HOME=/ is false. */
provisional?: boolean;
error?: string;
}>;
// Transfer with progress
cancelTransfer?(transferId: string): Promise<void>;