Skip to content
Open
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
4 changes: 2 additions & 2 deletions apps/desktop/src/ssh/DesktopSshEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe("sshEnvironment", () => {
},
{
alias: "devbox",
hostname: "devbox",
hostname: "devbox.example.com",
username: null,
port: null,
source: "ssh-config",
Expand All @@ -106,7 +106,7 @@ describe("sshEnvironment", () => {
},
{
alias: "staging",
hostname: "staging",
hostname: "staging.example.com",
username: null,
port: null,
source: "ssh-config",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2273,7 +2273,8 @@ export function ConnectionsSettings() {
const address = formatDesktopSshTarget(target);
return (
!savedDesktopSshEnvironmentKeys.has(target.alias) &&
!savedDesktopSshEnvironmentKeys.has(address)
// A configured alias keeps its own settings even when its hostname is saved.
(target.source === "ssh-config" || !savedDesktopSshEnvironmentKeys.has(address))
);
}),
[discoveredSshHosts, savedDesktopSshEnvironmentKeys],
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/state/desktopSshHosts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ describe("filterDiscoveredSshHosts", () => {
expect(filterDiscoveredSshHosts(suggestions, "PINOT")).toEqual([suggestions[1]]);
});

it("finds a configured alias by its hostname", () => {
expect(filterDiscoveredSshHosts(hosts, "box.LOCAL")).toEqual(hosts);
});

it("returns an empty array when no hosts match", () => {
expect(filterDiscoveredSshHosts(suggestions, "merlot")).toEqual([]);
});
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/state/desktopSshHosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export function filterDiscoveredSshHosts(
const alias = host.alias.toLowerCase();
if (alias.startsWith(normalizedQuery)) {
prefixMatches.push(host);
} else if (alias.includes(normalizedQuery)) {
} else if (
alias.includes(normalizedQuery) ||
host.hostname.toLowerCase().includes(normalizedQuery)
) {
substringMatches.push(host);
}
}
Expand Down
5 changes: 3 additions & 2 deletions packages/ssh/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe("ssh config", () => {
yield* fs.writeFileString(
path.join(sshDir, "known_hosts"),
[
"devbox.example.com ssh-ed25519 AAAA",
"known.example.com ssh-ed25519 AAAA",
"|1|hashed|entry ssh-ed25519 AAAA",
"[bastion.example.com]:2222 ssh-ed25519 AAAA",
Expand All @@ -67,7 +68,7 @@ describe("ssh config", () => {
},
{
alias: "devbox",
hostname: "devbox",
hostname: "devbox.example.com",
username: null,
port: null,
source: "ssh-config",
Expand All @@ -88,7 +89,7 @@ describe("ssh config", () => {
},
{
alias: "staging",
hostname: "staging",
hostname: "staging.example.com",
username: null,
port: null,
source: "ssh-config",
Expand Down
56 changes: 54 additions & 2 deletions packages/ssh/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,41 @@ const expandGlob = Effect.fnUntraced(function* (pattern: string) {
return matchedPaths.toSorted((left, right) => left.localeCompare(right));
});

interface HostNameRule {
/** Host patterns of the enclosing block, or null under a Match block. */
readonly patterns: ReadonlyArray<string> | null;
readonly hostname: string;
}

function matchesHostPatterns(alias: string, patterns: ReadonlyArray<string>): boolean {
let matched = false;
for (const pattern of patterns) {
const negated = pattern.startsWith("!");
if (!globToRegExp(negated ? pattern.slice(1) : pattern).test(alias)) continue;
if (negated) return false;
matched = true;
}
return matched;
}

/** ssh uses the first HostName whose block matches; null when that can't be known statically. */
function resolveConfiguredHostname(
alias: string,
rules: ReadonlyArray<HostNameRule>,
): string | null {
const rule = rules.find(
({ patterns }) => patterns === null || matchesHostPatterns(alias, patterns),
);
if (!rule || rule.patterns === null || /%[^h]/u.test(rule.hostname)) return null;
return rule.hostname.replaceAll("%h", alias);
}

const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
filePath: string,
visited = new Set<string>(),
homeDir: string,
hostNameRules: Array<HostNameRule>,
inheritedPatterns: ReadonlyArray<string> | null,
): Effect.fn.Return<
ReadonlyArray<string>,
PlatformError.PlatformError,
Expand All @@ -109,6 +140,7 @@ const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
const aliases = new Set<string>();
const directory = path.dirname(resolvedPath);
const raw = yield* fs.readFileString(resolvedPath);
let patterns = inheritedPatterns;

for (const line of raw.split(/\r?\n/u)) {
const stripped = stripInlineComment(line);
Expand All @@ -131,6 +163,8 @@ const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
includedPath,
visited,
homeDir,
hostNameRules,
patterns,
);
for (const alias of includedAliases) {
aliases.add(alias);
Expand All @@ -140,10 +174,21 @@ const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
continue;
}

if (normalizedDirective === "match") {
patterns = null;
continue;
}
if (normalizedDirective === "hostname") {
if (rawArgs[0])
hostNameRules.push({ patterns, hostname: rawArgs[0].replace(/^"(.*)"$/u, "$1") });
continue;
}
if (normalizedDirective !== "host") {
continue;
}

patterns = rawArgs;

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.

🟡 Medium src/config.ts:190

A Host inside a conditional Include can make discovery assign the wrong HostName to an alias and suppress its known_hosts suggestion. At line 190, patterns = rawArgs replaces the enclosing Host condition, so an included Host prod rule is treated as matching prod even when the include was reached only under Host dev. Preserve the enclosing condition alongside inner Host patterns, and keep Match-conditioned includes uncertain.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/ssh/src/config.ts around line 190:

A `Host` inside a conditional `Include` can make discovery assign the wrong `HostName` to an alias and suppress its known_hosts suggestion. At line 190, `patterns = rawArgs` replaces the enclosing `Host` condition, so an included `Host prod` rule is treated as matching `prod` even when the include was reached only under `Host dev`. Preserve the enclosing condition alongside inner `Host` patterns, and keep `Match`-conditioned includes uncertain.


for (const alias of rawArgs) {
if (alias.length === 0 || hasSshPattern(alias)) {
continue;
Expand Down Expand Up @@ -224,26 +269,33 @@ export const discoverSshHosts = Effect.fnUntraced(
}

const sshDirectory = path.join(homeDir, ".ssh");
const hostNameRules: Array<HostNameRule> = [];
const configAliases = yield* collectSshConfigAliasesFromFile(
path.join(sshDirectory, "config"),
new Set<string>(),
homeDir,
hostNameRules,
["*"],
);
const knownHosts = yield* readKnownHostsHostnames(path.join(sshDirectory, "known_hosts"));
const discovered = new Map<string, DesktopDiscoveredSshHost>();
const configuredHostnames = new Set<string>();

for (const alias of configAliases) {
const hostname = resolveConfiguredHostname(alias, hostNameRules) ?? alias;
configuredHostnames.add(hostname.toLowerCase());
discovered.set(alias, {
alias,
hostname: alias,
hostname,
username: null,
port: null,
source: "ssh-config",
});
}

// A raw known_hosts target that a configured alias points at would bypass the alias's settings.
for (const hostname of knownHosts) {
if (discovered.has(hostname)) {
if (discovered.has(hostname) || configuredHostnames.has(hostname.toLowerCase())) {
continue;
}
discovered.set(hostname, {
Expand Down
Loading