mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 00:30:57 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56645681a8 | ||
|
|
55714b939e | ||
|
|
1ee1c5d06d |
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user