diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 7cc252d3bf28..c92115f058b5 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -30,6 +30,7 @@ import { import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -660,7 +661,11 @@ function makeManager(input?: { ), vcsDriverLayer, serverSettingsLayer, - ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); + ).pipe( + Layer.provideMerge(sourceControlRegistryLayer), + Layer.provideMerge(ProcessRunner.layer), + Layer.provideMerge(NodeServices.layer), + ); return GitManager.make.pipe( Effect.provide(managerLayer), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index b4d1240c6a28..5007bab54e43 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -56,6 +56,8 @@ import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { canonicalizeSshRemoteUrl, sshConfigProbe } from "../vcs/sshRemoteUrl.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; import type { ChangeRequest } from "@t3tools/contracts"; @@ -593,6 +595,7 @@ function toPullRequestHeadRemoteInfo(pr: { export const make = Effect.gen(function* () { const gitCore = yield* GitVcsDriver.GitVcsDriver; + const processRunner = yield* ProcessRunner.ProcessRunner; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const textGeneration = yield* TextGeneration.TextGeneration; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; @@ -1102,6 +1105,15 @@ export const make = Effect.gen(function* () { const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); + const readRemoteUrl = (cwd: string, key: string) => + readConfigValueNullable(cwd, key).pipe( + Effect.flatMap((remoteUrl) => + remoteUrl === null + ? Effect.succeed(null) + : canonicalizeSshRemoteUrl(remoteUrl, sshConfigProbe(processRunner)), + ), + ); + const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( cwd: string, branch: string | null, @@ -1111,8 +1123,8 @@ export const make = Effect.gen(function* () { ? "origin" : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); const remoteUrl = - (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? - (yield* readConfigValueNullable(cwd, "remote.origin.url")); + (yield* readRemoteUrl(cwd, `remote.${preferredRemoteName}.url`)) ?? + (yield* readRemoteUrl(cwd, "remote.origin.url")); return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; }); @@ -1129,7 +1141,7 @@ export const make = Effect.gen(function* () { }; } - const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); + const remoteUrl = yield* readRemoteUrl(cwd, `remote.${remoteName}.url`); const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); return { remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, @@ -2320,4 +2332,4 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(GitManager, make); +export const layer = Layer.effect(GitManager, make).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 50608e7704c7..62b1b26b6aa9 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -11,6 +11,7 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ProcessRunner from "../processRunner.ts"; +import { canonicalizeSshRemoteUrl, sshConfigProbe } from "../vcs/sshRemoteUrl.ts"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); @@ -132,7 +133,15 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( } const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); - return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; + if (!remote) { + return null; + } + + const remoteUrl = yield* canonicalizeSshRemoteUrl( + remote.remoteUrl, + sshConfigProbe(processRunner), + ); + return buildRepositoryIdentity({ ...remote, remoteUrl, rootPath: cacheKey }); }); export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 8f7f7bd19544..ca887bd5b3a0 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -33,6 +33,7 @@ import { import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; +import { canonicalizeSshRemoteUrl } from "./sshRemoteUrl.ts"; export interface ExecuteGitInput { readonly operation: string; @@ -469,6 +470,22 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }, ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); + const sshConfigProbe = (cwd: string) => (host: string) => + vcsProcess + .run({ + operation: "GitVcsDriver.sshConfig", + command: "ssh", + args: ["-G", "--", host], + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }) + .pipe( + Effect.map((result) => result.stdout), + Effect.orElseSucceed(() => ""), + ); + const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) => gitCommand(vcsProcess, input.operation, input.cwd, input.args, { ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), @@ -574,7 +591,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } const parsed = parseGitRemoteVerboseOutput(result.stdout); - const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { + const configured = Array.from(parsed.entries()).flatMap(([name, remote]) => { if (!remote.url) { return []; } @@ -588,6 +605,12 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ]; }); + const remotes = yield* Effect.forEach(configured, (remote) => + canonicalizeSshRemoteUrl(remote.url, sshConfigProbe(cwd)).pipe( + Effect.map((url) => ({ ...remote, url })), + ), + ); + return { remotes, freshness: yield* nowFreshness(), diff --git a/apps/server/src/vcs/sshRemoteUrl.test.ts b/apps/server/src/vcs/sshRemoteUrl.test.ts new file mode 100644 index 000000000000..ce5d903af5b7 --- /dev/null +++ b/apps/server/src/vcs/sshRemoteUrl.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { canonicalizeSshRemoteUrl, SshHostnameCache } from "./sshRemoteUrl.ts"; + +const sshConfig = (hostname: string) => (host: string) => + Effect.succeed(`host ${host}\r\nhostname ${hostname}\r\nport 22\r\nuser git\r\n`); + +const unresolved = (host: string) => Effect.succeed(`hostname ${host}\n`); + +describe("canonicalizeSshRemoteUrl", () => { + it.effect("resolves an scp-style alias to its configured hostname", () => + Effect.gen(function* () { + expect( + yield* canonicalizeSshRemoteUrl("git@alt:pingdotgg/t3chat.git", sshConfig("github.com")), + ).toBe("git@github.com:pingdotgg/t3chat.git"); + }), + ); + + it.effect("resolves an alias in an ssh:// remote, port and path intact", () => + Effect.gen(function* () { + expect( + yield* canonicalizeSshRemoteUrl( + "ssh://git@work-main:2222/pingdotgg/t3chat.git", + sshConfig("gitlab.example.com"), + ), + ).toBe("ssh://git@gitlab.example.com:2222/pingdotgg/t3chat.git"); + }), + ); + + it.effect("probes each host once", () => + Effect.gen(function* () { + let probes = 0; + const probe = (host: string) => { + probes += 1; + return sshConfig("github.com")(host); + }; + yield* canonicalizeSshRemoteUrl("git@cached-alias:pingdotgg/t3chat.git", probe); + yield* canonicalizeSshRemoteUrl("git@cached-alias:pingdotgg/t3code.git", probe); + expect(probes).toBe(1); + }).pipe(Effect.provideService(SshHostnameCache, new Map())), + ); + + it.effect("retries after a failed probe instead of caching it", () => + Effect.gen(function* () { + const results = ["", "hostname github.com\n"]; + const probe = () => Effect.succeed(results.shift() ?? ""); + yield* canonicalizeSshRemoteUrl("git@flaky:pingdotgg/t3chat.git", probe); + expect(yield* canonicalizeSshRemoteUrl("git@flaky:pingdotgg/t3chat.git", probe)).toBe( + "git@github.com:pingdotgg/t3chat.git", + ); + }).pipe(Effect.provideService(SshHostnameCache, new Map())), + ); + + it.effect("brackets an IPv6 hostname before substitution", () => + Effect.gen(function* () { + expect( + yield* canonicalizeSshRemoteUrl("git@v6:pingdotgg/t3chat.git", sshConfig("2001:db8::1")), + ).toBe("git@[2001:db8::1]:pingdotgg/t3chat.git"); + expect( + yield* canonicalizeSshRemoteUrl( + "ssh://git@v6:2222/pingdotgg/t3chat.git", + sshConfig("2001:db8::1"), + ), + ).toBe("ssh://git@[2001:db8::1]:2222/pingdotgg/t3chat.git"); + }).pipe(Effect.provideService(SshHostnameCache, new Map())), + ); + + it.effect("leaves non-ssh remotes and local paths alone", () => + Effect.gen(function* () { + const failing = () => Effect.die("ssh must not be probed"); + for (const remoteUrl of [ + "https://github.com/pingdotgg/t3chat.git", + "git://github.com/pingdotgg/t3chat.git", + "/home/me/repo", + "C:/Users/me/repo", + "C:\\Users\\me\\repo", + "../sibling/repo", + ]) { + expect(yield* canonicalizeSshRemoteUrl(remoteUrl, failing)).toBe(remoteUrl); + } + }), + ); + + it.effect("leaves a host that ssh does not rewrite alone", () => + Effect.gen(function* () { + expect( + yield* canonicalizeSshRemoteUrl("git@github.com:pingdotgg/t3chat.git", unresolved), + ).toBe("git@github.com:pingdotgg/t3chat.git"); + }), + ); + + it.effect("keeps the remote when ssh cannot be run", () => + Effect.gen(function* () { + expect( + yield* canonicalizeSshRemoteUrl("git@no-ssh-here:pingdotgg/t3chat.git", () => + Effect.succeed(""), + ), + ).toBe("git@no-ssh-here:pingdotgg/t3chat.git"); + }), + ); +}); diff --git a/apps/server/src/vcs/sshRemoteUrl.ts b/apps/server/src/vcs/sshRemoteUrl.ts new file mode 100644 index 000000000000..729066e6d5fe --- /dev/null +++ b/apps/server/src/vcs/sshRemoteUrl.ts @@ -0,0 +1,56 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +import type * as ProcessRunner from "../processRunner.ts"; + +const SSH_URL_HOST = /^(ssh:\/\/(?:[^@/]*@)?)([^@:/]+)/iu; +const SCP_URL_HOST = /^((?:[^@:/]*@)?)([^@:/]{2,})(?=:(?!\/))/u; + +const hostPattern = (remoteUrl: string): RegExp => + /^ssh:\/\//iu.test(remoteUrl) ? SSH_URL_HOST : SCP_URL_HOST; + +export type SshConfigProbe = (host: string) => Effect.Effect; + +export const sshConfigProbe = + (processRunner: ProcessRunner.ProcessRunner["Service"]): SshConfigProbe => + (host) => + processRunner + .run({ command: "ssh", args: ["-G", "--", host], timeoutBehavior: "timedOutResult" }) + .pipe( + Effect.map((result) => result.stdout), + Effect.orElseSucceed(() => ""), + ); + +const HOSTNAME_TTL_MS = 5 * 60_000; + +export const SshHostnameCache = Context.Reference< + Map +>("@t3tools/server/vcs/SshHostnameCache", { + defaultValue: () => new Map(), +}); + +export const canonicalizeSshRemoteUrl = Effect.fnUntraced(function* ( + remoteUrl: string, + probe: SshConfigProbe, +) { + const host = hostPattern(remoteUrl).exec(remoteUrl)?.[2]; + if (host === undefined) return remoteUrl; + + const cache = yield* SshHostnameCache; + const now = Date.now(); + const cached = cache.get(host); + let hostname = + cached !== undefined && now - cached.at < HOSTNAME_TTL_MS ? cached.hostname : undefined; + if (hostname === undefined) { + // Only cache probes that yielded a hostname; a failed or empty ssh -G run + // must not pin the alias unresolved for the whole TTL. + const probed = /^hostname[ \t]+(\S+)/imu.exec(yield* probe(host))?.[1]; + if (probed !== undefined) cache.set(host, { at: now, hostname: probed }); + hostname = probed ?? host; + } + + if (hostname === host) return remoteUrl; + const substituted = + hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname; + return remoteUrl.replace(hostPattern(remoteUrl), (_, prefix: string) => prefix + substituted); +}); diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index 2610f1724a04..5484b46933f5 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -14,8 +14,9 @@ export interface EnvironmentQueryView { readonly refresh: () => void; } -function formatError(cause: Cause.Cause): string { - const error = Cause.squash(cause); +export function environmentQueryError(result: AsyncResult.AsyncResult): string | null { + if (result._tag !== "Failure" || Cause.hasInterruptsOnly(result.cause)) return null; + const error = Cause.squash(result.cause); return error instanceof Error && error.message.trim().length > 0 ? error.message : "The environment request failed."; @@ -29,7 +30,7 @@ export function useEnvironmentQuery( const refresh = useAtomRefresh(selectedAtom); return { data: Option.getOrNull(AsyncResult.value(result)), - error: result._tag === "Failure" ? formatError(result.cause) : null, + error: environmentQueryError(result), isPending: atom !== null && result.waiting, refresh, };