Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/desktop/electron-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ["**/*"],
},
Comment on lines +162 to +167
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

SSH2_VERSION=$(python - <<'PY'
import json
with open("apps/desktop/package.json") as f:
    pkg = json.load(f)
print(pkg["dependencies"]["ssh2"].lstrip("^~"))
PY
)

echo "ssh2 version: $SSH2_VERSION"
curl -fsSL "https://registry.npmjs.org/ssh2/${SSH2_VERSION}" | python - <<'PY'
import json, sys
data = json.load(sys.stdin)
print("dependencies:")
print(json.dumps(data.get("dependencies", {}), indent=2, sort_keys=True))
print("optionalDependencies:")
print(json.dumps(data.get("optionalDependencies", {}), indent=2, sort_keys=True))
PY

echo
echo "Current electron-builder coverage:"
rg -n -C2 'ssh2|cpu-features|nan|asn1|bcrypt-pbkdf' apps/desktop/electron-builder.ts

Repository: superset-sh/superset

Length of output: 853


🏁 Script executed:

cat apps/desktop/package.json | grep -A 5 '"ssh2"'

Repository: superset-sh/superset

Length of output: 215


🏁 Script executed:

if [ -f apps/desktop/package-lock.json ]; then
  echo "=== package-lock.json exists ==="
  jq '.dependencies."ssh2" | keys' apps/desktop/package-lock.json 2>/dev/null | head -20
elif [ -f bun.lock ]; then
  echo "=== bun.lock exists ==="
  grep -A 30 'ssh2' bun.lock | head -40
else
  echo "No lock file found"
fi

Repository: superset-sh/superset

Length of output: 1479


🏁 Script executed:

echo "=== Current electron-builder.ts packaging rules ==="
cat -n apps/desktop/electron-builder.ts | sed -n '150,200p'

Repository: superset-sh/superset

Length of output: 2192


🏁 Script executed:

echo "=== Looking for all copy rules in electron-builder.ts ==="
rg -n 'from.*node_modules' apps/desktop/electron-builder.ts

Repository: superset-sh/superset

Length of output: 750


🏁 Script executed:

echo "=== Searching bun.lock for ssh2 dependencies ==="
grep -A 50 '"ssh2"' bun.lock | head -60

Repository: superset-sh/superset

Length of output: 2658


🏁 Script executed:

echo "=== Full ssh2 entry in bun.lock ==="
python3 << 'EOF'
import re

with open('bun.lock', 'r') as f:
    content = f.read()
    
# Find ssh2@1.16.0 entry
pattern = r'"ssh2@\^1\.16\.0".*?(?="(?:[\w-]+)@|$)'
match = re.search(pattern, content, re.DOTALL)
if match:
    print(match.group(0)[:1500])
else:
    print("ssh2 entry not found with that pattern, trying simpler search")
    # Find any ssh2 section
    if 'ssh2@^1.16.0' in content:
        idx = content.find('ssh2@^1.16.0')
        print(content[idx:idx+1000])
EOF

Repository: superset-sh/superset

Length of output: 161


🏁 Script executed:

echo "=== Check if ssh2 dependencies are nested or separate in bun.lock ==="
rg -A 3 'ssh2@.*1\.16\.0' bun.lock

Repository: superset-sh/superset

Length of output: 133


🏁 Script executed:

echo "=== Packages ssh2 depends on (from bun.lock) ==="
echo "runtime dependencies: asn1, bcrypt-pbkdf"
echo "optional dependencies: cpu-features, nan"
echo
echo "=== Are these packaged in electron-builder.ts? ==="
for pkg in asn1 bcrypt-pbkdf cpu-features nan; do
  if rg -q "from.*$pkg" apps/desktop/electron-builder.ts; then
    echo "$pkg - explicitly packaged"
  else
    echo "$pkg - NOT packaged"
  fi
done

Repository: superset-sh/superset

Length of output: 349


Add explicit copy rules for ssh2 dependencies.

The ssh2@1.17.0 package requires four dependencies that are not currently packaged: asn1, bcrypt-pbkdf (runtime), and cpu-features, nan (optional). These are installed at the top-level node_modules, not nested under node_modules/ssh2, so the current copy rule will not include them. SSH connections will work in dev but fail in packaged builds.

Add copy rules similar to the existing ssh2 rule for each of these four packages.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/electron-builder.ts` around lines 162 - 167, The current copy
rule object for ssh2 only copies node_modules/ssh2 but misses top-level
dependencies installed alongside it; add four additional copy rule objects
matching the existing ssh2 pattern for "asn1", "bcrypt-pbkdf", "cpu-features",
and "nan" so each has from: "node_modules/<pkg>", to: "node_modules/<pkg>", and
filter: ["**/*"] (mirroring the ssh2 entry) to ensure these packages are
included in packaged builds.

"!**/.DS_Store",
],

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,6 +56,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
uiState: createUiStateRouter(),
ringtone: createRingtoneRouter(getWindow),
workspaceServiceManager: createWorkspaceServiceManagerRouter(),
sshHosts: createSshHostsRouter(),
});
};

Expand Down
253 changes: 253 additions & 0 deletions apps/desktop/src/lib/trpc/routers/ssh-hosts/index.ts
Original file line number Diff line number Diff line change
@@ -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)) {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 11, 2026

Choose a reason for hiding this comment

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

P2: Delete should disconnect unconditionally; gating on isConnected() misses connecting/reconnecting states and can leak active SSH state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/lib/trpc/routers/ssh-hosts/index.ts, line 85:

<comment>Delete should disconnect unconditionally; gating on `isConnected()` misses `connecting`/`reconnecting` states and can leak active SSH state.</comment>

<file context>
@@ -0,0 +1,253 @@
+			.input(z.object({ id: z.string() }))
+			.mutation(({ input }) => {
+				// Disconnect if connected
+				if (sshManager.isConnected(input.id)) {
+					sshManager.disconnect(input.id);
+				}
</file context>
Fix with Cubic

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(),
}),
Comment on lines +97 to +105
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Align testConnection port validation with the persisted-host schema.

create and update reject ports outside 1..65535, but testConnection currently accepts any integer. That lets obviously invalid values reach the SSH client only on the test path.

🩹 Suggested fix
-					port: z.number().int().default(22),
+					port: z.number().int().min(1).max(65535).default(22),
📝 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
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(),
}),
z.object({
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(),
password: z.string().optional(),
passphrase: z.string().optional(),
}),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/ssh-hosts/index.ts` around lines 97 - 105,
The testConnection input schema currently allows any integer for port; update
the zod schema used by the testConnection route to enforce the same port bounds
as the persisted-host schema by changing the port validator to
z.number().int().min(1).max(65535).default(22), keeping the rest of the fields
(hostname, username, authMethod, privateKeyPath, password, passphrase) unchanged
so testConnection, create, and update all validate ports identically.

)
.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<typeof createSshHostsRouter>;
Loading