diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 9a7afc1866f..9d3e3a43494 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -159,6 +159,12 @@ const config: Configuration = { to: "node_modules/friendly-words", filter: ["**/*"], }, + // ssh2 for remote SSH workspace connections + { + from: "node_modules/ssh2", + to: "node_modules/ssh2", + filter: ["**/*"], + }, "!**/.DS_Store", ], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f85c3ea97bf..cd43d5d3075 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -201,6 +201,7 @@ "shell-env": "^4.0.3", "shell-quote": "^1.8.3", "simple-git": "^3.30.0", + "ssh2": "^1.16.0", "streamdown": "^2.2.0", "strip-ansi": "^7.1.2", "superjson": "^2.2.5", @@ -223,6 +224,7 @@ "@tanstack/router-cli": "^1.149.0", "@tanstack/router-plugin": "^1.149.0", "@types/better-sqlite3": "^7.6.13", + "@types/ssh2": "^1.15.4", "@types/bun": "^1.2.17", "@types/culori": "^4.0.1", "@types/http-proxy": "^1.17.17", diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index e9f75513476..23c83bce97d 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -21,6 +21,7 @@ import { createProjectsRouter } from "./projects"; import { createResourceMetricsRouter } from "./resource-metrics"; import { createRingtoneRouter } from "./ringtone"; import { createSettingsRouter } from "./settings"; +import { createSshHostsRouter } from "./ssh-hosts"; import { createTerminalRouter } from "./terminal"; import { createUiStateRouter } from "./ui-state"; import { createWindowRouter } from "./window"; @@ -55,6 +56,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { uiState: createUiStateRouter(), ringtone: createRingtoneRouter(getWindow), workspaceServiceManager: createWorkspaceServiceManagerRouter(), + sshHosts: createSshHostsRouter(), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/ssh-hosts/index.ts b/apps/desktop/src/lib/trpc/routers/ssh-hosts/index.ts new file mode 100644 index 00000000000..c1d8c133682 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ssh-hosts/index.ts @@ -0,0 +1,253 @@ +import { sshHosts } from "@superset/local-db"; +import { observable } from "@trpc/server/observable"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; +import { getSshConnectionManager, parseSshConfig } from "main/lib/ssh"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +export function createSshHostsRouter() { + const sshManager = getSshConnectionManager(); + + return router({ + // List all SSH hosts + list: publicProcedure.query(() => { + return localDb.select().from(sshHosts).all(); + }), + + // Create a new SSH host + create: publicProcedure + .input( + z.object({ + label: z.string().min(1), + hostname: z.string().min(1), + port: z.number().int().min(1).max(65535).default(22), + username: z.string().min(1), + authMethod: z.enum(["password", "privateKey", "agent"]), + privateKeyPath: z.string().optional(), + defaultDirectory: z.string().optional(), + }), + ) + .mutation(({ input }) => { + const host = localDb + .insert(sshHosts) + .values({ + label: input.label, + hostname: input.hostname, + port: input.port, + username: input.username, + authMethod: input.authMethod, + privateKeyPath: input.privateKeyPath ?? null, + defaultDirectory: input.defaultDirectory ?? null, + }) + .returning() + .get(); + + return host; + }), + + // Update an SSH host + update: publicProcedure + .input( + z.object({ + id: z.string(), + label: z.string().min(1).optional(), + hostname: z.string().min(1).optional(), + port: z.number().int().min(1).max(65535).optional(), + username: z.string().min(1).optional(), + authMethod: z.enum(["password", "privateKey", "agent"]).optional(), + privateKeyPath: z.string().nullable().optional(), + defaultDirectory: z.string().nullable().optional(), + }), + ) + .mutation(({ input }) => { + const { id, ...fields } = input; + + const host = localDb + .update(sshHosts) + .set(fields) + .where(eq(sshHosts.id, id)) + .returning() + .get(); + + if (!host) { + throw new Error(`SSH host ${id} not found`); + } + + return host; + }), + + // Delete an SSH host + delete: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(({ input }) => { + // Disconnect if connected + if (sshManager.isConnected(input.id)) { + sshManager.disconnect(input.id); + } + + localDb.delete(sshHosts).where(eq(sshHosts.id, input.id)).run(); + + return { success: true }; + }), + + // Test SSH connection (without saving) + testConnection: publicProcedure + .input( + z.object({ + hostname: z.string().min(1), + port: z.number().int().default(22), + username: z.string().min(1), + authMethod: z.enum(["password", "privateKey", "agent"]), + privateKeyPath: z.string().optional(), + password: z.string().optional(), + passphrase: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const result = await sshManager.testConnection( + { + id: "__test__", + label: "test", + hostname: input.hostname, + port: input.port, + username: input.username, + authMethod: input.authMethod, + privateKeyPath: input.privateKeyPath, + }, + { + password: input.password, + passphrase: input.passphrase, + }, + ); + + return result; + }), + + // Connect to a saved host + connect: publicProcedure + .input( + z.object({ + id: z.string(), + password: z.string().optional(), + passphrase: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const host = localDb + .select() + .from(sshHosts) + .where(eq(sshHosts.id, input.id)) + .get(); + + if (!host) { + throw new Error(`SSH host ${input.id} not found`); + } + + await sshManager.connect( + { + id: host.id, + label: host.label, + hostname: host.hostname, + port: host.port ?? 22, + username: host.username, + authMethod: host.authMethod as "password" | "privateKey" | "agent", + privateKeyPath: host.privateKeyPath ?? undefined, + }, + { + password: input.password, + passphrase: input.passphrase, + }, + ); + + localDb + .update(sshHosts) + .set({ lastConnectedAt: Date.now() }) + .where(eq(sshHosts.id, input.id)) + .run(); + + return { success: true }; + }), + + // Disconnect from a host + disconnect: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(({ input }) => { + sshManager.disconnect(input.id); + return { success: true }; + }), + + // Get connection status for a host + getConnectionStatus: publicProcedure + .input(z.object({ id: z.string() })) + .query(({ input }) => { + return { state: sshManager.getState(input.id) }; + }), + + // Subscribe to connection state changes + onConnectionStateChange: publicProcedure.subscription(() => { + return observable<{ hostId: string; state: string; error?: string }>( + (emit) => { + const handler = (hostId: string, state: string, error?: string) => { + emit.next({ hostId, state, error }); + }; + + sshManager.on("state-change", handler); + + return () => { + sshManager.off("state-change", handler); + }; + }, + ); + }), + + // Import hosts from ~/.ssh/config + importFromConfig: publicProcedure.query(async () => { + return parseSshConfig(); + }), + + // Browse remote directory (for path picker) + browseRemoteDirectory: publicProcedure + .input( + z.object({ + hostId: z.string(), + path: z.string().default("/"), + }), + ) + .query(async ({ input }) => { + const sftp = await sshManager.getSftpClient(input.hostId); + + return new Promise< + { name: string; path: string; isDirectory: boolean }[] + >((resolve, reject) => { + sftp.readdir(input.path, (err, list) => { + if (err) { + reject( + new Error( + `Failed to read directory ${input.path}: ${err.message}`, + ), + ); + return; + } + + const entries = list + .filter((entry) => { + // d = directory + return entry.attrs.mode !== undefined + ? (entry.attrs.mode & 0o170000) === 0o040000 + : false; + }) + .map((entry) => ({ + name: entry.filename, + path: `${input.path.replace(/\/$/, "")}/${entry.filename}`, + isDirectory: true, + })); + + resolve(entries); + }); + }); + }), + }); +} + +export type SshHostsRouter = ReturnType; diff --git a/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts b/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts index 76c83285262..11738f105d7 100644 --- a/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts +++ b/apps/desktop/src/lib/trpc/routers/workspace-fs-service.ts @@ -1,5 +1,8 @@ import path from "node:path"; -import type { WorkspaceFsServiceInfo } from "@superset/workspace-fs/core"; +import type { + WorkspaceFsService, + WorkspaceFsServiceInfo, +} from "@superset/workspace-fs/core"; import { createWorkspaceFsHostService, toFileSystemChangeEvent, @@ -7,7 +10,9 @@ import { type WorkspaceFsPathError, WorkspaceFsWatcherManager, } from "@superset/workspace-fs/host"; +import { createSshWorkspaceFsService } from "@superset/workspace-fs/ssh"; import { shell } from "electron"; +import { getSshConnectionManager } from "main/lib/ssh"; import type { DirectoryEntry, FileSystemChangeEvent, @@ -99,6 +104,52 @@ export const registeredWorktreeFsService = createWorkspaceFsHostService({ ...sharedHostServiceOptions, }); +/** + * Returns the appropriate WorkspaceFsService for the given workspaceId. + * Remote workspaces (type === "remote" with sshHostId) use the SSH service; + * all others use the local host service. + */ +function getWorkspaceFsService(workspaceId: string): WorkspaceFsService { + const workspace = getWorkspace(workspaceId); + if (workspace?.type === "remote" && workspace.sshHostId) { + const sshHostId = workspace.sshHostId; + const remotePath = workspace.remotePath ?? ""; + const sshManager = getSshConnectionManager(); + return createSshWorkspaceFsService({ + // Cast: ssh2 SFTPWrapper satisfies SftpWrapper structurally; minor err type difference (undefined vs null) + getSftp: () => sshManager.getSftpClient(sshHostId) as never, + execCommand: async (command) => { + const client = sshManager.getConnection(sshHostId); + if (!client) { + throw new Error(`SSH host ${sshHostId} is not connected`); + } + return new Promise((resolve, reject) => { + // ssh2 client.exec() opens a non-interactive exec channel (not a shell) + client.exec(command, (err, channel) => { + if (err) { + reject(err); + return; + } + let stdout = ""; + let stderr = ""; + channel.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + channel.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + channel.on("close", (exitCode: number | null) => { + resolve({ stdout, stderr, exitCode: exitCode ?? 0 }); + }); + }); + }); + }, + resolveRootPath: () => remotePath, + }); + } + return workspaceFsService; +} + export function toRegisteredWorktreeRelativePath( worktreePath: string, absolutePath: string, @@ -222,7 +273,9 @@ export async function readWorkspaceDirectory(input: { workspaceId: string; absolutePath: string; }): Promise { - const entries = await workspaceFsService.listDirectory(input); + const entries = await getWorkspaceFsService(input.workspaceId).listDirectory( + input, + ); return entries.map((entry) => ({ id: entry.id, name: entry.name, @@ -242,7 +295,7 @@ export async function createWorkspaceFile(input: { name: string; content?: string; }): Promise<{ path: string }> { - const result = await workspaceFsService.createFile({ + const result = await getWorkspaceFsService(input.workspaceId).createFile({ workspaceId: input.workspaceId, absolutePath: path.join(input.parentAbsolutePath, input.name), content: input.content, @@ -255,10 +308,12 @@ export async function createWorkspaceDirectory(input: { parentAbsolutePath: string; name: string; }): Promise<{ path: string }> { - const result = await workspaceFsService.createDirectory({ - workspaceId: input.workspaceId, - absolutePath: path.join(input.parentAbsolutePath, input.name), - }); + const result = await getWorkspaceFsService(input.workspaceId).createDirectory( + { + workspaceId: input.workspaceId, + absolutePath: path.join(input.parentAbsolutePath, input.name), + }, + ); return { path: result.absolutePath }; } @@ -267,7 +322,7 @@ export async function renameWorkspacePath(input: { absolutePath: string; newName: string; }): Promise<{ oldPath: string; newPath: string }> { - const result = await workspaceFsService.rename(input); + const result = await getWorkspaceFsService(input.workspaceId).rename(input); return { oldPath: result.oldAbsolutePath, newPath: result.newAbsolutePath, @@ -282,7 +337,9 @@ export async function deleteWorkspacePaths(input: { deleted: string[]; errors: Array<{ path: string; error: string }>; }> { - const result = await workspaceFsService.deletePaths(input); + const result = await getWorkspaceFsService(input.workspaceId).deletePaths( + input, + ); return { deleted: result.deleted, errors: result.errors.map((error) => ({ @@ -300,7 +357,7 @@ export async function moveWorkspacePaths(input: { moved: Array<{ from: string; to: string }>; errors: Array<{ path: string; error: string }>; }> { - const result = await workspaceFsService.movePaths({ + const result = await getWorkspaceFsService(input.workspaceId).movePaths({ workspaceId: input.workspaceId, absolutePaths: input.sourceAbsolutePaths, destinationAbsolutePath: input.destinationAbsolutePath, @@ -322,7 +379,7 @@ export async function copyWorkspacePaths(input: { copied: Array<{ from: string; to: string }>; errors: Array<{ path: string; error: string }>; }> { - const result = await workspaceFsService.copyPaths({ + const result = await getWorkspaceFsService(input.workspaceId).copyPaths({ workspaceId: input.workspaceId, absolutePaths: input.sourceAbsolutePaths, destinationAbsolutePath: input.destinationAbsolutePath, @@ -340,7 +397,7 @@ export async function workspacePathExists(input: { workspaceId: string; absolutePath: string; }) { - return await workspaceFsService.exists(input); + return await getWorkspaceFsService(input.workspaceId).exists(input); } export async function statWorkspacePath(input: { @@ -348,7 +405,7 @@ export async function statWorkspacePath(input: { absolutePath: string; }) { try { - return await workspaceFsService.stat(input); + return await getWorkspaceFsService(input.workspaceId).stat(input); } catch (error) { console.warn("[workspace-fs/statWorkspacePath] Failed:", { workspaceId: input.workspaceId, @@ -363,7 +420,7 @@ export async function* watchWorkspaceFileSystemEvents( workspaceId: string, ): AsyncIterable { const rootPath = resolveWorkspaceRootPath(workspaceId); - for await (const event of workspaceFsService.watchWorkspace({ + for await (const event of getWorkspaceFsService(workspaceId).watchWorkspace({ workspaceId, })) { yield toFileSystemChangeEvent(event, rootPath); @@ -377,7 +434,7 @@ export async function searchWorkspaceFiles(input: { excludePattern?: string; limit?: number; }): Promise { - const results = await workspaceFsService.searchFiles({ + const results = await getWorkspaceFsService(input.workspaceId).searchFiles({ workspaceId: input.workspaceId, query: input.query, includeHidden: true, @@ -453,7 +510,7 @@ export async function searchWorkspaceKeyword(input: { excludePattern?: string; limit?: number; }): Promise { - const results = await workspaceFsService.searchKeyword({ + const results = await getWorkspaceFsService(input.workspaceId).searchKeyword({ workspaceId: input.workspaceId, query: input.query, includeHidden: true, diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 3da1e2642f6..518c01e3f3c 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -1,8 +1,16 @@ -import { projects, settings, workspaces, worktrees } from "@superset/local-db"; +import { + projects, + settings, + sshHosts, + workspaces, + worktrees, +} from "@superset/local-db"; import { and, eq, isNull, not } from "drizzle-orm"; import { track } from "main/lib/analytics"; import { localDb } from "main/lib/local-db"; +import { getSshConnectionManager } from "main/lib/ssh"; import { workspaceInitManager } from "main/lib/workspace-init-manager"; +import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime/registry"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { attemptWorkspaceAutoRenameFromPrompt } from "../utils/ai-name"; @@ -971,6 +979,117 @@ export const createCreateProcedures = () => { workspaceName, }); }), + createRemote: publicProcedure + .input( + z.object({ + projectId: z.string(), + sshHostId: z.string(), + remotePath: z.string().min(1), + name: z.string().optional(), + prompt: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (!project) { + throw new Error(`Project ${input.projectId} not found`); + } + + // Verify SSH host exists + const host = localDb + .select() + .from(sshHosts) + .where(eq(sshHosts.id, input.sshHostId)) + .get(); + if (!host) { + throw new Error(`SSH host ${input.sshHostId} not found`); + } + + // Verify connection is active + const sshManager = getSshConnectionManager(); + if (!sshManager.isConnected(input.sshHostId)) { + throw new Error( + `Not connected to SSH host "${host.label}". Please connect first.`, + ); + } + + // Verify remote path exists via SFTP + try { + const sftp = await sshManager.getSftpClient(input.sshHostId); + await new Promise((resolve, reject) => { + sftp.stat(input.remotePath, (err) => { + if (err) { + reject( + new Error( + `Remote path "${input.remotePath}" does not exist or is not accessible`, + ), + ); + } else { + resolve(); + } + }); + }); + } catch (error) { + throw error instanceof Error + ? error + : new Error("Failed to verify remote path"); + } + + const maxTabOrder = getMaxProjectChildTabOrder(input.projectId); + const workspaceName = + input.name || + `${host.label}: ${input.remotePath.split("/").pop() || input.remotePath}`; + + const workspace = localDb + .insert(workspaces) + .values({ + projectId: input.projectId, + type: "remote", + branch: "remote", + name: workspaceName, + tabOrder: maxTabOrder + 1, + sshHostId: input.sshHostId, + remotePath: input.remotePath, + }) + .returning() + .get(); + + setLastActiveWorkspace(workspace.id); + activateProject(project); + + // Register in runtime registry for correct routing + const registry = getWorkspaceRuntimeRegistry(); + registry.registerRemoteWorkspace(workspace.id, input.sshHostId); + + track("workspace_created", { + workspace_id: workspace.id, + project_id: project.id, + type: "remote", + source: "ssh", + }); + + // Attempt auto-rename from prompt if provided + if (input.prompt) { + void attemptWorkspaceAutoRenameFromPrompt({ + workspaceId: workspace.id, + prompt: input.prompt, + }).catch(() => {}); + } + + return { + workspace, + remotePath: input.remotePath, + projectId: project.id, + sshHostId: input.sshHostId, + isInitializing: false, + wasExisting: false, + }; + }), + importAllWorktrees: publicProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ input }) => { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts index 21cf02c02b1..9077ba1710c 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -281,6 +281,10 @@ export const createDeleteProcedures = () => { deleteWorkspace(input.id); + if (workspace.type === "remote") { + getWorkspaceRuntimeRegistry().unregisterRemoteWorkspace(input.id); + } + if (worktree) { deleteWorktreeRecord(worktree.id); } @@ -318,6 +322,10 @@ export const createDeleteProcedures = () => { hideProjectIfNoWorkspaces(workspace.projectId); updateActiveWorkspaceIfRemoved(input.id); + if (workspace.type === "remote") { + getWorkspaceRuntimeRegistry().unregisterRemoteWorkspace(input.id); + } + const terminalWarning = terminalResult.failed > 0 ? `${terminalResult.failed} terminal process(es) may still be running` diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index fe676f1018b..ef12d0895c8 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -1,5 +1,6 @@ import { projects, + sshHosts, workspaceSections, workspaces, worktrees, @@ -63,7 +64,7 @@ export const createQueryProcedures = () => { return { ...workspace, - type: workspace.type as "worktree" | "branch", + type: workspace.type as "worktree" | "branch" | "remote", worktreePath: getWorkspacePath(workspace) ?? "", project: project ? { @@ -100,7 +101,7 @@ export const createQueryProcedures = () => { sectionId: string | null; worktreeId: string | null; worktreePath: string; - type: "worktree" | "branch"; + type: "worktree" | "branch" | "remote"; branch: string; name: string; tabOrder: number; @@ -109,6 +110,9 @@ export const createQueryProcedures = () => { lastOpenedAt: number; isUnread: boolean; isUnnamed: boolean; + sshHostId: string | null; + remotePath: string | null; + sshHostLabel: string | null; }; type SectionItem = { @@ -138,6 +142,9 @@ export const createQueryProcedures = () => { allWorktrees.map((wt) => [wt.id, wt.path]), ); + const allSshHosts = localDb.select().from(sshHosts).all(); + const sshHostLabelMap = new Map(allSshHosts.map((h) => [h.id, h.label])); + const allSections = localDb.select().from(workspaceSections).all(); const groupsMap = new Map< @@ -206,15 +213,22 @@ export const createQueryProcedures = () => { worktreePath = worktreePathMap.get(workspace.worktreeId) ?? ""; } else if (workspace.type === "branch") { worktreePath = group.project.mainRepoPath; + } else if (workspace.type === "remote") { + worktreePath = workspace.remotePath ?? ""; } const item: WorkspaceItem = { ...workspace, sectionId: workspace.sectionId ?? null, - type: workspace.type as "worktree" | "branch", + type: workspace.type as "worktree" | "branch" | "remote", worktreePath, isUnread: workspace.isUnread ?? false, isUnnamed: workspace.isUnnamed ?? false, + sshHostId: workspace.sshHostId ?? null, + remotePath: workspace.remotePath ?? null, + sshHostLabel: workspace.sshHostId + ? (sshHostLabelMap.get(workspace.sshHostId) ?? null) + : null, }; if (workspace.sectionId) { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts index 0ecccdc326b..6a2c63fad7e 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts @@ -18,6 +18,7 @@ export function getWorktreePath(worktreeId: string): string | undefined { * Gets the working directory path for a workspace. * For worktree workspaces: returns the worktree path * For branch workspaces: returns the main repo path + * For remote workspaces: returns the remotePath field */ export function getWorkspacePath(workspace: SelectWorkspace): string | null { if (workspace.type === "branch") { @@ -29,6 +30,11 @@ export function getWorkspacePath(workspace: SelectWorkspace): string | null { return project?.mainRepoPath ?? null; } + // For remote type, use remotePath + if (workspace.type === "remote") { + return workspace.remotePath ?? null; + } + // For worktree type, use worktree path if (workspace.worktreeId) { const worktree = localDb diff --git a/apps/desktop/src/main/lib/ssh/index.ts b/apps/desktop/src/main/lib/ssh/index.ts new file mode 100644 index 00000000000..fea9db5f3ac --- /dev/null +++ b/apps/desktop/src/main/lib/ssh/index.ts @@ -0,0 +1,25 @@ +import { SshConnectionManager } from "./ssh-connection-manager"; + +export { parseSshConfig } from "./ssh-config-parser"; +export { SshConnectionManager } from "./ssh-connection-manager"; +export type { + SshConnectionEvents, + SshConnectionInfo, + SshConnectionState, + SshHostConfig, + SshSessionInfo, +} from "./types"; + +let sshConnectionManager: SshConnectionManager | null = null; + +/** + * Returns the singleton SshConnectionManager instance, creating it lazily on + * first call. Mirrors the getDaemonTerminalManager() pattern used elsewhere in + * the desktop app. + */ +export function getSshConnectionManager(): SshConnectionManager { + if (!sshConnectionManager) { + sshConnectionManager = new SshConnectionManager(); + } + return sshConnectionManager; +} diff --git a/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts b/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts new file mode 100644 index 00000000000..3fcb456b0fb --- /dev/null +++ b/apps/desktop/src/main/lib/ssh/ssh-config-parser.ts @@ -0,0 +1,128 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { SshHostConfig } from "./types"; + +interface ParsedHostBlock { + host: string; + hostname?: string; + user?: string; + port?: number; + identityFile?: string; +} + +/** + * Parse the user's ~/.ssh/config file and return an array of SshHostConfig objects. + * Wildcard hosts (Host *) are skipped. + */ +export async function parseSshConfig(): Promise { + const configPath = path.join(os.homedir(), ".ssh", "config"); + + let content: string; + try { + content = await fs.readFile(configPath, "utf8"); + } catch { + // No SSH config file found — return empty list + return []; + } + + const blocks = parseHostBlocks(content); + const configs: SshHostConfig[] = []; + + for (const block of blocks) { + // Skip wildcard hosts + if (block.host === "*" || block.host.includes("*")) { + continue; + } + + const hostname = block.hostname ?? block.host; + const username = block.user ?? os.userInfo().username; + const port = block.port ?? 22; + const hasIdentityFile = !!block.identityFile; + + const config: SshHostConfig = { + id: randomUUID(), + label: block.host, + hostname, + port, + username, + authMethod: hasIdentityFile ? "privateKey" : "agent", + ...(hasIdentityFile && { + privateKeyPath: resolveKeyPath(block.identityFile!), + }), + }; + + configs.push(config); + } + + return configs; +} + +function parseHostBlocks(content: string): ParsedHostBlock[] { + const lines = content.split(/\r?\n/); + const blocks: ParsedHostBlock[] = []; + let current: ParsedHostBlock | null = null; + + for (const raw of lines) { + const line = raw.trim(); + + // Skip blank lines and comments + if (!line || line.startsWith("#")) { + continue; + } + + const spaceIdx = line.indexOf(" "); + const eqIdx = line.indexOf("="); + let sepIdx: number; + if (spaceIdx === -1 && eqIdx === -1) continue; + if (spaceIdx === -1) sepIdx = eqIdx; + else if (eqIdx === -1) sepIdx = spaceIdx; + else sepIdx = Math.min(spaceIdx, eqIdx); + + const key = line.slice(0, sepIdx).toLowerCase(); + const value = line.slice(sepIdx + 1).trim(); + + if (key === "host") { + if (current) { + blocks.push(current); + } + current = { host: value }; + continue; + } + + if (!current) continue; + + switch (key) { + case "hostname": + current.hostname = value; + break; + case "user": + current.user = value; + break; + case "port": { + const parsed = parseInt(value, 10); + if (!Number.isNaN(parsed)) { + current.port = parsed; + } + break; + } + case "identityfile": + current.identityFile = value; + break; + } + } + + if (current) { + blocks.push(current); + } + + return blocks; +} + +function resolveKeyPath(keyPath: string): string { + if (keyPath.startsWith("~")) { + return path.join(os.homedir(), keyPath.slice(1)); + } + return keyPath; +} diff --git a/apps/desktop/src/main/lib/ssh/ssh-connection-manager.ts b/apps/desktop/src/main/lib/ssh/ssh-connection-manager.ts new file mode 100644 index 00000000000..9a8b1ef7e4f --- /dev/null +++ b/apps/desktop/src/main/lib/ssh/ssh-connection-manager.ts @@ -0,0 +1,542 @@ +import { EventEmitter } from "node:events"; +import * as fs from "node:fs/promises"; +// ssh2 may need a dynamic-style import for Electron compatibility. +// Using named imports directly works when the module is bundled correctly. +import { Client, type ClientChannel, type SFTPWrapper } from "ssh2"; +import type { + SshConnectionEvents, + SshConnectionInfo, + SshConnectionState, + SshHostConfig, + SshSessionInfo, +} from "./types"; + +const MAX_RECONNECT_ATTEMPTS = 5; +const RECONNECT_BASE_MS = 1000; + +function shellEscape(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; +} + +export class SshConnectionManager extends EventEmitter { + private connections = new Map(); + private sessions = new Map(); + private sftpClients = new Map(); + private reconnectTimers = new Map(); + + // ------------------------------------------------------------------- + // Typed EventEmitter overrides + // ------------------------------------------------------------------- + + override on( + event: K, + listener: SshConnectionEvents[K], + ): this { + return super.on(event, listener); + } + + override emit( + event: K, + ...args: Parameters + ): boolean { + return super.emit(event, ...args); + } + + // ------------------------------------------------------------------- + // Connection management + // ------------------------------------------------------------------- + + /** + * Connect to an SSH host. The connection is pooled by hostId. + * Supports password, privateKey (reads key file from disk), and agent auth. + */ + async connect( + config: SshHostConfig, + credentials?: { password?: string; passphrase?: string }, + ): Promise { + const existing = this.connections.get(config.id); + if (existing?.state === "connected" && existing.client) { + return; + } + + this.setState(config.id, "connecting"); + + return new Promise((resolve, reject) => { + const client = new Client(); + + client.on("ready", () => { + const info = this.connections.get(config.id); + if (info) { + info.client = client; + info.reconnectAttempts = 0; + } + this.setState(config.id, "connected"); + resolve(); + }); + + client.on("error", (err: Error) => { + console.error( + `[SshConnectionManager] Connection error for ${config.id}:`, + err.message, + ); + const info = this.connections.get(config.id); + if (info?.state !== "connected") { + this.setState(config.id, "error", err.message); + reject(err); + } else { + this.handleDisconnect(config); + } + }); + + client.on("close", () => { + const info = this.connections.get(config.id); + if (info?.state === "connected" || info?.state === "reconnecting") { + this.handleDisconnect(config); + } + }); + + client.on("end", () => { + const info = this.connections.get(config.id); + if (info?.state === "connected") { + this.handleDisconnect(config); + } + }); + + this.connections.set(config.id, { + hostId: config.id, + state: "connecting", + client, + reconnectAttempts: 0, + }); + + this.buildConnectConfig(config, credentials) + .then((connectConfig) => { + client.connect(connectConfig); + }) + .catch((err: Error) => { + this.setState(config.id, "error", err.message); + reject(err); + }); + }); + } + + /** + * Disconnect from a host, closing all associated sessions and SFTP clients. + */ + disconnect(hostId: string): void { + this.clearReconnectTimer(hostId); + + // Close all sessions for this host + for (const session of this.getSessionsByHost(hostId)) { + this.closeSession(session.paneId); + } + + // Close SFTP client if cached + const sftp = this.sftpClients.get(hostId); + if (sftp) { + try { + sftp.end(); + } catch { + // ignore + } + this.sftpClients.delete(hostId); + } + + const info = this.connections.get(hostId); + if (info?.client) { + try { + info.client.end(); + } catch { + // ignore + } + } + + this.connections.delete(hostId); + this.setState(hostId, "disconnected"); + } + + /** Get the active ssh2 Client for a host, or null if not connected. */ + getConnection(hostId: string): Client | null { + const info = this.connections.get(hostId); + return info?.state === "connected" ? (info.client ?? null) : null; + } + + /** Returns true if the host connection is in the "connected" state. */ + isConnected(hostId: string): boolean { + return this.connections.get(hostId)?.state === "connected"; + } + + /** Get the current connection state for a host. */ + getState(hostId: string): SshConnectionState { + return this.connections.get(hostId)?.state ?? "disconnected"; + } + + // ------------------------------------------------------------------- + // Shell sessions + // ------------------------------------------------------------------- + + /** + * Open an interactive shell channel with a PTY on the given host. + * Tracks the session in the internal map and wires up data/exit events. + */ + async openShell( + hostId: string, + paneId: string, + options: { + cols: number; + rows: number; + cwd?: string; + env?: Record; + }, + ): Promise { + const client = this.getConnection(hostId); + if (!client) { + throw new Error(`[SshConnectionManager] Host ${hostId} is not connected`); + } + + return new Promise((resolve, reject) => { + client.shell( + { + term: "xterm-256color", + cols: options.cols, + rows: options.rows, + }, + { env: options.env }, + (err, channel) => { + if (err) { + reject(err); + return; + } + + const session: SshSessionInfo = { + paneId, + hostId, + channel, + cwd: options.cwd ?? "", + createdAt: Date.now(), + }; + this.sessions.set(paneId, session); + + channel.on("data", (data: Buffer) => { + this.emit("session-data", paneId, data); + }); + + channel.stderr.on("data", (data: Buffer) => { + this.emit("session-data", paneId, data); + }); + + channel.on("close", (code: number | null, signal?: string) => { + this.sessions.delete(paneId); + this.emit("session-exit", paneId, code, signal); + }); + + // If a working directory was requested, cd into it first + if (options.cwd) { + channel.stdin.write(`cd ${shellEscape(options.cwd)}\n`); + } + + resolve(); + }, + ); + }); + } + + /** Write data to a session channel's stdin. */ + writeToSession(paneId: string, data: string): void { + const session = this.sessions.get(paneId); + if (!session) { + console.warn( + `[SshConnectionManager] writeToSession: session ${paneId} not found`, + ); + return; + } + session.channel.stdin.write(data); + } + + /** Resize the PTY for a session by sending a window-change request. */ + resizeSession(paneId: string, cols: number, rows: number): void { + const session = this.sessions.get(paneId); + if (!session) { + console.warn( + `[SshConnectionManager] resizeSession: session ${paneId} not found`, + ); + return; + } + // ClientChannel exposes setWindow for PTY resize + ( + session.channel as ClientChannel & { + setWindow( + rows: number, + cols: number, + height: number, + width: number, + ): void; + } + ).setWindow(rows, cols, 0, 0); + } + + /** Send a signal to a session channel (e.g. SIGINT). */ + signalSession(paneId: string, signal = "INT"): void { + const session = this.sessions.get(paneId); + if (!session) { + console.warn( + `[SshConnectionManager] signalSession: session ${paneId} not found`, + ); + return; + } + session.channel.signal(signal); + } + + /** Close a session channel. */ + closeSession(paneId: string): void { + const session = this.sessions.get(paneId); + if (!session) return; + try { + session.channel.close(); + } catch { + // ignore + } + this.sessions.delete(paneId); + } + + /** Get session info for a pane, or null if not found. */ + getSession(paneId: string): SshSessionInfo | null { + return this.sessions.get(paneId) ?? null; + } + + /** Get all sessions belonging to a given host. */ + getSessionsByHost(hostId: string): SshSessionInfo[] { + return Array.from(this.sessions.values()).filter( + (s) => s.hostId === hostId, + ); + } + + // ------------------------------------------------------------------- + // SFTP + // ------------------------------------------------------------------- + + /** + * Get an SFTP subsystem client for the given host. + * Clients are cached per connection — a new one is created if the cache is empty. + */ + async getSftpClient(hostId: string): Promise { + const cached = this.sftpClients.get(hostId); + if (cached) return cached; + + const client = this.getConnection(hostId); + if (!client) { + throw new Error(`[SshConnectionManager] Host ${hostId} is not connected`); + } + + return new Promise((resolve, reject) => { + client.sftp((err, sftp) => { + if (err) { + reject(err); + return; + } + this.sftpClients.set(hostId, sftp); + + sftp.on("end", () => { + this.sftpClients.delete(hostId); + }); + sftp.on("error", () => { + this.sftpClients.delete(hostId); + }); + + resolve(sftp); + }); + }); + } + + // ------------------------------------------------------------------- + // Test connectivity + // ------------------------------------------------------------------- + + /** + * Test connectivity to a host without persisting the connection. + * Returns success/error without modifying internal state. + */ + async testConnection( + config: SshHostConfig, + credentials?: { password?: string; passphrase?: string }, + ): Promise<{ success: boolean; error?: string }> { + return new Promise<{ success: boolean; error?: string }>((resolve) => { + const client = new Client(); + let settled = false; + + const done = (success: boolean, error?: string) => { + if (settled) return; + settled = true; + clearTimeout(timeoutHandle); + try { + client.end(); + } catch { + // ignore + } + resolve({ success, error }); + }; + + const timeoutHandle = setTimeout(() => { + done(false, "Connection timed out"); + }, 15000); + + client.on("ready", () => done(true)); + client.on("error", (err: Error) => done(false, err.message)); + + this.buildConnectConfig(config, credentials) + .then((connectConfig) => { + client.connect(connectConfig); + }) + .catch((err: Error) => { + done(false, err.message); + }); + }); + } + + // ------------------------------------------------------------------- + // Cleanup + // ------------------------------------------------------------------- + + /** Close all connections and sessions. */ + async cleanup(): Promise { + for (const timer of this.reconnectTimers.values()) { + clearTimeout(timer); + } + this.reconnectTimers.clear(); + + for (const session of this.sessions.values()) { + try { + session.channel.close(); + } catch { + // ignore + } + } + this.sessions.clear(); + + for (const sftp of this.sftpClients.values()) { + try { + sftp.end(); + } catch { + // ignore + } + } + this.sftpClients.clear(); + + for (const info of this.connections.values()) { + try { + info.client?.end(); + } catch { + // ignore + } + } + this.connections.clear(); + + this.removeAllListeners(); + } + + // ------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------- + + private setState( + hostId: string, + state: SshConnectionState, + error?: string, + ): void { + const existing = this.connections.get(hostId); + if (existing) { + existing.state = state; + if (error !== undefined) existing.error = error; + } + this.emit("state-change", hostId, state, error); + } + + private handleDisconnect(config: SshHostConfig): void { + const info = this.connections.get(config.id); + if (!info) return; + + // Clear stale SFTP client so next getSftpClient() opens a fresh one + this.sftpClients.delete(config.id); + + if (info.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + console.error( + `[SshConnectionManager] Max reconnect attempts reached for ${config.id}`, + ); + this.setState(config.id, "error", "Max reconnect attempts exceeded"); + return; + } + + info.reconnectAttempts += 1; + const delay = RECONNECT_BASE_MS * 2 ** (info.reconnectAttempts - 1); + + console.warn( + `[SshConnectionManager] Reconnecting to ${config.id} in ${delay}ms (attempt ${info.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`, + ); + + this.setState(config.id, "reconnecting"); + + const timer = setTimeout(() => { + this.reconnectTimers.delete(config.id); + this.connect(config).catch((err: Error) => { + console.error( + `[SshConnectionManager] Reconnect failed for ${config.id}:`, + err.message, + ); + }); + }, delay); + + this.reconnectTimers.set(config.id, timer); + } + + private clearReconnectTimer(hostId: string): void { + const timer = this.reconnectTimers.get(hostId); + if (timer) { + clearTimeout(timer); + this.reconnectTimers.delete(hostId); + } + } + + private async buildConnectConfig( + config: SshHostConfig, + credentials?: { password?: string; passphrase?: string }, + ): Promise["connect"]>[0]> { + const base = { + host: config.hostname, + port: config.port, + username: config.username, + }; + + switch (config.authMethod) { + case "password": + return { + ...base, + password: credentials?.password ?? "", + }; + + case "privateKey": { + if (!config.privateKeyPath) { + throw new Error( + `[SshConnectionManager] No privateKeyPath configured for host ${config.id}`, + ); + } + const privateKey = await fs.readFile(config.privateKeyPath); + return { + ...base, + privateKey, + passphrase: credentials?.passphrase, + }; + } + + case "agent": + return { + ...base, + agent: process.env.SSH_AUTH_SOCK, + }; + + default: + throw new Error( + `[SshConnectionManager] Unknown authMethod: ${config.authMethod}`, + ); + } + } +} diff --git a/apps/desktop/src/main/lib/ssh/types.ts b/apps/desktop/src/main/lib/ssh/types.ts new file mode 100644 index 00000000000..7322b755c73 --- /dev/null +++ b/apps/desktop/src/main/lib/ssh/types.ts @@ -0,0 +1,49 @@ +import type { Client, ClientChannel } from "ssh2"; + +export type SshConnectionState = + | "disconnected" + | "connecting" + | "connected" + | "reconnecting" + | "error"; + +export interface SshHostConfig { + id: string; + label: string; + hostname: string; + port: number; + username: string; + authMethod: "password" | "privateKey" | "agent"; + privateKeyPath?: string; + defaultDirectory?: string; +} + +export interface SshConnectionInfo { + hostId: string; + state: SshConnectionState; + client: Client | null; + error?: string; + reconnectAttempts: number; +} + +export interface SshSessionInfo { + paneId: string; + hostId: string; + channel: ClientChannel; + cwd: string; + createdAt: number; +} + +export interface SshConnectionEvents { + "state-change": ( + hostId: string, + state: SshConnectionState, + error?: string, + ) => void; + "session-data": (paneId: string, data: Buffer) => void; + "session-exit": ( + paneId: string, + code: number | null, + signal?: string, + ) => void; +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/index.ts b/apps/desktop/src/main/lib/workspace-runtime/index.ts index 1b7a962fe73..41a4829f3e7 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/index.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/index.ts @@ -15,9 +15,12 @@ export { LocalWorkspaceRuntime } from "./local"; export { + DefaultWorkspaceRuntimeRegistry, getWorkspaceRuntimeRegistry, resetWorkspaceRuntimeRegistry, } from "./registry"; +export { SshWorkspaceRuntime } from "./ssh"; +export { SshTerminalRuntime } from "./ssh-terminal-runtime"; export type { TerminalCapabilities, TerminalEventSource, diff --git a/apps/desktop/src/main/lib/workspace-runtime/registry.ts b/apps/desktop/src/main/lib/workspace-runtime/registry.ts index c39b7850c2c..f7519047738 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/registry.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/registry.ts @@ -13,7 +13,11 @@ * - Local + cloud workspaces can coexist */ +import { workspaces } from "@superset/local-db"; +import { isNull } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; import { LocalWorkspaceRuntime } from "./local"; +import { SshWorkspaceRuntime } from "./ssh"; import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types"; // ============================================================================= @@ -26,18 +30,54 @@ import type { WorkspaceRuntime, WorkspaceRuntimeRegistry } from "./types"; * Currently returns the same LocalWorkspaceRuntime for all workspaces. * The interface supports per-workspace selection for future cloud work. */ -class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry { +export class DefaultWorkspaceRuntimeRegistry + implements WorkspaceRuntimeRegistry +{ private localRuntime: LocalWorkspaceRuntime | null = null; + /** workspaceId → hostId for remote workspaces */ + private readonly remoteWorkspaceMap = new Map(); + + /** hostId → SshWorkspaceRuntime (cached per host) */ + private readonly sshRuntimes = new Map(); + + /** Whether hydrateRemoteWorkspaces() has been called */ + private hydrated = false; + + /** + * Populate remoteWorkspaceMap from the local-db on first access. + * This ensures remote workspaces are restored correctly after app restart. + */ + private hydrateRemoteWorkspaces(): void { + if (this.hydrated) return; + this.hydrated = true; + + const remoteWorkspaces = localDb + .select() + .from(workspaces) + .where(isNull(workspaces.deletingAt)) + .all() + .filter((w) => w.type === "remote" && w.sshHostId); + + for (const workspace of remoteWorkspaces) { + if (workspace.sshHostId) { + this.remoteWorkspaceMap.set(workspace.id, workspace.sshHostId); + } + } + } + /** * Get the runtime for a specific workspace. * - * Currently always returns the local runtime. - * Future: will check workspace metadata to select local vs cloud. + * Returns an SshWorkspaceRuntime when the workspace has been registered as + * remote via registerRemoteWorkspace(). Otherwise returns the local runtime. */ - getForWorkspaceId(_workspaceId: string): WorkspaceRuntime { - // Currently all workspaces use the local runtime - // Future: check workspace metadata for cloudWorkspaceId to select cloud runtime + getForWorkspaceId(workspaceId: string): WorkspaceRuntime { + this.hydrateRemoteWorkspaces(); + const hostId = this.remoteWorkspaceMap.get(workspaceId); + if (hostId) { + return this._getOrCreateSshRuntime(hostId); + } return this.getDefault(); } @@ -53,6 +93,34 @@ class DefaultWorkspaceRuntimeRegistry implements WorkspaceRuntimeRegistry { } return this.localRuntime; } + + /** + * Register a workspace as remote, associating it with an SSH host. + * Call this when a remote workspace is created or opened so that + * getForWorkspaceId() can return the correct SSH runtime. + */ + registerRemoteWorkspace(workspaceId: string, hostId: string): void { + this.remoteWorkspaceMap.set(workspaceId, hostId); + } + + /** + * Unregister a remote workspace mapping. + * Call this when a remote workspace is deleted or closed. + * Does not destroy the SshWorkspaceRuntime (the host may still have other workspaces). + */ + unregisterRemoteWorkspace(workspaceId: string): void { + this.remoteWorkspaceMap.delete(workspaceId); + } + + /** Get or create a cached SshWorkspaceRuntime for the given hostId. */ + private _getOrCreateSshRuntime(hostId: string): SshWorkspaceRuntime { + let runtime = this.sshRuntimes.get(hostId); + if (!runtime) { + runtime = new SshWorkspaceRuntime(hostId); + this.sshRuntimes.set(hostId, runtime); + } + return runtime; + } } // ============================================================================= diff --git a/apps/desktop/src/main/lib/workspace-runtime/ssh-terminal-runtime.ts b/apps/desktop/src/main/lib/workspace-runtime/ssh-terminal-runtime.ts new file mode 100644 index 00000000000..63ed21c0fea --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/ssh-terminal-runtime.ts @@ -0,0 +1,393 @@ +/** + * SSH Terminal Runtime + * + * Implements the TerminalRuntime interface backed by SshConnectionManager. + * Sessions are ephemeral (no persistence across app restart) and scoped + * to a single SSH host. + */ + +import { EventEmitter } from "node:events"; +import type { SshConnectionManager } from "../ssh/ssh-connection-manager"; +import type { CreateSessionParams, SessionResult } from "../terminal/types"; +import type { ListSessionsResponse } from "../terminal-host/types"; +import type { + TerminalCapabilities, + TerminalManagement, + TerminalRuntime, +} from "./types"; + +// ============================================================================= +// SSH Terminal Runtime +// ============================================================================= + +/** + * Terminal runtime backed by an SSH connection. + * + * Each instance is scoped to a single SSH host (identified by hostId). + * Sessions opened through this runtime map directly to SSH shell channels + * on that host. + * + * Capabilities: + * - persistent: false — sessions do not survive app restart + * - coldRestore: false — no on-disk scrollback to recover from + */ +export class SshTerminalRuntime + extends EventEmitter + implements TerminalRuntime +{ + private readonly hostId: string; + private readonly sshManager: SshConnectionManager; + + /** workspaceId → Set of paneIds registered under that workspace */ + private readonly workspacePanes = new Map>(); + + /** paneId → bound data listener (for detach cleanup) */ + private readonly dataListeners = new Map< + string, + (paneId: string, data: Buffer) => void + >(); + + /** paneId → bound exit listener (for detach cleanup) */ + private readonly exitListeners = new Map< + string, + (paneId: string, code: number | null, signal?: string) => void + >(); + + readonly management: TerminalManagement; + + readonly capabilities: TerminalCapabilities = { + persistent: false, + coldRestore: false, + }; + + constructor(hostId: string, sshManager: SshConnectionManager) { + super(); + this.hostId = hostId; + this.sshManager = sshManager; + + this.management = { + listSessions: () => this._listSessions(), + killAllSessions: () => this._killAllSessions(), + resetHistoryPersistence: async () => { + /* no-op: SSH runtime has no history persistence */ + }, + }; + } + + // =========================================================================== + // Session Operations + // =========================================================================== + + /** + * Create a new SSH shell session or attach to an existing one for the given paneId. + * + * Registers the pane under the workspace, wires up data/exit event forwarding, + * and returns an empty SessionResult (SSH has no scrollback snapshot). + */ + async createOrAttach(params: CreateSessionParams): Promise { + const { paneId, workspaceId, cols = 80, rows = 24, cwd } = params; + + // Register pane under workspace for workspace-scoped operations + this._registerPane(workspaceId, paneId); + + // If already open (re-attach), just wire up listeners again + const existing = this.sshManager.getSession(paneId); + if (!existing) { + await this.sshManager.openShell(this.hostId, paneId, { + cols, + rows, + cwd, + }); + } + + // Wire up event forwarding for this paneId + this._attachListeners(paneId); + + return { + isNew: !existing, + scrollback: "", + wasRecovered: false, + }; + } + + /** Write data to a session's stdin. */ + write({ paneId, data }: { paneId: string; data: string }): void { + try { + this.sshManager.writeToSession(paneId, data); + } catch (err) { + console.error( + `[SshTerminalRuntime] write error for pane ${paneId}:`, + err, + ); + } + } + + /** Resize the PTY for a session. */ + resize({ + paneId, + cols, + rows, + }: { + paneId: string; + cols: number; + rows: number; + }): void { + try { + this.sshManager.resizeSession(paneId, cols, rows); + } catch (err) { + console.error( + `[SshTerminalRuntime] resize error for pane ${paneId}:`, + err, + ); + } + } + + /** Send a signal to the session (e.g. "INT" for Ctrl+C). */ + signal({ paneId, signal }: { paneId: string; signal?: string }): void { + try { + this.sshManager.signalSession(paneId, signal ?? "INT"); + } catch (err) { + console.error( + `[SshTerminalRuntime] signal error for pane ${paneId}:`, + err, + ); + } + } + + /** Kill the session channel. */ + async kill({ paneId }: { paneId: string }): Promise { + this._detachListeners(paneId); + try { + this.sshManager.closeSession(paneId); + } catch (err) { + console.error(`[SshTerminalRuntime] kill error for pane ${paneId}:`, err); + } + this._unregisterPane(paneId); + } + + /** + * Detach from the session — removes local event listeners but keeps the + * SSH channel alive on the remote host. + */ + detach({ paneId }: { paneId: string }): void { + this._detachListeners(paneId); + } + + /** + * Clear scrollback — no-op for SSH (no headless emulator). + */ + clearScrollback(_params: { paneId: string }): void { + /* no-op: SSH runtime has no headless emulator */ + } + + /** + * Acknowledge cold restore — no-op (cold restore not supported). + */ + ackColdRestore(_paneId: string): void { + /* no-op: SSH runtime does not support cold restore */ + } + + /** + * Get session metadata for a pane, or null if not found. + */ + getSession( + paneId: string, + ): { isAlive: boolean; cwd: string; lastActive: number } | null { + const session = this.sshManager.getSession(paneId); + if (!session) return null; + return { + isAlive: true, + cwd: session.cwd, + lastActive: session.createdAt, + }; + } + + // =========================================================================== + // Workspace Operations + // =========================================================================== + + /** + * Kill all sessions belonging to a workspace. + * Returns counts of killed and failed sessions. + */ + async killByWorkspaceId( + workspaceId: string, + ): Promise<{ killed: number; failed: number }> { + const paneIds = this.workspacePanes.get(workspaceId); + if (!paneIds || paneIds.size === 0) return { killed: 0, failed: 0 }; + + let killed = 0; + let failed = 0; + + for (const paneId of [...paneIds]) { + try { + await this.kill({ paneId }); + killed++; + } catch (err) { + console.error( + `[SshTerminalRuntime] killByWorkspaceId: failed to kill pane ${paneId}:`, + err, + ); + failed++; + } + } + + return { killed, failed }; + } + + /** + * Count alive sessions for a workspace. + */ + async getSessionCountByWorkspaceId(workspaceId: string): Promise { + const paneIds = this.workspacePanes.get(workspaceId); + if (!paneIds) return 0; + let count = 0; + for (const paneId of paneIds) { + if (this.sshManager.getSession(paneId)) count++; + } + return count; + } + + /** + * Send a newline to all sessions for a workspace to refresh shell prompts. + */ + refreshPromptsForWorkspace(workspaceId: string): void { + const paneIds = this.workspacePanes.get(workspaceId); + if (!paneIds) return; + for (const paneId of paneIds) { + try { + this.sshManager.writeToSession(paneId, "\n"); + } catch { + // ignore — session may have already exited + } + } + } + + // =========================================================================== + // Event Source + // =========================================================================== + + /** Remove all terminal-specific listeners from this emitter. */ + detachAllListeners(): void { + for (const paneId of this.dataListeners.keys()) { + this._detachListeners(paneId); + } + this.removeAllListeners(); + } + + // =========================================================================== + // Lifecycle + // =========================================================================== + + /** Close all sessions for this host and clean up. */ + async cleanup(): Promise { + for (const session of this.sshManager.getSessionsByHost(this.hostId)) { + this._detachListeners(session.paneId); + try { + this.sshManager.closeSession(session.paneId); + } catch { + // ignore + } + } + this.workspacePanes.clear(); + this.removeAllListeners(); + } + + // =========================================================================== + // Private helpers + // =========================================================================== + + /** Wire up data/exit forwarding from sshManager events for a paneId. */ + private _attachListeners(paneId: string): void { + // Remove stale listeners first to avoid duplicates on re-attach + this._detachListeners(paneId); + + const dataListener = (id: string, data: Buffer) => { + if (id === paneId) { + this.emit(`data:${paneId}`, data.toString()); + } + }; + + const exitListener = (id: string, code: number | null, signal?: string) => { + if (id === paneId) { + this.emit(`exit:${paneId}`, code, signal); + // Clean up our own tracking — session is gone + this._detachListeners(paneId); + this._unregisterPane(paneId); + } + }; + + this.sshManager.on("session-data", dataListener); + this.sshManager.on("session-exit", exitListener); + + this.dataListeners.set(paneId, dataListener); + this.exitListeners.set(paneId, exitListener); + } + + /** Remove data/exit forwarding listeners for a paneId. */ + private _detachListeners(paneId: string): void { + const dataListener = this.dataListeners.get(paneId); + if (dataListener) { + this.sshManager.off("session-data", dataListener); + this.dataListeners.delete(paneId); + } + + const exitListener = this.exitListeners.get(paneId); + if (exitListener) { + this.sshManager.off("session-exit", exitListener); + this.exitListeners.delete(paneId); + } + } + + /** Register a paneId under a workspaceId. */ + private _registerPane(workspaceId: string, paneId: string): void { + let panes = this.workspacePanes.get(workspaceId); + if (!panes) { + panes = new Set(); + this.workspacePanes.set(workspaceId, panes); + } + panes.add(paneId); + } + + /** Remove a paneId from all workspace mappings. */ + private _unregisterPane(paneId: string): void { + for (const [workspaceId, panes] of this.workspacePanes) { + panes.delete(paneId); + if (panes.size === 0) { + this.workspacePanes.delete(workspaceId); + } + } + } + + /** List all sessions for this host in ListSessionsResponse format. */ + private async _listSessions(): Promise { + const sessions = this.sshManager.getSessionsByHost(this.hostId); + return { + sessions: sessions.map((s) => ({ + sessionId: s.paneId, + workspaceId: this._findWorkspaceForPane(s.paneId) ?? "", + paneId: s.paneId, + isAlive: true, + attachedClients: 1, + pid: null, + createdAt: new Date(s.createdAt).toISOString(), + })), + }; + } + + /** Kill all sessions for this host. */ + private async _killAllSessions(): Promise { + const sessions = this.sshManager.getSessionsByHost(this.hostId); + for (const session of sessions) { + await this.kill({ paneId: session.paneId }); + } + } + + /** Reverse-lookup workspaceId for a paneId. */ + private _findWorkspaceForPane(paneId: string): string | undefined { + for (const [workspaceId, panes] of this.workspacePanes) { + if (panes.has(paneId)) return workspaceId; + } + return undefined; + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/ssh.ts b/apps/desktop/src/main/lib/workspace-runtime/ssh.ts new file mode 100644 index 00000000000..3566eb9baa6 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/ssh.ts @@ -0,0 +1,39 @@ +/** + * SSH Workspace Runtime + * + * Implements the WorkspaceRuntime interface for SSH-connected remote workspaces. + * Each instance is scoped to a single SSH host identified by hostId. + */ + +import { getSshConnectionManager } from "../ssh"; +import { SshTerminalRuntime } from "./ssh-terminal-runtime"; +import type { + TerminalRuntime, + WorkspaceRuntime, + WorkspaceRuntimeId, +} from "./types"; + +// ============================================================================= +// SSH Workspace Runtime +// ============================================================================= + +/** + * Workspace runtime for remote SSH hosts. + * + * Wraps SshTerminalRuntime and exposes it through the WorkspaceRuntime interface. + * The runtime id is `ssh-${hostId}` to ensure uniqueness across hosts. + */ +export class SshWorkspaceRuntime implements WorkspaceRuntime { + readonly id: WorkspaceRuntimeId; + readonly terminal: TerminalRuntime; + readonly capabilities: WorkspaceRuntime["capabilities"]; + + constructor(hostId: string) { + this.id = `ssh-${hostId}`; + const sshManager = getSshConnectionManager(); + this.terminal = new SshTerminalRuntime(hostId, sshManager); + this.capabilities = { + terminal: this.terminal.capabilities, + }; + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/types.ts b/apps/desktop/src/main/lib/workspace-runtime/types.ts index 33f22782a0c..1135b23f6bb 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/types.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/types.ts @@ -224,4 +224,17 @@ export interface WorkspaceRuntimeRegistry { * Used by settings screens and endpoints that don't have workspace context. */ getDefault(): WorkspaceRuntime; + + /** + * Register a workspace as remote, associating it with an SSH host. + * Call this when a remote workspace is created so that + * getForWorkspaceId() returns the correct SSH runtime. + */ + registerRemoteWorkspace(workspaceId: string, hostId: string): void; + + /** + * Unregister a remote workspace mapping. + * Call this when a remote workspace is deleted. + */ + unregisterRemoteWorkspace(workspaceId: string): void; } diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModalDraftContext.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModalDraftContext.tsx index c72dc7d6d1c..a6182e62f76 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModalDraftContext.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModalDraftContext.tsx @@ -12,7 +12,8 @@ export type NewWorkspaceModalTab = | "prompt" | "issues" | "pull-requests" - | "branches"; + | "branches" + | "ssh-remote"; export interface NewWorkspaceModalDraft { activeTab: NewWorkspaceModalTab; @@ -27,6 +28,8 @@ export interface NewWorkspaceModalDraft { issuesQuery: string; pullRequestsQuery: string; branchesQuery: string; + sshHostId: string | null; + remotePath: string; } interface NewWorkspaceModalDraftState extends NewWorkspaceModalDraft { @@ -46,6 +49,8 @@ const initialDraft: NewWorkspaceModalDraft = { issuesQuery: "", pullRequestsQuery: "", branchesQuery: "", + sshHostId: null, + remotePath: "", }; function buildInitialDraftState(): NewWorkspaceModalDraftState { @@ -149,6 +154,8 @@ export function NewWorkspaceModalDraftProvider({ issuesQuery: state.issuesQuery, pullRequestsQuery: state.pullRequestsQuery, branchesQuery: state.branchesQuery, + sshHostId: state.sshHostId, + remotePath: state.remotePath, }, draftVersion: state.draftVersion, closeModal: onClose, diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/NewWorkspaceModalContent/NewWorkspaceModalContent.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/NewWorkspaceModalContent/NewWorkspaceModalContent.tsx index 13f1103a952..2676db86c7d 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/NewWorkspaceModalContent/NewWorkspaceModalContent.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/NewWorkspaceModalContent/NewWorkspaceModalContent.tsx @@ -11,6 +11,7 @@ import { IssuesGroup } from "../IssuesGroup"; import { ProjectSelector } from "../ProjectSelector"; import { PromptGroup } from "../PromptGroup"; import { PullRequestsGroup } from "../PullRequestsGroup"; +import { SshRemoteGroup } from "../SshRemoteGroup"; const COMMAND_CLASS_NAME = "[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 flex h-full w-full flex-1 flex-col overflow-hidden rounded-none"; @@ -68,7 +69,8 @@ export function NewWorkspaceModalContent({ const selectedProject = recentProjects.find( (project) => project.id === draft.selectedProjectId, ); - const isListTab = draft.activeTab !== "prompt"; + const isListTab = + draft.activeTab !== "prompt" && draft.activeTab !== "ssh-remote"; const listQuery = draft.activeTab === "issues" ? draft.issuesQuery @@ -106,6 +108,7 @@ export function NewWorkspaceModalContent({ Issues Pull requests Branches + SSH Remote - {isListTab ? ( + {draft.activeTab === "ssh-remote" ? ( +
+ +
+ ) : isListTab ? ( { + setIsConnected(true); + setPassword(""); + }, + onError: () => { + setIsConnected(false); + }, + }); + + const createRemoteWorkspace = useCreateRemoteWorkspace(); + + const selectedHost = sshHosts.find((host) => host.id === sshHostId); + + const handleHostChange = (value: string) => { + if (value === "__add_new__") { + return; + } + setIsConnected(false); + updateDraft({ sshHostId: value, remotePath: "" }); + }; + + const handleConnect = () => { + if (!sshHostId) return; + setIsConnecting(true); + void connectMutation + .mutateAsync({ id: sshHostId, password: password || undefined }) + .finally(() => { + setIsConnecting(false); + }); + }; + + const handleCreate = () => { + if (!sshHostId || !remotePath.trim() || !projectId) return; + void runAsyncAction( + createRemoteWorkspace.mutateAsync({ + projectId, + sshHostId, + remotePath: remotePath.trim(), + prompt: prompt.trim() || undefined, + }), + { + loading: "Creating remote workspace...", + success: "Remote workspace created", + error: (err) => + err instanceof Error ? err.message : "Failed to create workspace", + }, + ); + }; + + return ( +
+
+ + +
+ + {sshHostId && ( +
+ + {isConnected ? ( + + + Connected + + ) : ( +
+ setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleConnect(); + }} + /> + +
+ )} +
+ )} + + {isConnected && ( +
+ + updateDraft({ remotePath: e.target.value })} + /> +
+ )} + + {isConnected && ( +
+ +