Skip to content

Conversation

@celicoo
Copy link

@celicoo celicoo commented Jan 17, 2026

This PR adds SSH remote workspace support to the Superset desktop app, similar to VS Code Remote SSH functionality. Users can now:

  • Import SSH hosts from their ~/.ssh/config file
  • Connect to remote servers using key-based or SSH agent authentication
  • Open remote projects and work with them through SSH terminal sessions

Features

  • SSH Config Import: Automatically parse and import hosts from ~/.ssh/config
  • SSH Connection Management: Manage connections with support for key and agent auth
  • Remote Projects: Track remote project paths and associate them with SSH connections
  • Remote Workspaces: Create workspaces that connect via SSH terminals
  • Terminal Routing: Seamlessly route terminal operations to local or SSH backend
  • Cross-Platform: Works on macOS, Linux, and Windows

Implementation

New Components

  • SSH tRPC router for connection management and config parsing
  • SSH client and terminal manager using ssh2 library
  • SSH workspace runtime adapter for the workspace runtime registry
  • SSH settings page for managing connections
  • Remote project dialog in the Start view

Key Changes

  • Terminal router now routes operations based on workspace type (local vs SSH)
  • Workspace queries check both local and remote workspace tables
  • Stream subscription accepts workspaceId for correct terminal routing
  • Added ssh_connections, remote_projects, and remote_workspaces database tables

Windows Support

  • SSH agent authentication uses Windows OpenSSH named pipe (\\.\pipe\openssh-ssh-agent)
  • Auto-detects default SSH keys (id_ed25519, id_rsa, id_ecdsa)
  • Cross-platform path handling for tilde expansion

Test Plan

  • Import SSH hosts from ~/.ssh/config
  • Connect to a remote server via SSH
  • Open a remote project and verify terminal works
  • Verify terminal input/output flows correctly over SSH
  • Test reconnection after disconnect
  • Test with various SSH authentication methods
  • Test on Windows with OpenSSH agent

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Manage SSH servers in Settings: add, test, delete, and import hosts from your SSH config.
    • Open remote projects/workspaces via an Open Remote dialog and Remote Projects list.
    • Per-pane remote terminals with connect/disconnect, session controls (write, resize, signals) and session lifecycle.
  • Chores

    • Local database and runtime now persist SSH connections, remote projects, and remote workspaces.

✏️ Tip: You can customize this high-level summary in your review settings.

Add the ability to connect to remote servers via SSH and open remote
workspaces, similar to VS Code Remote SSH functionality.

## Features

- **SSH Config Import**: Parse and import hosts from ~/.ssh/config file
- **SSH Connection Management**: Connect to remote servers using key-based
  or SSH agent authentication
- **Remote Projects**: Track remote project paths and associate them with
  SSH connections
- **Remote Workspaces**: Create workspaces for remote projects with SSH
  terminal sessions
- **Terminal Routing**: Route terminal operations (create, write, resize,
  stream) to the appropriate backend (local or SSH) based on workspace type

## Implementation Details

### New Files
- `apps/desktop/src/lib/trpc/routers/ssh/` - SSH tRPC router for connection
  management, config parsing, and remote project operations
- `apps/desktop/src/main/lib/ssh/` - SSH client, terminal manager, and
  config parser using ssh2 library
- `apps/desktop/src/main/lib/workspace-runtime/ssh.ts` - SSH workspace
  runtime adapter
- `apps/desktop/src/renderer/routes/_authenticated/settings/ssh/` - SSH
  settings page for managing connections
- `apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx` -
  Dialog for connecting to SSH hosts

### Modified Files
- Terminal router now routes operations to SSH or local backend based on
  workspace type using a paneId->workspaceId mapping
- Workspace query now checks both local and remote workspace tables
- StartView displays remote projects and allows opening them
- Stream subscription accepts workspaceId for correct terminal routing

### Database
- Added ssh_connections, remote_projects, and remote_workspaces tables

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@coderabbitai
Copy link

coderabbitai bot commented Jan 17, 2026

📝 Walkthrough

Walkthrough

Adds end-to-end SSH support: dependency, DB migration and schema, SSH client/parser/terminal manager, workspace-runtime SSH integration, tRPC SSH router and terminal routing changes, and renderer UI for SSH settings and remote-project flows.

Changes

Cohort / File(s) Summary
Dependencies
\apps/desktop/package.json``
Add ssh2 dependency and @types/ssh2 devDependency; update build copy/unpack for native modules (ssh2, cpu-features) and Vite externals.
DB migrations & snapshots
\packages/local-db/drizzle/0012_add_ssh_tables.sql`, `packages/local-db/drizzle/meta/0012_snapshot.json`, `packages/local-db/drizzle/meta/_journal.json``
Add ssh_connections, remote_projects, remote_workspaces tables, snapshot and journal entry.
DB schema & zod
\packages/local-db/src/schema/schema.ts`, `packages/local-db/src/schema/zod.ts``
Export new drizzle tables and types; add Zod schema and SSHAuthMethod / SSHConnectionConfig types.
SSH core — types & barrel
\apps/desktop/src/main/lib/ssh/types.ts`, `apps/desktop/src/main/lib/ssh/index.ts``
New SSH public types and barrel re-export for SSH client, terminal manager, and config parser utilities.
SSH core — client & parser
\apps/desktop/src/main/lib/ssh/ssh-client.ts`, `apps/desktop/src/main/lib/ssh/ssh-config-parser.ts``
Add SSHClient (ssh2-backed connection, channels, reconnection, path/auth handling) and SSH config parser + conversion helpers.
SSH core — terminal manager
\apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts`, `apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts``
Add SSHTerminalManager implementing per-pane SSH sessions, I/O, events, lifecycle, and workspace-scoped operations.
Workspace runtime & registry
\apps/desktop/src/main/lib/workspace-runtime/registry.ts`, `apps/desktop/src/main/lib/workspace-runtime/ssh.ts``
Extend registry for SSH/cloud runtimes, add SSHWorkspaceRuntime and SSHTerminalRuntime adapter; per-workspace SSH registration and lifecycle APIs.
tRPC — SSH router & exposure
\apps/desktop/src/lib/trpc/routers/ssh/index.ts`, `apps/desktop/src/lib/trpc/routers/index.ts``
New SSH router with CRUD, connect/test/import, remoteProjects/remoteWorkspaces endpoints; expose ssh route on AppRouter.
Terminal router & utils
\apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`, `apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts`, `apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.test.ts`, `apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts``
Route terminal ops to SSH/local runtimes via registry; add pane→workspace mapping; change stream input to object { paneId, workspaceId? }; resolveCwd supports POSIX remote semantics and isRemote flag; tests updated.
Workspace query
\apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts``
get falls back to remote_workspaces, returns type: "remote", worktreePath, project, and sshConnection for remote workspaces; local responses include sshConnection: null.
Renderer — SSH UI & remote flows
\apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx`, `apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx`, `apps/desktop/src/renderer/screens/main/components/StartView/index.tsx`, `apps/desktop/src/renderer/screens/.../Terminal/Terminal.tsx``
Add SSH Settings page (list/create/test/import hosts), OpenRemoteDialog for opening remote projects (connect → createRemoteProject → createRemoteWorkspace), StartView remote projects UI, and update terminal subscription usage to useSubscription({ paneId, workspaceId }, ...).
Tests & callsites
\apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts`, `apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.test.ts``
Update tests to new stream input object and new resolveCwd signature.
Misc main/process changes
\apps/desktop/src/main/index.ts`, `apps/desktop/src/main/windows/main.ts`, `apps/desktop/electron-builder.ts`, `apps/desktop/electron.vite.config.ts`, `apps/desktop/scripts/copy-native-modules.ts``
Protocol registration tweaks (dev/prod handling), add webSecurity flag for BrowserWindow, include ssh2/cpu-features in build copy/unpack and Vite externals.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as Settings / OpenRemoteDialog
    participant tRPC as tRPC SSH Router
    participant DB as Local DB
    participant Registry as WorkspaceRuntimeRegistry
    participant Runtime as SSHWorkspaceRuntime / SSHTerminalManager
    participant Client as SSHClient

    User->>UI: select connection + remote path (or import)
    UI->>tRPC: ssh.connect(connectionId)
    tRPC->>DB: fetch ssh connection record
    DB-->>tRPC: connection config
    tRPC->>Registry: getSSHRuntime(config)
    Registry->>Runtime: create or return runtime for config
    Runtime->>Client: SSHClient.connect()
    Client-->>Runtime: connected / events
    UI->>tRPC: ssh.remoteProjects.create(...)
    tRPC->>DB: insert remote_project
    DB-->>tRPC: created row
    UI->>tRPC: ssh.remoteWorkspaces.create(...)
    tRPC->>DB: insert remote_workspace
    DB-->>tRPC: created row
    tRPC->>Registry: registerSSHWorkspace(workspaceId, sshConnectionId)
    Registry-->>UI: success (navigate to workspace)
Loading
sequenceDiagram
    participant Renderer as Terminal Renderer
    participant tRPC as tRPC Terminal Router
    participant Registry as WorkspaceRuntimeRegistry
    participant Runtime as SSHWorkspaceRuntime / LocalRuntime
    participant Manager as SSHTerminalManager / LocalTerminalManager
    participant Client as SSHClient

    Renderer->>tRPC: subscribe stream.useSubscription({ paneId, workspaceId })
    tRPC->>Registry: getForWorkspaceId(workspaceId)
    alt workspace is SSH
        Registry-->>tRPC: SSHWorkspaceRuntime
        tRPC->>Runtime: delegate stream for paneId
        Runtime->>Manager: route to SSHTerminalManager
        Manager->>Client: channel events (data/exit/error)
        Client-->>Manager: data
        Manager-->>tRPC: forward data event
    else local workspace
        Registry-->>tRPC: LocalRuntime
        LocalRuntime->>Manager: forward data event
        Manager-->>tRPC: forward data
    end
    tRPC-->>Renderer: data event
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • AviPeltz

Poem

🐰
I nibbled keys by lantern light,
Tunnels hummed and tabs took flight,
Across the burrow reached a tree,
My terminal now hops to me,
A rabbit cheers — remote work feels bright! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(desktop): add SSH remote workspace support' clearly and concisely describes the main change—adding SSH remote workspace functionality to the desktop application.
Description check ✅ Passed The PR description is comprehensive and well-structured. It includes a clear overview, detailed features, implementation details, Windows support specifics, and a test plan with completion status, exceeding the template's basic requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts (1)

124-190: Pass isRemote flag to resolveCwd and use path.posix for remote workspace paths.

When workspacePath is a POSIX remote path (e.g., /home/user/...), the current implementation will fail on Windows because node:path methods use platform-specific separators. On Windows, join("/home/user/project", "apps") produces a mangled path with backslashes, causing existsSync() to fail and the function to fall back to the base workspacePath, losing relative path resolution.

Pass the isRemote flag to resolveCwd and conditionally use path.posix for remote workspaces:

const cwd = resolveCwd(cwdOverride, workspacePath, isRemote);

Then in resolve-cwd.ts, import and use path.posix when isRemote is true to preserve POSIX path semantics.

🤖 Fix all issues with AI agents
In `@apps/desktop/src/main/lib/ssh/ssh-client.ts`:
- Around line 292-295: The code currently interpolates cwd directly into a shell
command via channel.write(`cd ${JSON.stringify(cwd)} && clear\n`), which risks
shell injection; update the logic in ssh-client.ts to avoid injecting raw cwd
into the shell by either validating/whitelisting the cwd (e.g., ensure it's an
absolute path with no shell metacharacters) or by using a proper escaping
function (e.g., shell-escape or similar) before interpolation, or alternately
change the approach to execute a safe built-in cd operation on the remote
channel (send a separate, validated exec rather than building a combined
command); modify the code around the cwd check and the channel.write call so
only a safely-escaped or validated path is sent to the remote shell.

In `@apps/desktop/src/main/lib/ssh/ssh-config-parser.ts`:
- Around line 77-78: The port parsing in the ssh-config-parser switch (case
"port") uses parseInt(value, 10) and may assign NaN to currentHost.port for
invalid input; update the logic in ssh-config-parser.ts where currentHost.port
is set so you parse the value, verify it's a finite integer within the valid
port range (1–65535) using Number.isInteger or Number.isFinite checks, and only
assign it when valid (otherwise leave unset or apply a safe default/emit a
warning) to avoid storing NaN in currentHost.port.

In `@apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts`:
- Around line 163-184: The per-pane sshClient.on handlers (data:${paneId},
exit:${paneId}, error:${paneId}) are added for each session but never removed,
causing listener leaks and duplicate events; modify the session creation in
ssh-terminal-manager to store the actual listener functions (e.g., dataHandler,
exitHandler, errorHandler) on the Session object (or a handlers map keyed by
paneId) and call sshClient.off/removeListener for those handlers when a session
ends (in the existing exit/kill/cleanup logic and the exit:${paneId} handler),
ensuring you detach data:${paneId}, exit:${paneId}, and error:${paneId} to
prevent MaxListenersExceededWarning and duplicate emissions.
- Around line 302-310: The catch block in the loop that calls
this.sshClient.killChannel(paneId) is swallowing errors; update the catch to
capture the exception (e) and log a meaningful error including paneId and
workspaceId and the error details so failures are diagnosable (e.g., use
this.logger.error or the module logger in this file to log "Failed to kill SSH
channel" with paneId, workspaceId and the error/stack), then increment failed as
before.

In `@apps/desktop/src/main/lib/workspace-runtime/registry.ts`:
- Around line 177-192: The cleanupAll implementation can abort cleaning
remaining SSH runtimes if any runtime.terminal.cleanup() throws; update
cleanupAll to iterate over this.sshRuntimes and for each [id, runtime] call
runtime.terminal.cleanup() inside a try/catch so errors are logged but do not
break the loop, and ensure runtime.disconnect() is invoked in a finally block
(or after catching) so connections are always torn down even when cleanup fails;
preserve the final clearing of this.sshRuntimes, this.sshConfigs, and
this.workspaceToSSH after the loop.
- Around line 156-172: disconnectSSHRuntime can fail if
runtime.terminal.cleanup() or runtime.disconnect() throws, leaving sshRuntimes,
sshConfigs and workspaceToSSH in an inconsistent state; wrap the
cleanup/disconnect calls for the found runtime in a try/catch/finally: perform
runtime.terminal.cleanup() and runtime.disconnect() inside try, catch and log
the error with its details (do not swallow silently), and in finally always
delete this.sshRuntimes.delete(sshConnectionId),
this.sshConfigs.delete(sshConnectionId) and remove entries from
this.workspaceToSSH so cleanup always runs; after logging, rethrow the error so
callers can handle it. Use the existing function and symbols:
disconnectSSHRuntime, runtime.terminal.cleanup, runtime.disconnect, sshRuntimes,
sshConfigs, workspaceToSSH when making the change.

In
`@apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx`:
- Around line 25-33: The onSuccess handler for createRemoteProject is hardcoding
the branch to "main"; change it to use the returned project's defaultBranch by
passing branch: project.defaultBranch || "main" when calling
createRemoteWorkspace.mutate so the workspace uses the actual default branch.
Locate the createRemoteProject mutation's onSuccess callback and update the
branch field in the createRemoteWorkspace.mutate call (reference:
createRemoteProject, createRemoteWorkspace, project.defaultBranch).

In `@packages/local-db/drizzle/meta/0011_snapshot.json`:
- Around line 4-5: The snapshot 0011 has a duplicated "id" value (currently
matching the previous snapshot) which must be unique; update the "id" field in
this snapshot's JSON to a new UUID while keeping "prevId" set to the existing
previous snapshot id (the current value of "prevId" should remain unchanged) so
the migration chain remains intact—regenerate a UUID for the "id" key in the
0011 snapshot JSON and save.
🧹 Nitpick comments (16)
apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx (4)

158-162: Move existingNames Set computation outside the map callback.

The existingNames Set is recreated for every host in the list. This should be computed once before mapping.

♻️ Suggested refactor
 <div className="max-h-64 overflow-y-auto space-y-2">
-	{configHosts.map((host) => {
-		const existingNames = new Set(
-			(connections ?? []).map((c) => c.name.toLowerCase()),
-		);
-		const alreadyExists = existingNames.has(host.name.toLowerCase());
+	{(() => {
+		const existingNames = new Set(
+			(connections ?? []).map((c) => c.name.toLowerCase()),
+		);
+		return configHosts.map((host) => {
+			const alreadyExists = existingNames.has(host.name.toLowerCase());

Or better yet, use useMemo at the component level:

const existingConnectionNames = useMemo(
	() => new Set((connections ?? []).map((c) => c.name.toLowerCase())),
	[connections],
);

Then use existingConnectionNames.has(host.name.toLowerCase()) inside the map.


313-320: Consider adding a confirmation dialog before deleting connections.

Deleting an SSH connection is a destructive action that could disrupt workflows if done accidentally. A confirmation step would improve the user experience and prevent accidental deletions.

Additionally, deleteConnection.isPending disables all delete buttons when any deletion is in progress. If you want per-row disabled state, you'd need to track which connection is being deleted (similar to testingId).


364-375: Add validation for the port number.

parseInt(port, 10) can return NaN if the input is empty or invalid. While the required attribute and type="number" help, edge cases could slip through (e.g., submitting before the browser validates). Consider adding explicit validation.

♻️ Suggested validation
 const handleSubmit = (e: React.FormEvent) => {
 	e.preventDefault();
+	const portNum = parseInt(port, 10);
+	if (Number.isNaN(portNum) || portNum < 1 || portNum > 65535) {
+		// Handle invalid port - could set error state or show toast
+		return;
+	}
 	onSubmit({
 		name,
 		host,
-		port: parseInt(port, 10),
+		port: portNum,
 		username,
 		authMethod,
 		privateKeyPath: authMethod === "key" ? privateKeyPath : undefined,
 		remoteWorkDir: remoteWorkDir || undefined,
 	});
 };

339-482: Consider extracting AddConnectionForm to a separate file.

Per coding guidelines, each file should contain one component. While AddConnectionForm is a private helper tightly coupled to this page, extracting it to a sibling file (e.g., add-connection-form.tsx) would improve testability and align with the project convention.

This is a nitpick given the component is not exported and only used here.

packages/local-db/src/schema/zod.ts (2)

122-133: Constrain SSH numeric fields to valid ranges.

port, keepAliveInterval, and connectionTimeout currently accept negative/non-int values. Tightening these avoids invalid configs propagating to the runtime. Also, extracting the default port improves clarity. As per coding guidelines, extract hardcoded values to named constants.

♻️ Proposed refinement
+const DEFAULT_SSH_PORT = 22;
+
 export const sshConnectionConfigSchema = z.object({
 	id: z.string(),
 	name: z.string(),
 	host: z.string(),
-	port: z.number().default(22),
+	port: z.number().int().min(1).max(65535).default(DEFAULT_SSH_PORT),
 	username: z.string(),
 	authMethod: z.enum(SSH_AUTH_METHODS),
 	privateKeyPath: z.string().optional(),
 	agentForward: z.boolean().optional(),
 	remoteWorkDir: z.string().optional(),
-	keepAliveInterval: z.number().optional(),
-	connectionTimeout: z.number().optional(),
+	keepAliveInterval: z.number().int().positive().optional(),
+	connectionTimeout: z.number().int().positive().optional(),
 });

115-118: Avoid duplicating SSHAuthMethod/SSHConnectionConfig definitions.

There’s a parallel SSHAuthMethod and SSHConnectionConfig in apps/desktop/src/main/lib/ssh/types.ts (Line 10–38). Consider sharing or re-exporting to prevent drift.

apps/desktop/src/renderer/screens/main/components/StartView/OpenRemoteDialog.tsx (1)

113-120: Reset dialog state on all close paths.

The “Configure SSH Servers” and “Manage Servers” links call onClose() without resetting form state, so stale inputs can reappear when reopening. Consider a shared handleClose() that also calls resetForm().

♻️ Suggested tweak
+	const handleClose = () => {
+		onClose();
+		resetForm();
+	};

...
-						<Link
+						<Link
 							to="/settings/ssh"
 							className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-foreground text-background hover:bg-foreground/90 transition-colors text-sm font-medium"
-							onClick={onClose}
+							onClick={handleClose}
 						>
...
-							<Link
+							<Link
 								to="/settings/ssh"
 								className="px-4 py-2 rounded-md text-muted-foreground hover:text-foreground transition-colors text-sm"
-								onClick={onClose}
+								onClick={handleClose}
 							>
...
-								<button
+								<button
 									type="button"
 									onClick={() => {
-										onClose();
-										resetForm();
+										handleClose();
 									}}

Also applies to: 191-204

apps/desktop/src/main/lib/ssh/ssh-config-parser.ts (1)

87-89: proxyJump is parsed but never used.

The proxyJump field is captured from the SSH config but convertToConnectionConfigs doesn't include it in the output. If proxy jump support is intended for a future iteration, consider adding a TODO comment; otherwise, remove the dead code.

apps/desktop/src/lib/trpc/routers/ssh/index.ts (3)

151-155: Manual deletion may be redundant due to cascade.

The remoteProjects table has onDelete: "cascade" on the sshConnectionId foreign key (see schema.ts lines 192-194), so deleting the connection should automatically cascade to projects and workspaces. This manual deletion is defensive but redundant.

Optional: rely on cascade
 // Disconnect the runtime if active
 await registry.disconnectSSHRuntime(input.id);

-// Delete associated remote projects (cascades to remote workspaces)
-localDb
-  .delete(remoteProjects)
-  .where(eq(remoteProjects.sshConnectionId, input.id))
-  .run();
-
 // Delete the connection
 localDb
   .delete(sshConnections)
   .where(eq(sshConnections.id, input.id))
   .run();

597-610: Default branch "main" is hardcoded.

When creating a default workspace, the branch is hardcoded to "main". Consider using project.defaultBranch if available, falling back to "main".

Proposed fix
 // Create a default workspace
+const defaultBranch = project.defaultBranch || "main";
 workspace = localDb
   .insert(remoteWorkspaces)
   .values({
     remoteProjectId: project.id,
-    branch: "main",
-    name: "main",
+    branch: defaultBranch,
+    name: defaultBranch,
     tabOrder: 0,
   })
   .returning()
   .get();

483-489: Tab order calculation loads all workspaces into memory.

For projects with many workspaces, this could be inefficient. Consider using a SQL aggregate instead.

Proposed optimization
-// Get max tab order
-const maxOrder = localDb
-  .select()
-  .from(remoteWorkspaces)
-  .where(eq(remoteWorkspaces.remoteProjectId, input.remoteProjectId))
-  .all()
-  .reduce((max, ws) => Math.max(max, ws.tabOrder), -1);
+// Get max tab order using SQL aggregate
+import { max } from "drizzle-orm";
+
+const result = localDb
+  .select({ maxOrder: max(remoteWorkspaces.tabOrder) })
+  .from(remoteWorkspaces)
+  .where(eq(remoteWorkspaces.remoteProjectId, input.remoteProjectId))
+  .get();
+const maxOrder = result?.maxOrder ?? -1;
apps/desktop/src/main/lib/ssh/ssh-client.ts (1)

150-170: Consider removing once handlers on timeout to prevent memory leaks.

If the connection times out, the once handlers for ready and error remain attached. They should be removed on timeout to avoid potential issues.

Proposed fix
 return new Promise((resolve, reject) => {
+  const onReady = () => {
+    clearTimeout(timeout);
+    this.client.off("error", onError);
+    console.log(`[ssh/client] Connection ready`);
+    resolve();
+  };
+
+  const onError = (err: Error) => {
+    clearTimeout(timeout);
+    this.client.off("ready", onReady);
+    console.error(`[ssh/client] Connection error: ${err.message}`);
+    reject(err);
+  };
+
   const timeout = setTimeout(() => {
     console.error(`[ssh/client] Connection timeout after ${this.config.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT}ms`);
+    this.client.off("ready", onReady);
+    this.client.off("error", onError);
     this.client.end();
     reject(new Error("Connection timeout"));
   }, this.config.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT);

-  this.client.once("ready", () => {
-    clearTimeout(timeout);
-    console.log(`[ssh/client] Connection ready`);
-    resolve();
-  });
-
-  this.client.once("error", (err) => {
-    clearTimeout(timeout);
-    console.error(`[ssh/client] Connection error: ${err.message}`);
-    reject(err);
-  });
+  this.client.once("ready", onReady);
+  this.client.once("error", onError);

   this.client.connect(connectConfig);
 });
apps/desktop/src/main/lib/workspace-runtime/ssh.ts (1)

109-197: Consider simplifying EventEmitter delegation.

The EventEmitter interface delegation is verbose. If TerminalRuntime extends EventEmitter, you could potentially use composition differently or extend the backend directly. However, this explicit delegation is safe and works correctly.

apps/desktop/src/main/lib/workspace-runtime/registry.ts (3)

79-79: sshConfigs map is stored but never read.

The sshConfigs map is populated in getSSHRuntime and cleared in disconnectSSHRuntime/cleanupAll, but the stored configs are never retrieved or used elsewhere. Consider removing this dead state unless there's a planned use case.

♻️ Suggested removal
 class DefaultWorkspaceRuntimeRegistry implements ExtendedWorkspaceRuntimeRegistry {
 	private localRuntime: LocalWorkspaceRuntime | null = null;
 	private sshRuntimes: Map<string, SSHWorkspaceRuntime> = new Map();
 	private workspaceToSSH: Map<string, string> = new Map(); // workspaceId -> sshConnectionId
-	private sshConfigs: Map<string, SSHConnectionConfig> = new Map(); // sshConnectionId -> config

Then remove corresponding .set() and .delete() calls in getSSHRuntime, disconnectSSHRuntime, and cleanupAll.


86-95: Silent fallback when SSH runtime is missing could mask bugs.

If a workspace is registered as SSH (via registerSSHWorkspace) but the SSH runtime hasn't been created yet (via getSSHRuntime), this silently falls back to the local runtime. This could mask configuration issues where callers forget to initialize the SSH runtime before using the workspace.

Consider adding a warning log to aid debugging:

🔧 Suggested improvement
 	getForWorkspaceId(workspaceId: string): WorkspaceRuntime {
 		const sshConnectionId = this.workspaceToSSH.get(workspaceId);
 		if (sshConnectionId) {
 			const sshRuntime = this.sshRuntimes.get(sshConnectionId);
 			if (sshRuntime) {
 				return sshRuntime;
 			}
+			console.warn(`[registry] Workspace ${workspaceId} is registered for SSH ${sshConnectionId} but runtime not found, falling back to local`);
 		}
 		return this.getDefault();
 	}

135-137: Missing log statement for consistency.

registerSSHWorkspace logs the action, but unregisterSSHWorkspace does not. For debugging parity, consider adding a log here.

🔧 Suggested improvement
 	unregisterSSHWorkspace(workspaceId: string): void {
+		console.log(`[registry] Unregistering workspace ${workspaceId} from SSH`);
 		this.workspaceToSSH.delete(workspaceId);
 	}

- Add Windows SSH agent support using OpenSSH named pipe
  (\\.\pipe\openssh-ssh-agent) instead of SSH_AUTH_SOCK
- Auto-detect default SSH keys (id_ed25519, id_rsa, id_ecdsa)
  instead of hardcoding id_rsa
- Fix tilde expansion to handle both forward and back slashes
  for cross-platform path compatibility

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/desktop/src/main/lib/ssh/ssh-client.ts`:
- Around line 150-171: The promise in connect() currently registers a one-time
"error" handler while setupClientHandlers() registers a persistent "error"
handler, causing both the Promise rejection and attemptReconnect() to fire on
initial connection failure; modify the logic so initial connection errors do not
trigger the automatic reconnect: add a connection state flag (e.g., isConnected
or isInitialConnect) managed by connect() and setupClientHandlers(), have the
persistent this.client.on("error") handler call attemptReconnect() only when the
flag indicates an established connection (not during initial connect), and have
the once("error") in connect() still reject the promise for initial failures;
update connect(), setupClientHandlers(), and attemptReconnect() usages to
check/update that flag accordingly.
♻️ Duplicate comments (1)
apps/desktop/src/main/lib/ssh/ssh-client.ts (1)

265-326: Shell injection via cwd (lines 319-321) was flagged in a previous review.

The concern about shell metacharacter injection when interpolating cwd into the shell command remains valid. Please address the earlier feedback.

🧹 Nitpick comments (2)
apps/desktop/src/main/lib/ssh/ssh-client.ts (2)

100-126: Consider exponential backoff for reconnection attempts.

The fixed 2000ms delay between reconnection attempts could lead to aggressive reconnection behavior, especially if the server is temporarily overloaded. Exponential backoff is a common resilience pattern for this scenario.

♻️ Suggested exponential backoff implementation
+const BASE_RECONNECT_DELAY_MS = 1000;
+const MAX_RECONNECT_DELAY_MS = 30000;
-const RECONNECT_DELAY_MS = 2000;

 private attemptReconnect(): void {
   if (this.reconnectTimer) {
     return;
   }

   if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
     console.log(`[ssh/client] Max reconnect attempts reached`);
     this.state = "error";
     this.emitStatus("Max reconnect attempts reached");
     return;
   }

   this.state = "reconnecting";
   this.reconnectAttempts++;
   this.emitStatus();

+  const delay = Math.min(
+    BASE_RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1),
+    MAX_RECONNECT_DELAY_MS
+  );
   console.log(
-    `[ssh/client] Reconnecting in ${RECONNECT_DELAY_MS}ms (attempt ${this.reconnectAttempts})`,
+    `[ssh/client] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`,
   );

   this.reconnectTimer = setTimeout(() => {
     this.reconnectTimer = null;
     this.connect().catch((err) => {
       console.error(`[ssh/client] Reconnect failed:`, err.message);
     });
-  }, RECONNECT_DELAY_MS);
+  }, delay);
 }

331-356: Use object parameters for functions with 2+ arguments.

Per coding guidelines, functions with 2+ parameters should accept a single params object for self-documentation and extensibility. The write, resize, and signal methods use positional arguments.

♻️ Suggested refactor to object parameters
-  write(paneId: string, data: string): void {
+  write({ paneId, data }: { paneId: string; data: string }): void {
     const channel = this.channels.get(paneId);
     if (channel) {
       channel.write(data);
     }
   }

-  resize(paneId: string, cols: number, rows: number): void {
+  resize({ paneId, cols, rows }: { paneId: string; cols: number; rows: number }): void {
     const channel = this.channels.get(paneId);
     if (channel) {
       channel.setWindow(rows, cols, 0, 0);
     }
   }

-  signal(paneId: string, signalName: string): void {
+  signal({ paneId, signalName }: { paneId: string; signalName: string }): void {
     const channel = this.channels.get(paneId);
     if (channel) {
       channel.signal(signalName);
     }
   }

Based on coding guidelines, functions with 2+ parameters should use object parameters.

- Add path.normalize() after tilde expansion to ensure consistent
  path separators on Windows
- Verify Windows OpenSSH agent pipe exists before using it, with
  helpful error message if the ssh-agent service is not running

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/desktop/src/main/lib/ssh/ssh-client.ts`:
- Around line 342-367: The write, resize, and signal methods silently ignore
when this.channels.get(paneId) returns undefined; update each method (write,
resize, signal) to log a warning when the channel is missing instead of
swallowing the condition—include paneId and the attempted action/parameters
(e.g., cols/rows or signalName) in the message; use the class's existing logger
(e.g., this.logger or processLogger) to emit a warning so missing-channel cases
are visible during debugging.
♻️ Duplicate comments (3)
apps/desktop/src/main/lib/ssh/ssh-client.ts (3)

40-88: Solid event handler setup with one caveat.

The persistent handlers are well-structured. The keyboard-interactive handler correctly finishes with an empty array since password prompts aren't supported.

Note: The interaction between the persistent error handler (line 48) calling attemptReconnect() and the one-time error handler in connect() was already flagged in a previous review.


150-171: Double error handling concern already flagged.

The potential conflict between the one-time error handler here and the persistent error handler in setupClientHandlers() was addressed in a previous review comment.


329-332: Shell injection concern already flagged.

The cwd interpolation into a shell command was addressed in a previous review comment.

🧹 Nitpick comments (3)
apps/desktop/src/main/lib/ssh/ssh-client.ts (3)

100-126: Consider exponential backoff for reconnection.

The fixed 2-second delay works but exponential backoff would be more resilient against transient network issues, reducing load during extended outages.

♻️ Optional: Exponential backoff
+const BASE_RECONNECT_DELAY_MS = 1000;
+const MAX_RECONNECT_DELAY_MS = 30000;
-const RECONNECT_DELAY_MS = 2000;

 private attemptReconnect(): void {
   // ... existing guards ...

+  const delay = Math.min(
+    BASE_RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts),
+    MAX_RECONNECT_DELAY_MS
+  );

   console.log(
-    `[ssh/client] Reconnecting in ${RECONNECT_DELAY_MS}ms (attempt ${this.reconnectAttempts})`,
+    `[ssh/client] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`,
   );

   this.reconnectTimer = setTimeout(() => {
     // ...
-  }, RECONNECT_DELAY_MS);
+  }, delay);
 }

259-266: Consider a dedicated flag to disable reconnection.

Setting reconnectAttempts = MAX_RECONNECT_ATTEMPTS works but obscures intent. A shouldReconnect flag would be more explicit.

♻️ Clearer reconnection control
+  private shouldReconnect = true;

   disconnect(): void {
     if (this.reconnectTimer) {
       clearTimeout(this.reconnectTimer);
       this.reconnectTimer = null;
     }
-    this.reconnectAttempts = MAX_RECONNECT_ATTEMPTS; // Prevent reconnect
+    this.shouldReconnect = false;
     this.client.end();
   }

   private attemptReconnect(): void {
+    if (!this.shouldReconnect) {
+      return;
+    }
     if (this.reconnectTimer) {
       return;
     }
     // ...
   }

311-317: Consider explicit encoding for data conversion.

toString() defaults to UTF-8, which is correct for most terminal output. However, explicit encoding prevents surprises with binary data or locale-specific output.

♻️ Explicit encoding
 channel.on("data", (data: Buffer) => {
-  this.emit(`data:${paneId}`, data.toString());
+  this.emit(`data:${paneId}`, data.toString("utf-8"));
 });

 channel.stderr.on("data", (data: Buffer) => {
-  this.emit(`data:${paneId}`, data.toString());
+  this.emit(`data:${paneId}`, data.toString("utf-8"));
 });

- Use path.posix for remote workspace paths in resolveCwd to prevent
  mangled paths on Windows when working with POSIX remote paths
- Fix shell injection vulnerability by adding shellEscape() and
  isValidRemotePath() validation for SSH cwd handling
- Fix double error handling in SSH connect by tracking isInitialConnect
  flag to prevent reconnect during initial connection attempts
- Add exponential backoff for reconnection (1s, 2s, 4s, 8s, 16s) capped
  at 30 seconds
- Add warning logs when write/resize/signal is called on missing channels

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts (1)

193-248: Clear pane mapping when createOrAttach fails.

paneToWorkspace is set before terminal.createOrAttach. If the call throws, the mapping remains and later operations can be routed to a workspace that never attached. Clean it up on failure.

🛠️ Proposed fix
 try {
 	const result = await terminal.createOrAttach({
 		paneId,
 		tabId,
 		workspaceId,
 		workspaceName,
 		workspacePath,
 		rootPath,
 		cwd,
 		cols,
 		rows,
 		initialCommands,
 		skipColdRestore,
 	});
@@
 	return {
 		paneId,
 		isNew: result.isNew,
 		scrollback: result.scrollback,
 		wasRecovered: result.wasRecovered,
 		viewportY: result.viewportY,
 		isColdRestore: result.isColdRestore,
 		previousCwd: result.previousCwd,
 		snapshot: result.snapshot,
 	};
 } catch (error) {
+	paneToWorkspace.delete(paneId);
 	if (DEBUG_TERMINAL) {
 		console.warn("[Terminal Router] createOrAttach failed:", {
 			callId,
 			paneId,
 			durationMs: Date.now() - startedAt,
 			error: error instanceof Error ? error.message : String(error),
 		});
 	}
 	console.error("[Terminal Router] createOrAttach ERROR:", error);
 	throw error;
 }
apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts (1)

3-33: Refactor resolveCwd to use a params object to avoid boolean blindness.

The function signature with three positional parameters violates the coding guideline requiring object parameters for functions with 2+ arguments. The isRemote boolean flag is not self-documenting at call sites like resolveCwd(cwdOverride, workspacePath, isRemote).

♻️ Proposed refactor
-export function resolveCwd(
-	cwdOverride: string | undefined,
-	worktreePath: string | undefined,
-	isRemote = false,
-): string | undefined {
+export function resolveCwd({
+	cwdOverride,
+	worktreePath,
+	isRemote = false,
+}: {
+	cwdOverride?: string;
+	worktreePath?: string;
+	isRemote?: boolean;
+}): string | undefined {

Update the single call site in apps/desktop/src/lib/trpc/routers/terminal/terminal.ts and test cases to use named properties.

🤖 Fix all issues with AI agents
In `@apps/desktop/src/lib/trpc/routers/terminal/terminal.ts`:
- Around line 44-52: The getTerminalForPane function currently falls back to
registry.getDefault().terminal when paneToWorkspace mapping is missing; instead
fail fast so callers can reattach. Modify getTerminalForPane (and any callers if
needed) to throw a clear error (e.g., including the paneId) when
paneToWorkspace.get(paneId) is undefined rather than returning the default
terminal from getWorkspaceRuntimeRegistry().getDefault().terminal; keep the
existing successful path that calls getTerminalForWorkspace(workspaceId).
- Around line 563-575: Tests are calling caller.stream with a string but the
router's subscription input uses a Zod object schema (z.object({ paneId:
z.string(), workspaceId: z.string().optional() })) so update each test
invocation in terminal.stream.test.ts (the three caller.stream calls) to pass
the object shape, e.g. caller.stream({ paneId: "pane-1" }) (add workspaceId when
a workspace is needed), so the input matches the subscription handler in
terminal.ts.
🧹 Nitpick comments (1)
apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.ts (1)

39-75: Guard against Windows-style remote paths.

In remote mode, inputs like C:\foo or strings with backslashes will be treated as relative POSIX segments and joined to the worktree, which can be surprising on Windows. Consider rejecting/normalizing non‑POSIX inputs here, or ensure callers validate with a remote-path validator before calling resolveCwd.

celicoo and others added 3 commits January 17, 2026 20:19
- Clean up paneToWorkspace mapping on createOrAttach failure to prevent
  stale routing to workspaces that never attached
- Refactor resolveCwd to use params object instead of positional args
  to follow coding guidelines and avoid boolean blindness

Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Validate port in SSH config parser (1-65535 range)
- Fix listener leaks in SSH terminal manager by storing and cleaning up handlers
- Add error logging in killByWorkspaceId catch block
- Add try/catch in cleanupAll to prevent aborting on individual failures
- Add try/catch/finally in disconnectSSHRuntime for consistent state cleanup
- Use project.defaultBranch instead of hardcoded "main" in OpenRemoteDialog
- Fix duplicate ID in 0011_snapshot.json migration

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/desktop/src/main/lib/workspace-runtime/registry.ts`:
- Around line 156-181: In disconnectSSHRuntime, runtime.disconnect() is
currently inside the try block and is skipped if runtime.terminal.cleanup()
throws; move the disconnect call into the finally block so it always runs (but
only if runtime is truthy), capturing any error from disconnect separately so it
doesn't overwrite cleanupError; ensure state cleanup (this.sshRuntimes.delete,
this.sshConfigs.delete, and iterating this.workspaceToSSH) still runs and that
any thrown errors from cleanup() and disconnect() are propagated appropriately
(use separate error variables or aggregate errors) while referencing the
existing runtime variable and this.sshRuntimes/this.sshConfigs/workspaceToSSH
maps.
♻️ Duplicate comments (1)
apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts (1)

135-149: Clean up stale session handlers before recreating.

At Line 135, if a session exists but is dead or the channel is missing, we proceed to recreate without detaching old SSHClient listeners, which can reintroduce duplicate events and listener leaks for the same pane.

🛠️ Proposed fix
 		// Check for existing session
 		const existing = this.sessions.get(paneId);
 		if (existing && existing.isAlive && this.sshClient.hasChannel(paneId)) {
 			existing.lastActive = Date.now();
 			return {
 				isNew: false,
 				scrollback: "",
 				wasRecovered: false,
 				viewportY: existing.viewportY,
 			};
 		}
+
+		// Stale session: remove old handlers before recreating
+		if (existing) {
+			this.cleanupSessionHandlers(paneId);
+			this.sessions.delete(paneId);
+		}
🧹 Nitpick comments (2)
apps/desktop/src/main/lib/workspace-runtime/registry.ts (2)

33-37: Prefer params object for registerSSHWorkspace.
Two positional params are easy to swap; a named params object is clearer and extensible. Please update the implementation and callers to match.

♻️ Proposed refactor
-	registerSSHWorkspace(workspaceId: string, sshConnectionId: string): void;
+	registerSSHWorkspace(params: { workspaceId: string; sshConnectionId: string }): void;

As per coding guidelines.


113-129: Adopt [domain/operation] log prefixes for new registry logs.
This makes it easier to trace per-operation actions in mixed logs.

♻️ Example log prefix updates
-			console.log(`[registry] Creating new SSH runtime for ${config.name} (${config.host})`);
+			console.log(`[registry/getSSHRuntime] Creating new SSH runtime for ${config.name} (${config.host})`);
...
-		console.log(`[registry] Registering workspace ${workspaceId} with SSH connection ${sshConnectionId}`);
+		console.log(`[registry/registerSSHWorkspace] Registering workspace ${workspaceId} with SSH connection ${sshConnectionId}`);

As per coding guidelines.

- Update resolve-cwd.test.ts to use object params
- Update terminal.stream.test.ts to pass object to stream subscription
- Make getTerminalForPane throw instead of falling back to default
- Move runtime.disconnect() to finally block in disconnectSSHRuntime

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts (1)

74-81: Tests will fail—missing required pane-to-workspace mapping setup

When stream({ paneId }) is called without workspaceId, the code routes to getTerminalForPane(paneId) (line 603 in terminal.ts), which throws a TRPCError if the paneId isn't in the paneToWorkspace map. These tests never populate that map (only createOrAttach does, at line 207), so the subscription will fail immediately with "No workspace mapping found for pane pane-N".

Fix by passing workspaceId to stream() so it uses getTerminalForWorkspace() directly (which is mocked):

- const stream$ = await caller.stream({ paneId: "pane-1" });
+ const stream$ = await caller.stream({ paneId: "pane-1", workspaceId: "test-workspace" });

Apply the same change to all three test cases (lines 81, 120, 148).

🧹 Nitpick comments (4)
apps/desktop/src/lib/trpc/routers/terminal/utils/resolve-cwd.test.ts (1)

24-109: Missing test coverage for isRemote parameter.

The implementation accepts an isRemote parameter that significantly changes behavior (uses POSIX paths, skips local existence checks, returns undefined instead of homedir in some fallback cases). Given this PR adds SSH remote workspace support, consider adding test coverage for isRemote = true scenarios.

Example test cases for remote path handling
describe("when isRemote is true", () => {
	test("uses POSIX path resolution", () => {
		expect(
			resolveCwd({
				cwdOverride: "apps/desktop",
				worktreePath: "/home/user/project",
				isRemote: true,
			}),
		).toBe("/home/user/project/apps/desktop");
	});

	test("returns absolute path without existence check", () => {
		expect(
			resolveCwd({
				cwdOverride: "/remote/path",
				isRemote: true,
			}),
		).toBe("/remote/path");
	});

	test("returns undefined when relative path has no worktreePath", () => {
		expect(
			resolveCwd({
				cwdOverride: "apps/desktop",
				isRemote: true,
			}),
		).toBeUndefined();
	});
});
apps/desktop/src/lib/trpc/routers/terminal/terminal.ts (1)

138-158: Consider adding a warning when remote project is not found.

If remoteWs exists but remoteProject is not found (e.g., deleted or orphaned), workspacePath and rootPath remain undefined. The terminal will still be created but may behave unexpectedly without a working directory. Consider logging a warning for observability.

💡 Add warning log
 				if (remoteProject) {
 					workspacePath = remoteProject.remotePath;
 					rootPath = remoteProject.remotePath;
+				} else {
+					console.warn(
+						`[Terminal Router] Remote project not found for workspace ${workspaceId}, remoteProjectId: ${remoteWs.remoteProjectId}`,
+					);
 				}
apps/desktop/src/main/lib/workspace-runtime/registry.ts (2)

89-98: Consider logging when mapped SSH runtime is missing.

If workspaceToSSH has a mapping but sshRuntimes doesn't have the runtime, this silently falls back to local. While disconnectSSHRuntime cleans both maps, edge cases (race conditions, partial cleanup) could leave inconsistent state. A warning would aid debugging.

💡 Add warning for missing SSH runtime
 	getForWorkspaceId(workspaceId: string): WorkspaceRuntime {
 		const sshConnectionId = this.workspaceToSSH.get(workspaceId);
 		if (sshConnectionId) {
 			const sshRuntime = this.sshRuntimes.get(sshConnectionId);
 			if (sshRuntime) {
 				return sshRuntime;
 			}
+			console.warn(
+				`[registry] Workspace ${workspaceId} mapped to SSH ${sshConnectionId} but runtime not found, falling back to local`,
+			);
 		}
 		return this.getDefault();
 	}

284-290: Consider cleanup before reset to prevent orphaned connections.

resetWorkspaceRuntimeRegistry sets the instance to null without calling cleanupAll(). In tests that create SSH runtimes, this could leave orphaned connections. Consider:

💡 Optional: Add async reset with cleanup
 /**
  * Reset the registry (for testing only).
  * This should not be called in production code.
  */
-export function resetWorkspaceRuntimeRegistry(): void {
+export async function resetWorkspaceRuntimeRegistry(): Promise<void> {
+	if (registryInstance) {
+		await registryInstance.cleanupAll();
+	}
 	registryInstance = null;
 }

celicoo and others added 3 commits January 18, 2026 17:18
- Pass workspaceId in stream tests to avoid getTerminalForPane throwing
- Add warning when remote project not found for workspace
- Add warning when SSH runtime not found for mapped workspace

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Extract hostNames and skipExisting from optional input to preserve
type information instead of using `input ?? {}` which loses types.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
…er leak

- Extend WorkspaceRuntimeRegistry to support cloud workspaces alongside SSH
- Add CloudWorkspaceRuntime interface for cloud providers to implement
- Add unified workspaceToRemote mapping for both SSH and cloud types
- Add cloud-specific methods: registerCloudRuntime, getCloudRuntime,
  registerCloudWorkspace, isCloudWorkspace, disconnectCloudRuntime
- Add generic methods: getWorkspaceType, unregisterRemoteWorkspace
- Fix memory leak: connectionStatus listener now properly removed on disconnect
- Update cleanupAll to handle local, SSH, and cloud runtimes

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@apps/desktop/src/main/lib/workspace-runtime/registry.ts`:
- Around line 350-355: The loop deletes entries from the Map while iterating
(this.workspaceToRemote) which can skip items; change both places (the SSH
cleanup block using sshConnectionId and the similar cloud cleanup in
disconnectCloudRuntime) to first collect keys to remove (e.g., const toDelete:
string[] = []; for (const [workspaceId, mapping] of this.workspaceToRemote) { if
(mapping.type === "ssh" && mapping.runtimeId === sshConnectionId)
toDelete.push(workspaceId); }) and then iterate toDelete to call
this.workspaceToRemote.delete(id), ensuring you reference the same Map
(workspaceToRemote) and the same matching logic used currently.

In `@apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx`:
- Around line 42-114: The UI uses a single testingId which is overwritten by
rapid clicks causing racey pending state; replace testingId (and setTestingId)
with a per-connection map (e.g., testingIds: Record<string, boolean>) and update
all usage sites: in handleTest toggle testingIds[id]=true and remove it on
completion, and in testConnection.onSuccess and onError clear testingIds[id]
while still updating testResults; also update any UI checks that read testingId
to instead consult testingIds[id] (including the other occurrence range
referencing the same logic).
- Around line 376-386: The handleSubmit handler currently uses parseInt(port,
10) without validation; update handleSubmit to validate that port is a numeric
integer between 1 and 65535 before calling onSubmit, and block submission if
invalid. Add or reuse a local state like portError (e.g., via useState) and set
it when validation fails so the UI can render a small inline error near the port
input; only call onSubmit({ ..., port: parsedPort, ... }) when parsedPort is a
valid integer in the 1–65535 range. Ensure when authMethod is "key" the
privateKeyPath logic remains unchanged and clear portError on successful
validation.
- Around line 54-65: Add onError handlers to the createConnection and
deleteConnection mutations so failures are logged instead of swallowed: in the
electronTrpc.ssh.createConnection.useMutation call for createConnection and in
electronTrpc.ssh.deleteConnection.useMutation for deleteConnection, add an
onError callback that calls the logger (or processLogger) with a clear prefixed
message (e.g., "createConnection error:" / "deleteConnection error:") and the
error object; keep existing onSuccess behavior
(utils.ssh.listConnections.invalidate and setIsAddDialogOpen(false)) unchanged.

In `@apps/web/src/app/auth/desktop/success/page.tsx`:
- Around line 11-13: The protocolParam from searchParams is user-controlled and
must be validated before passing to DesktopRedirect; add an allowlist check
(e.g., const allowedProtocols = ['lance', 'lance-desktop', 'myapp'] or whatever
app-specific schemes you support) and only use protocolParam if it exactly
matches an entry in that list, otherwise fall back to a safe default (or
reject/redirect to a safe page). Update the code paths that read protocolParam
(the destructuring of searchParams and the place where DesktopRedirect is
constructed/used) to perform this validation and ensure inputs containing ":" or
"//" or full URLs are rejected.
🧹 Nitpick comments (5)
apps/api/src/app/api/auth/desktop/connect/route.ts (1)

10-10: Consider validating the protocol parameter.

The protocol parameter is forwarded to the success page and used to construct a deep link URL. While the value originates from the desktop app's build-time constant, consider validating against an allowlist (["superset", "superset-dev"]) for defense-in-depth. This prevents potential open-redirect-like behavior if the parameter were ever manipulated.

🔒 Optional: Add protocol validation
 const protocol = url.searchParams.get("protocol") || "superset";
+const ALLOWED_PROTOCOLS = ["superset", "superset-dev"];
+if (!ALLOWED_PROTOCOLS.includes(protocol)) {
+	return new Response("Invalid protocol", { status: 400 });
+}

Also applies to: 20-22

apps/desktop/src/main/lib/ssh/ssh-terminal-manager.ts (2)

413-422: Handler cleanup missing before killing channels in cleanup().

The cleanup() method kills all channels but doesn't remove the session handlers first. While disconnect() is called afterward which does remove handlers, the handlers could still fire during the channel kill loop. Consider calling cleanupSessionHandlers(paneId) before killChannel(paneId) for consistency with the kill() method.

♻️ Suggested improvement
 	async cleanup(): Promise<void> {
 		// Kill all sessions
 		for (const paneId of this.sessions.keys()) {
+			this.cleanupSessionHandlers(paneId);
 			this.sshClient.killChannel(paneId);
 		}
 		this.sessions.clear();
 
 		// Disconnect SSH
 		this.disconnect();
 	}

376-384: Async method performs only synchronous operations.

getSessionCountByWorkspaceId is marked async but only performs synchronous iteration over an in-memory Map. This is likely to match the TerminalRuntime interface signature, which is acceptable for consistency.

apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx (2)

163-169: Avoid recreating existingNames for every host.

It’s recomputed inside the map, yielding O(n²) work. Precompute once before mapping.

♻️ Suggested refactor
-<div className="max-h-64 overflow-y-auto space-y-2">
-	{configHosts.map((host) => {
-		const existingNames = new Set(
-			(connections ?? []).map((c) => c.name.toLowerCase()),
-		);
+const existingNames = new Set(
+	(connections ?? []).map((c) => c.name.toLowerCase()),
+);
+
+<div className="max-h-64 overflow-y-auto space-y-2">
+	{configHosts.map((host) => {
 		const alreadyExists = existingNames.has(host.name.toLowerCase());
 		return (

351-496: Split AddConnectionForm into its own file.

This file defines two components, which conflicts with the “one component per file” rule.

As per coding guidelines, keep one component per .tsx file.

Comment on lines +350 to +355
// Remove all workspace mappings for this SSH connection
for (const [workspaceId, mapping] of this.workspaceToRemote) {
if (mapping.type === "ssh" && mapping.runtimeId === sshConnectionId) {
this.workspaceToRemote.delete(workspaceId);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Deleting from Map while iterating may skip entries.

Deleting entries from workspaceToRemote while iterating over it with for...of can cause entries to be skipped. Consider collecting the keys to delete first, then deleting them after the iteration.

🐛 Proposed fix
 				// Remove all workspace mappings for this SSH connection
+				const workspacesToRemove: string[] = [];
 				for (const [workspaceId, mapping] of this.workspaceToRemote) {
 					if (mapping.type === "ssh" && mapping.runtimeId === sshConnectionId) {
-						this.workspaceToRemote.delete(workspaceId);
+						workspacesToRemove.push(workspaceId);
 					}
 				}
+				for (const workspaceId of workspacesToRemove) {
+					this.workspaceToRemote.delete(workspaceId);
+				}

The same issue exists in disconnectCloudRuntime at lines 452-457.

🤖 Prompt for AI Agents
In `@apps/desktop/src/main/lib/workspace-runtime/registry.ts` around lines 350 -
355, The loop deletes entries from the Map while iterating
(this.workspaceToRemote) which can skip items; change both places (the SSH
cleanup block using sshConnectionId and the similar cloud cleanup in
disconnectCloudRuntime) to first collect keys to remove (e.g., const toDelete:
string[] = []; for (const [workspaceId, mapping] of this.workspaceToRemote) { if
(mapping.type === "ssh" && mapping.runtimeId === sshConnectionId)
toDelete.push(workspaceId); }) and then iterate toDelete to call
this.workspaceToRemote.delete(id), ensuring you reference the same Map
(workspaceToRemote) and the same matching logic used currently.

Comment on lines +42 to +114
const [testingId, setTestingId] = useState<string | null>(null);
const [testResults, setTestResults] = useState<
Record<string, { success: boolean; message: string }>
>({});

// Queries
const { data: connections, isLoading } =
electronTrpc.ssh.listConnections.useQuery();
const { data: hasConfig } = electronTrpc.ssh.hasSSHConfig.useQuery();
const { data: configHosts } = electronTrpc.ssh.getSSHConfigHosts.useQuery();

// Mutations
const createConnection = electronTrpc.ssh.createConnection.useMutation({
onSuccess: () => {
utils.ssh.listConnections.invalidate();
setIsAddDialogOpen(false);
},
});

const deleteConnection = electronTrpc.ssh.deleteConnection.useMutation({
onSuccess: () => {
utils.ssh.listConnections.invalidate();
},
});

const testConnection = electronTrpc.ssh.testConnection.useMutation({
onSuccess: (result, variables) => {
setTestResults((prev) => ({
...prev,
[variables.id]: result,
}));
setTestingId(null);
},
onError: (error, variables) => {
setTestResults((prev) => ({
...prev,
[variables.id]: { success: false, message: error.message },
}));
setTestingId(null);
},
});

const [importResult, setImportResult] = useState<{
imported: string[];
skipped: string[];
total: number;
} | null>(null);

const importFromConfig = electronTrpc.ssh.importFromSSHConfig.useMutation({
onSuccess: (result) => {
utils.ssh.listConnections.invalidate();
setImportResult(result);
// Only close if something was imported
if (result.imported.length > 0) {
setIsImportDialogOpen(false);
setImportResult(null);
}
},
onError: (err) => {
console.error("[ssh/import] Failed to import:", err);
setImportResult(null);
},
});

const handleTest = (id: string) => {
setTestingId(id);
setTestResults((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
testConnection.mutate({ id });
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Track pending tests per connection instead of a single testingId.

With multiple quick clicks, testingId gets overwritten and pending state clears prematurely. A per‑connection map avoids racey UI states.

✅ Suggested fix
-const [testingId, setTestingId] = useState<string | null>(null);
+const [testingIds, setTestingIds] = useState<Record<string, boolean>>({});

 const testConnection = electronTrpc.ssh.testConnection.useMutation({
 	onSuccess: (result, variables) => {
 		setTestResults((prev) => ({
 			...prev,
 			[variables.id]: result,
 		}));
-		setTestingId(null);
+		setTestingIds((prev) => {
+			const next = { ...prev };
+			delete next[variables.id];
+			return next;
+		});
 	},
 	onError: (error, variables) => {
 		setTestResults((prev) => ({
 			...prev,
 			[variables.id]: { success: false, message: error.message },
 		}));
-		setTestingId(null);
+		setTestingIds((prev) => {
+			const next = { ...prev };
+			delete next[variables.id];
+			return next;
+		});
 	},
 });

 const handleTest = (id: string) => {
-	setTestingId(id);
+	setTestingIds((prev) => ({ ...prev, [id]: true }));
 	setTestResults((prev) => {
 		const next = { ...prev };
 		delete next[id];
 		return next;
 	});
 	testConnection.mutate({ id });
 };

-const isTesting = testingId === conn.id;
+const isTesting = !!testingIds[conn.id];

Also applies to: 262-322

🤖 Prompt for AI Agents
In `@apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx` around
lines 42 - 114, The UI uses a single testingId which is overwritten by rapid
clicks causing racey pending state; replace testingId (and setTestingId) with a
per-connection map (e.g., testingIds: Record<string, boolean>) and update all
usage sites: in handleTest toggle testingIds[id]=true and remove it on
completion, and in testConnection.onSuccess and onError clear testingIds[id]
while still updating testResults; also update any UI checks that read testingId
to instead consult testingIds[id] (including the other occurrence range
referencing the same logic).

Comment on lines +54 to +65
const createConnection = electronTrpc.ssh.createConnection.useMutation({
onSuccess: () => {
utils.ssh.listConnections.invalidate();
setIsAddDialogOpen(false);
},
});

const deleteConnection = electronTrpc.ssh.deleteConnection.useMutation({
onSuccess: () => {
utils.ssh.listConnections.invalidate();
},
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add onError logging for create/delete mutations.

Failures currently surface no signal. Please add prefixed error logging for both mutations so users (and logs) capture failures.

✅ Suggested fix
 const createConnection = electronTrpc.ssh.createConnection.useMutation({
 	onSuccess: () => {
 		utils.ssh.listConnections.invalidate();
 		setIsAddDialogOpen(false);
 	},
+	onError: (err) => {
+		console.error("[ssh/create] Failed to create connection:", err);
+	},
 });

 const deleteConnection = electronTrpc.ssh.deleteConnection.useMutation({
 	onSuccess: () => {
 		utils.ssh.listConnections.invalidate();
 	},
+	onError: (err, variables) => {
+		console.error("[ssh/delete] Failed to delete connection:", err, {
+			id: variables.id,
+		});
+	},
 });

As per coding guidelines, never swallow errors silently.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const createConnection = electronTrpc.ssh.createConnection.useMutation({
onSuccess: () => {
utils.ssh.listConnections.invalidate();
setIsAddDialogOpen(false);
},
});
const deleteConnection = electronTrpc.ssh.deleteConnection.useMutation({
onSuccess: () => {
utils.ssh.listConnections.invalidate();
},
});
const createConnection = electronTrpc.ssh.createConnection.useMutation({
onSuccess: () => {
utils.ssh.listConnections.invalidate();
setIsAddDialogOpen(false);
},
onError: (err) => {
console.error("[ssh/create] Failed to create connection:", err);
},
});
const deleteConnection = electronTrpc.ssh.deleteConnection.useMutation({
onSuccess: () => {
utils.ssh.listConnections.invalidate();
},
onError: (err, variables) => {
console.error("[ssh/delete] Failed to delete connection:", err, {
id: variables.id,
});
},
});
🤖 Prompt for AI Agents
In `@apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx` around
lines 54 - 65, Add onError handlers to the createConnection and deleteConnection
mutations so failures are logged instead of swallowed: in the
electronTrpc.ssh.createConnection.useMutation call for createConnection and in
electronTrpc.ssh.deleteConnection.useMutation for deleteConnection, add an
onError callback that calls the logger (or processLogger) with a clear prefixed
message (e.g., "createConnection error:" / "deleteConnection error:") and the
error object; keep existing onSuccess behavior
(utils.ssh.listConnections.invalidate and setIsAddDialogOpen(false)) unchanged.

Comment on lines +376 to +386
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
name,
host,
port: parseInt(port, 10),
username,
authMethod,
privateKeyPath: authMethod === "key" ? privateKeyPath : undefined,
remoteWorkDir: remoteWorkDir || undefined,
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate the port before submitting.

parseInt can yield NaN (or out‑of‑range values), which will pass through to the backend. Consider blocking submit and showing a small inline error when the port isn’t 1–65535.

🤖 Prompt for AI Agents
In `@apps/desktop/src/renderer/routes/_authenticated/settings/ssh/page.tsx` around
lines 376 - 386, The handleSubmit handler currently uses parseInt(port, 10)
without validation; update handleSubmit to validate that port is a numeric
integer between 1 and 65535 before calling onSubmit, and block submission if
invalid. Add or reuse a local state like portError (e.g., via useState) and set
it when validation fails so the UI can render a small inline error near the port
input; only call onSubmit({ ..., port: parsedPort, ... }) when parsedPort is a
valid integer in the 1–65535 range. Ensure when authMethod is "key" the
privateKeyPath logic remains unchanged and clear portError on successful
validation.

Comment on lines +11 to +13
searchParams: Promise<{ desktop_state?: string; protocol?: string }>;
}) {
const { desktop_state: state } = await searchParams;
const { desktop_state: state, protocol: protocolParam } = await searchParams;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate the protocol parameter to prevent arbitrary URL schemes.

The protocolParam comes from URL search params (user-controllable) and is used directly to construct the desktop callback URL. An attacker could craft a malicious link like ?protocol=javascript&desktop_state=... or ?protocol=https://evil.com/fake which would be passed to DesktopRedirect.

You should validate against the known allowed protocols:

🔒 Proposed fix: Validate protocol against allowlist
+const ALLOWED_PROTOCOLS = ["superset", "superset-dev"];
+
 // Use protocol from URL param (passed from desktop app), fallback to production
-const protocol = protocolParam || "superset";
+const protocol = protocolParam && ALLOWED_PROTOCOLS.includes(protocolParam) 
+  ? protocolParam 
+  : "superset";
 const desktopUrl = `${protocol}://auth/callback?token=${encodeURIComponent(token)}&expiresAt=${encodeURIComponent(expiresAt.toISOString())}&state=${encodeURIComponent(state)}`;

Also applies to: 80-82

🤖 Prompt for AI Agents
In `@apps/web/src/app/auth/desktop/success/page.tsx` around lines 11 - 13, The
protocolParam from searchParams is user-controlled and must be validated before
passing to DesktopRedirect; add an allowlist check (e.g., const allowedProtocols
= ['lance', 'lance-desktop', 'myapp'] or whatever app-specific schemes you
support) and only use protocolParam if it exactly matches an entry in that list,
otherwise fall back to a safe default (or reject/redirect to a safe page).
Update the code paths that read protocolParam (the destructuring of searchParams
and the place where DesktopRedirect is constructed/used) to perform this
validation and ensure inputs containing ":" or "//" or full URLs are rejected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant