diff --git a/plugins/file-manager/src/backend/home-directory.ts b/plugins/file-manager/src/backend/home-directory.ts new file mode 100644 index 000000000..3cc9f2607 --- /dev/null +++ b/plugins/file-manager/src/backend/home-directory.ts @@ -0,0 +1,35 @@ +import { execChannel, getSessionSftp, type SSHSession } from "./session.js"; + +/** The SFTP login directory also respects custom homes and chroot roots. */ +export async function resolveHomeDirectory( + session: SSHSession, +): Promise { + try { + const sftp = await getSessionSftp(session); + return await new Promise((resolve, reject) => { + sftp.realpath(".", (error, path) => { + if (error) reject(error); + else if (!path) reject(new Error("Empty SFTP login directory")); + else resolve(path); + }); + }); + } catch { + // Keep the existing shell-only file browser usable without SFTP. + return new Promise((resolve, reject) => { + execChannel(session, "pwd -P", (error, stream) => { + if (error) return reject(error); + let output = ""; + stream.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + stream.on("error", reject); + stream.on("close", (code: number) => { + const path = output.replace(/[\r\n]+$/, ""); + if (code !== 0 || !path.startsWith("/")) { + reject(new Error("Could not resolve the remote login directory")); + } else resolve(path); + }); + }); + }); + } +} diff --git a/plugins/file-manager/src/backend/list-routes.ts b/plugins/file-manager/src/backend/list-routes.ts index b40bb798f..230affa49 100644 --- a/plugins/file-manager/src/backend/list-routes.ts +++ b/plugins/file-manager/src/backend/list-routes.ts @@ -1,3 +1,4 @@ +import { resolveHomeDirectory } from "./home-directory.js"; import { getErrorMessage } from "./error-message.js"; import type { Express } from "express"; import type { PluginContext } from "@termix/plugin-sdk/backend"; @@ -52,10 +53,10 @@ export function registerFileListingRoutes( * 500: * description: Failed to list files. */ - app.get("/listFiles", (req, res) => { + app.get("/listFiles", async (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; - const sshPath = (req.query.path as string) || "/"; + let sshPath = (req.query.path as string) || "."; const userId = ctx.currentActor()!; if (!sessionId) { @@ -70,6 +71,16 @@ export function registerFileListingRoutes( return res.status(403).json({ error: "Session access denied" }); } + if (sshPath === ".") { + try { + sshPath = await resolveHomeDirectory(sshConn); + } catch (error) { + return res.status(500).json({ + error: getErrorMessage(error, "Could not resolve home directory"), + }); + } + } + // Drop concurrent requests for the same session+path — each would open // a new SSH channel and can exceed the server's per-connection channel limit. const listKey = `${sessionId}:${sshPath}`; diff --git a/plugins/file-manager/src/frontend/FileManager.tsx b/plugins/file-manager/src/frontend/FileManager.tsx index 620d7dfa4..cdce9016e 100644 --- a/plugins/file-manager/src/frontend/FileManager.tsx +++ b/plugins/file-manager/src/frontend/FileManager.tsx @@ -155,19 +155,19 @@ function FileManagerContent({ const [currentPath, setCurrentPath] = useState( initialPath || fileManagerHostSetting(initialHost, "defaultPath", "") || - "/", + ".", ); const currentPathRef = useRef(currentPath); currentPathRef.current = currentPath; const lastSuccessfulPathRef = useRef( initialPath || fileManagerHostSetting(initialHost, "defaultPath", "") || - "/", + ".", ); const [navHistory, setNavHistory] = useState([ initialPath || fileManagerHostSetting(initialHost, "defaultPath", "") || - "/", + ".", ]); const [navIndex, setNavIndex] = useState(0); const [files, setFiles] = useState([]); @@ -689,7 +689,12 @@ function FileManagerContent({ ? response : response?.files || []; setFiles(files); - lastSuccessfulPathRef.current = currentPath; + const listedPath = response.path || currentPath; + setCurrentPath(listedPath); + setNavHistory((history) => + history.map((path) => (path === "." ? listedPath : path)), + ); + lastSuccessfulPathRef.current = listedPath; clearSelection(); initialLoadDoneRef.current = true; @@ -799,7 +804,14 @@ function FileManagerContent({ : response?.files || []; setFiles(files); - lastSuccessfulPathRef.current = resolvedPath; + const listedPath = response.path || resolvedPath; + if (listedPath !== resolvedPath) { + setCurrentPath(listedPath); + setNavHistory((history) => + history.map((entry) => (entry === path ? listedPath : entry)), + ); + } + lastSuccessfulPathRef.current = listedPath; clearSelection(); return true; } catch (error: unknown) { diff --git a/plugins/file-manager/src/frontend/SftpTransferTab.tsx b/plugins/file-manager/src/frontend/SftpTransferTab.tsx index 2a973be4a..5b41698b9 100644 --- a/plugins/file-manager/src/frontend/SftpTransferTab.tsx +++ b/plugins/file-manager/src/frontend/SftpTransferTab.tsx @@ -374,11 +374,11 @@ function RemotePane({ ...current, connectionState: "ready", sessionId: result.sessionId || String(host.id), - path: fileManagerHostSetting(host, "defaultPath", "") || "/", + path: fileManagerHostSetting(host, "defaultPath", "") || ".", })); await loadRemotePath( result.sessionId || String(host.id), - fileManagerHostSetting(host, "defaultPath", "") || "/", + fileManagerHostSetting(host, "defaultPath", "") || ".", ); }, [loadRemotePath, setPane], diff --git a/plugins/file-manager/tests/backend/home-directory.test.ts b/plugins/file-manager/tests/backend/home-directory.test.ts new file mode 100644 index 000000000..b5afa90c5 --- /dev/null +++ b/plugins/file-manager/tests/backend/home-directory.test.ts @@ -0,0 +1,99 @@ +import { EventEmitter } from "node:events"; +import type { Express, Request, Response } from "express"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerFileListingRoutes } from "../../src/backend/list-routes.js"; +import { + execChannel, + getSessionSftp, + type SSHSession, +} from "../../src/backend/session.js"; +import { resolveHomeDirectory } from "../../src/backend/home-directory.js"; + +vi.mock("../../src/backend/session.js", () => ({ + getSessionSftp: vi.fn(), + execChannel: vi.fn(), +})); +beforeEach(() => vi.resetAllMocks()); + +function setup(home: string, owned = true) { + const sftp = { + realpath: vi.fn((_path, done) => done(null, home)), + readdir: vi.fn((_path, done) => done(null, [])), + }; + vi.mocked(getSessionSftp).mockResolvedValue(sftp as never); + let handler!: (req: Request, res: Response) => Promise; + registerFileListingRoutes( + { + get: (_path, cb) => { + handler = cb; + }, + } as Express, + { + ctx: { + currentActor: () => "user", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + } as unknown as PluginContext, + sshSessions: { + s: { isConnected: true, activeOperations: 0 } as SSHSession, + }, + activeListRequests: {}, + verifySessionOwnership: () => owned, + }, + ); + const res = { on: vi.fn(), status: vi.fn().mockReturnThis(), json: vi.fn() }; + return { + sftp, + res, + list: (path?: string) => + handler( + { query: { sessionId: "s", path } } as unknown as Request, + res as unknown as Response, + ), + }; +} + +describe("remote login directory", () => { + it.each(["/root", "/srv/users/alice", "/"])( + "lists the actual SFTP directory %s", + async (home) => { + const { sftp, res, list } = setup(home); + await list("."); + await vi.waitFor(() => + expect(res.json).toHaveBeenCalledWith({ files: [], path: home }), + ); + expect(sftp.realpath).toHaveBeenCalledWith(".", expect.any(Function)); + expect(sftp.readdir).toHaveBeenCalledWith(home, expect.any(Function)); + expect(execChannel).not.toHaveBeenCalled(); + }, + ); + it("preserves an explicit root instead of replacing it with home", async () => { + const { sftp, res, list } = setup("/home/alice"); + await list("/"); + await vi.waitFor(() => + expect(res.json).toHaveBeenCalledWith({ files: [], path: "/" }), + ); + expect(sftp.realpath).not.toHaveBeenCalled(); + }); + it("checks ownership before discovering the home directory", async () => { + const { res, list } = setup("/home/alice", false); + await list("."); + expect(res.status).toHaveBeenCalledWith(403); + expect(getSessionSftp).not.toHaveBeenCalled(); + }); + it("falls back to the shell login directory when SFTP is unavailable", async () => { + vi.mocked(getSessionSftp).mockRejectedValue(new Error("SFTP unavailable")); + vi.mocked(execChannel).mockImplementation((_session, command, done) => { + expect(command).toBe("pwd -P"); + const stream = new EventEmitter(); + done(undefined as never, stream as never); + queueMicrotask(() => { + stream.emit("data", Buffer.from("/srv/alice\n")); + stream.emit("close", 0); + }); + }); + await expect(resolveHomeDirectory({} as SSHSession)).resolves.toBe( + "/srv/alice", + ); + }); +}); diff --git a/plugins/file-manager/tests/frontend/SftpTransferTab.test.tsx b/plugins/file-manager/tests/frontend/SftpTransferTab.test.tsx index 13bcf54f4..da98fa81c 100644 --- a/plugins/file-manager/tests/frontend/SftpTransferTab.test.tsx +++ b/plugins/file-manager/tests/frontend/SftpTransferTab.test.tsx @@ -124,6 +124,35 @@ async function selectHosts() { } describe("SftpTransferTab", () => { + it("opens the server login directory when no default path is configured", async () => { + const hosts = await api.getSSHHosts(); + api.getSSHHosts.mockResolvedValue( + hosts.map((host: object) => ({ ...host, pluginSettings: {} })), + ); + api.browseSSHDirectory.mockImplementation( + async (sessionId: string, path: string) => ({ + status: "ok", + path: path === "." ? `/srv/home-${sessionId}` : path, + files: [ + { + name: `remote-${sessionId}.txt`, + type: "file", + path: `/srv/home-${sessionId}/remote-${sessionId}.txt`, + size: 1, + }, + ], + }), + ); + render(); + await selectHosts(); + await waitFor(() => { + expect(api.browseSSHDirectory).toHaveBeenCalledWith("1", "."); + expect(api.browseSSHDirectory).toHaveBeenCalledWith("2", "."); + }); + expect(await screen.findByDisplayValue("/srv/home-1")).toBeTruthy(); + expect(await screen.findByDisplayValue("/srv/home-2")).toBeTruthy(); + }); + it("loads file manager-enabled hosts into both host pickers", async () => { render(); const selects = await screen.findAllByRole("combobox");