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: 3 additions & 3 deletions apps/desktop/src/ssh/DesktopSshEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe("sshEnvironment", () => {
yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true });
yield* fs.writeFileString(
path.join(sshDir, "config"),
["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"),
["Include config.d/*.conf", "Host devbox", " HostName devbox.example.com", ""].join("\n"),
);
yield* fs.writeFileString(
path.join(sshDir, "config.d", "team.conf"),
Expand Down 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
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 target hostname", () => {
expect(filterDiscoveredSshHosts(hosts, "DEVBOX.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
164 changes: 161 additions & 3 deletions packages/ssh/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ describe("ssh config", () => {
yield* fs.writeFileString(
path.join(sshDir, "config"),
[
"Include=config.d/*.conf",
"Host devbox",
" HostName devbox.example.com",
"Host=equalsbox",
"Include=config.d/*.conf",
"",
].join("\n"),
);
Expand Down Expand Up @@ -67,7 +67,7 @@ describe("ssh config", () => {
},
{
alias: "devbox",
hostname: "devbox",
hostname: "devbox.example.com",
username: null,
port: null,
source: "ssh-config",
Expand All @@ -88,7 +88,7 @@ describe("ssh config", () => {
},
{
alias: "staging",
hostname: "staging",
hostname: "staging.example.com",
username: null,
port: null,
source: "ssh-config",
Expand All @@ -97,6 +97,164 @@ describe("ssh config", () => {
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);

it.effect("prefers configured aliases over their known_hosts targets", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const homeDir = yield* makeTempHomeDir();
const sshDir = path.join(homeDir, ".ssh");
yield* fs.makeDirectory(sshDir);
yield* fs.writeFileString(
path.join(sshDir, "config"),
[
"Host mini",
" HostName 100.111.210.10",
"Host work-box",
' HostName "work.example.com"',
"Host *",
" HostName fallback.example.com",
"",
].join("\n"),
);
yield* fs.writeFileString(
path.join(sshDir, "known_hosts"),
[
"100.111.210.10 ssh-ed25519 AAAA",
"work.example.com ssh-ed25519 BBBB",
"fallback.example.com ssh-ed25519 CCCC",
"other.example.com ssh-ed25519 DDDD",
"",
].join("\n"),
);

const hosts = yield* discoverSshHosts({ homeDir });
assert.deepEqual(
hosts.map(({ alias }) => alias),
["fallback.example.com", "mini", "other.example.com", "work-box"],
);
assert.equal(hosts.find(({ alias }) => alias === "mini")?.hostname, "100.111.210.10");
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);

it.effect("uses the effective HostName across includes, wildcards, and Match all", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
for (const fixture of [
{
config: "Host work\n Include target.conf\n",
included: " HostName work.example.com\n",
target: "work.example.com",
},
{
config:
"Host *\n HostName bastion.example.com\nHost work\n HostName ignored.example.com\n",
included: "",
target: "bastion.example.com",
},
{
config: "Host work\nMatch all\n HostName shared.example.com\n",
included: "",
target: "shared.example.com",
},
{
config: "Match originalhost work\n Include target.conf\n",
included: "Host work\n HostName work.example.com\n",
target: "work.example.com",
},
]) {
const homeDir = yield* makeTempHomeDir();
const sshDir = path.join(homeDir, ".ssh");
yield* fs.makeDirectory(sshDir);
yield* fs.writeFileString(path.join(sshDir, "config"), fixture.config);
yield* fs.writeFileString(path.join(sshDir, "target.conf"), fixture.included);
yield* fs.writeFileString(
path.join(sshDir, "known_hosts"),
`${fixture.target} ssh-ed25519 AAAA\n`,
);

const hosts = yield* discoverSshHosts({ homeDir });
assert.deepEqual(hosts, [
{
alias: "work",
hostname: fixture.target,
username: null,
port: null,
source: "ssh-config",
},
]);
}
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);

it.effect(
"restores the enclosing Host after an Include and respects tokenized first values",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const homeDir = yield* makeTempHomeDir();
const sshDir = path.join(homeDir, ".ssh");
yield* fs.makeDirectory(sshDir);
yield* fs.writeFileString(
path.join(sshDir, "config"),
[
"Host work",
" Include nested.conf",
" HostName work.example.com",
"Host tokenized",
" HostName %h.internal",
"Host tokenized",
" HostName wrong.example.com",
"",
].join("\n"),
);
yield* fs.writeFileString(
path.join(sshDir, "nested.conf"),
"Host nested\n HostName nested.example.com\n",
);
yield* fs.writeFileString(
path.join(sshDir, "known_hosts"),
"work.example.com ssh-ed25519 AAAA\ntokenized.internal ssh-ed25519 BBBB\nwrong.example.com ssh-ed25519 CCCC\n",
);

const hosts = yield* discoverSshHosts({ homeDir });
assert.deepEqual(
hosts.map(({ alias, hostname }) => [alias, hostname]),
[
["tokenized", "tokenized.internal"],
["work", "work.example.com"],
["wrong.example.com", "wrong.example.com"],
],
);
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);

it.effect("expands supported HostName tokens without treating escaped percents as tokens", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const homeDir = yield* makeTempHomeDir();
const sshDir = path.join(homeDir, ".ssh");
yield* fs.makeDirectory(sshDir);
yield* fs.writeFileString(
path.join(sshDir, "config"),
"Host Mixed\n HostName %h.internal\nHost escaped\n HostName zone%%en0\nHost unsupported\n HostName %p.internal\n",
);
yield* fs.writeFileString(
path.join(sshDir, "known_hosts"),
"mixed.internal ssh-ed25519 AAAA\nzone%en0 ssh-ed25519 BBBB\n",
);

const hosts = yield* discoverSshHosts({ homeDir });
assert.deepEqual(Object.fromEntries(hosts.map(({ alias, hostname }) => [alias, hostname])), {
Mixed: "mixed.internal",
escaped: "zone%en0",
unsupported: "unsupported",
});
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);

it.effect("parses known_hosts entries without returning hashed hosts", () =>
Effect.sync(() => {
assert.deepEqual(
Expand Down
81 changes: 78 additions & 3 deletions packages/ssh/src/config.ts

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.

🟠 High

const includedAliases = yield* collectSshConfigAliasesFromFile(

An Include inside Host work is parsed without the active host context, so HostName work.example.com in the included file is not recorded for work; discovery therefore keeps work as the hostname and does not suppress the duplicate known_hosts target. Propagate the caller's active currentHosts into the recursive collection and use it to initialize the included file's context, while still allowing its own Host directives to replace that context.

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

An `Include` inside `Host work` is parsed without the active host context, so `HostName work.example.com` in the included file is not recorded for `work`; discovery therefore keeps `work` as the hostname and does not suppress the duplicate `known_hosts` target. Propagate the caller's active `currentHosts` into the recursive collection and use it to initialize the included file's context, while still allowing its own `Host` directives to replace that context.

Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,44 @@ const expandGlob = Effect.fnUntraced(function* (pattern: string) {
return matchedPaths.toSorted((left, right) => left.localeCompare(right));
});

interface SshHostNameRule {
readonly guards: ReadonlyArray<ReadonlyArray<string>>;
readonly patterns: ReadonlyArray<string>;
readonly hostname: string;
}

function expandConfiguredHostname(hostname: string, alias: string): string | null {
let supported = true;
const expanded = hostname.replace(/%(.?)/gsu, (_, token: string) => {
if (token === "h") return alias.toLowerCase();
if (token === "%") return "%";
supported = false;
return "";
});
return supported ? expanded : null;
}

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

const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
filePath: string,
visited = new Set<string>(),
homeDir: string,
context: {
patterns: ReadonlyArray<string>;
guards: ReadonlyArray<ReadonlyArray<string>>;
},
hostnameRules: Array<SshHostNameRule>,
): Effect.fn.Return<
ReadonlyArray<string>,
PlatformError.PlatformError,
Expand Down Expand Up @@ -131,6 +165,11 @@ const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
includedPath,
visited,
homeDir,
{
patterns: context.patterns,
guards: [...context.guards, context.patterns],
},
hostnameRules,
);
for (const alias of includedAliases) {
aliases.add(alias);
Expand All @@ -141,17 +180,40 @@ const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
}

if (normalizedDirective !== "host") {
if (normalizedDirective === "match") {
const condition = rawArgs[0]?.toLowerCase();
context.patterns =
condition === "all" && rawArgs.length === 1
? ["*"]
: condition === "originalhost" && rawArgs.length === 2
? (rawArgs[1]?.split(",") ?? [])
: [];
}
if (normalizedDirective === "hostname" && context.patterns.length > 0) {
const hostname = rawArgs[0]?.replace(/^(["'])(.*)\1$/u, "$2");
if (hostname) {
hostnameRules.push({
guards: context.guards,
patterns: context.patterns,
hostname,
});
}
}
continue;
}

context.patterns = rawArgs;
for (const alias of rawArgs) {
if (alias.length === 0 || hasSshPattern(alias)) {
continue;
}
aliases.add(alias);
if (context.guards.every((guard) => matchesHostPatterns(alias, guard))) {
aliases.add(alias);
}
}
}

visited.delete(resolvedPath);
return [...aliases].toSorted((left, right) => left.localeCompare(right));
});

Expand Down Expand Up @@ -224,26 +286,39 @@ export const discoverSshHosts = Effect.fnUntraced(
}

const sshDirectory = path.join(homeDir, ".ssh");
const hostnameRules: Array<SshHostNameRule> = [];
const configAliases = yield* collectSshConfigAliasesFromFile(
path.join(sshDirectory, "config"),
new Set<string>(),
homeDir,
{ patterns: ["*"], guards: [] },
hostnameRules,
);
const knownHosts = yield* readKnownHostsHostnames(path.join(sshDirectory, "known_hosts"));
const discovered = new Map<string, DesktopDiscoveredSshHost>();
const configuredTargets = new Set<string>();

for (const alias of configAliases) {
const configuredHostname = hostnameRules.find(
(rule) =>
rule.guards.every((guard) => matchesHostPatterns(alias, guard)) &&
matchesHostPatterns(alias, rule.patterns),
)?.hostname;
const hostname = configuredHostname
? (expandConfiguredHostname(configuredHostname, alias) ?? alias)
: alias;
configuredTargets.add(hostname.toLowerCase());
discovered.set(alias, {
alias,
hostname: alias,
hostname,
username: null,
port: null,
source: "ssh-config",
});
}

for (const hostname of knownHosts) {
if (discovered.has(hostname)) {
if (discovered.has(hostname) || configuredTargets.has(hostname.toLowerCase())) {
continue;
}
discovered.set(hostname, {
Expand Down
Loading