1import * as HostProcess from "@t3tools/shared/HostProcess";
2import * as Config from "effect/Config";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as PlatformError from "effect/PlatformError";
10import * as Semaphore from "effect/Semaphore";
11
12import { SshPasswordPromptError } from "./errors.ts";
13
14export interface SshPasswordRequest {
15 readonly destination: string;
16 readonly username: string | null;
17 readonly prompt: string;
18 readonly attempt: number;
19}
20
21interface SshAskpassFile {
22 readonly path: string;
23 readonly contents: string;
24 readonly mode?: number;
25}
26
27export interface SshAskpassHelperDescriptor {
28 readonly launcherPath: string;
29 readonly files: ReadonlyArray<SshAskpassFile>;
30}
31
32export interface SshAuthOptions {
33 readonly authSecret?: string | null;
34 readonly batchMode?: "yes" | "no";
35 readonly interactiveAuth?: boolean;
36}
37
38export interface SshPasswordPromptShape {
39 readonly isAvailable: boolean;
40 readonly request: (
41 request: SshPasswordRequest,
42 ) => Effect.Effect<string | null, SshPasswordPromptError>;
43}
44
45export class SshPasswordPrompt extends Context.Service<SshPasswordPrompt, SshPasswordPromptShape>()(
46 "@t3tools/ssh/auth/SshPasswordPrompt",
47) {
48 static readonly disabledLayer = Layer.succeed(
49 SshPasswordPrompt,
50 SshPasswordPrompt.of({
51 isAvailable: false,
52 request: Effect.fn("SshPasswordPrompt.request")(function () {
53 return Effect.succeed(null);
54 }),
55 }),
56 );
57}
58
59export interface SshChildEnvironmentOptions {
60 readonly interactiveAuth?: boolean;
61 readonly baseEnv?: NodeJS.ProcessEnv;
62 readonly askpassDirectory?: string;
63 readonly authSecret?: string | null;
64}
65
66const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass";
67
68function joinSshAskpassPath(
69 directory: string,
70 fileName: string,
71 platform: NodeJS.Platform,
72): string {
73 const trimmed = directory.replace(/[\\/]+$/u, "");
74 return platform === "win32" ? `${trimmed}\\${fileName}` : `${trimmed}/${fileName}`;
75}
76
77const ASKPASS_POSIX_SCRIPT = `#!/bin/sh
78# Invoked by ssh via SSH_ASKPASS when T3 Code re-runs ssh with a cached password
79# from the renderer's in-app prompt. We never expose a native dialog here - if
80# T3_SSH_AUTH_SECRET is missing, that's a caller bug and we fail loudly.
81if [ "\${T3_SSH_AUTH_SECRET+x}" = "x" ]; then
82 printf "%s\\n" "$T3_SSH_AUTH_SECRET"
83 exit 0
84fi
85printf 'T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.\\n' >&2
86exit 1
87`;
88
89const ASKPASS_WINDOWS_LAUNCHER_SCRIPT = `@echo off\r
90powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-askpass.ps1" %*\r
91`;
92
93const ASKPASS_WINDOWS_SCRIPT = `# Invoked by ssh via SSH_ASKPASS (through ssh-askpass.cmd) when T3 Code re-runs\r
94# ssh with a cached password from the renderer's in-app prompt. We never expose\r
95# a native dialog here - if T3_SSH_AUTH_SECRET is missing, that's a caller bug\r
96# and we fail loudly.\r
97if ($null -ne $env:T3_SSH_AUTH_SECRET) {\r
98 [Console]::Out.WriteLine($env:T3_SSH_AUTH_SECRET)\r
99 exit 0\r
100}\r
101[Console]::Error.WriteLine("T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.")\r
102exit 1\r
103`;
104
105const buildSshAskpassHelperDescriptor = Effect.fn("ssh/auth.buildSshAskpassHelperDescriptor")(
106 function* (input: {
107 readonly directory: string;
108 }): Effect.fn.Return<SshAskpassHelperDescriptor, never, Path.Path> {
109 const platform = yield* HostProcess.Platform;
110 const path = yield* Path.Path;
111 const directory = input.directory;
112
113 if (platform === "win32") {
114 const powershellPath = joinSshAskpassPath(directory, "ssh-askpass.ps1", platform);
115 return {
116 launcherPath: joinSshAskpassPath(directory, "ssh-askpass.cmd", platform),
117 files: [
118 {
119 path: joinSshAskpassPath(directory, "ssh-askpass.cmd", platform),
120 contents: ASKPASS_WINDOWS_LAUNCHER_SCRIPT,
121 },
122 {
123 path: powershellPath,
124 contents: ASKPASS_WINDOWS_SCRIPT,
125 },
126 ],
127 };
128 }
129
130 return {
131 launcherPath: path.join(directory, "ssh-askpass.sh"),
132 files: [
133 {
134 path: path.join(directory, "ssh-askpass.sh"),
135 contents: ASKPASS_POSIX_SCRIPT,
136 mode: 0o700,
137 },
138 ],
139 };
140 },
141);
142
143const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(function* (input: {
144 readonly directory: string;
145}): Effect.fn.Return<string, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> {
146 const fs = yield* FileSystem.FileSystem;
147 const path = yield* Path.Path;
148 const descriptor = yield* buildSshAskpassHelperDescriptor(input);
149 const platform = yield* HostProcess.Platform;
150
151 yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true });
152
153 for (const file of descriptor.files) {
154 const existing = yield* fs.exists(file.path);
155 const current = existing ? yield* fs.readFileString(file.path) : null;
156 if (current !== file.contents) {
157 yield* fs.writeFileString(file.path, file.contents);
158 }
159 if (file.mode !== undefined && platform !== "win32") {
160 yield* fs.chmod(file.path, file.mode);
161 }
162 }
163
164 return descriptor.launcherPath;
165});
166
167// Keep setup serialized through cancellation so writes finish before another command uses the helper.
168let defaultSshAskpassRuntimeDirectory: string | undefined;
169const defaultSshAskpassLock = Semaphore.makeUnsafe(1);
170
171const ensureDefaultSshAskpassHelpers = Effect.fn("ssh/auth.ensureDefaultSshAskpassHelpers")(
172 function* () {
173 const fs = yield* FileSystem.FileSystem;
174 const path = yield* Path.Path;
175 const userId = yield* HostProcess.UserId;
176 let runtimeDirectory = defaultSshAskpassRuntimeDirectory;
177 if (runtimeDirectory !== undefined) {
178 const cached = runtimeDirectory;
179 const reusable = yield* Effect.all([fs.realPath(cached), fs.stat(cached)]).pipe(
180 Effect.map(
181 ([resolved, info]) =>
182 resolved === cached &&
183 info.type === "Directory" &&
184 (userId === undefined || Option.getOrUndefined(info.uid) === userId),
185 ),
186 Effect.orElseSucceed(() => false),
187 );
188 if (!reusable) {
189 runtimeDirectory = undefined;
190 }
191 }
192 runtimeDirectory ??= yield* fs.realPath(
193 yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }),
194 );
195 const launcherPath = yield* ensureSshAskpassHelpers({
196 directory: path.join(runtimeDirectory, SSH_ASKPASS_DIR_NAME),
197 });
198 defaultSshAskpassRuntimeDirectory = runtimeDirectory;
199 return launcherPath;
200 },
201 (effect) => defaultSshAskpassLock.withPermit(Effect.uninterruptible(effect)),
202);
203
204export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnvironment")(function* (
205 input: SshChildEnvironmentOptions = {},
206): Effect.fn.Return<
207 NodeJS.ProcessEnv,
208 PlatformError.PlatformError,
209 FileSystem.FileSystem | Path.Path
210> {
211 const baseEnv = { ...input.baseEnv };
212 if (!input.interactiveAuth) {
213 return baseEnv;
214 }
215
216 const platform = yield* HostProcess.Platform;
217 const hostDisplay = input.baseEnv
218 ? input.baseEnv.DISPLAY
219 : yield* Config.String("DISPLAY").pipe(
220 Config.option,
221 Effect.orElseSucceed(() => Option.none<string>()),
222 Effect.map(Option.getOrUndefined),
223 );
224 const sshAskpass =
225 input.askpassDirectory === undefined
226 ? yield* ensureDefaultSshAskpassHelpers()
227 : yield* ensureSshAskpassHelpers({ directory: input.askpassDirectory });
228
229 return {
230 ...baseEnv,
231 SSH_ASKPASS: sshAskpass,
232 SSH_ASKPASS_REQUIRE: "force",
233 ...(input.authSecret === undefined ? {} : { T3_SSH_AUTH_SECRET: input.authSecret ?? "" }),
234 ...(platform === "win32" || baseEnv.DISPLAY || hostDisplay ? {} : { DISPLAY: "t3code" }),
235 };
236});
237
238export function isSshAuthFailure(error: unknown): boolean {
239 const message = error instanceof Error ? error.message : String(error);
240 const normalized = message.toLowerCase();
241 return (
242 /permission denied \((?:publickey|password|keyboard-interactive|hostbased|gssapi-with-mic)[^)]*\)/u.test(
243 normalized,
244 ) ||
245 /authentication failed/u.test(normalized) ||
246 /too many authentication failures/u.test(normalized)
247 );
248}
249
250// A local agent signing failure cannot be remedied by the remote account password.
251export function isSshAgentSigningFailure(error: unknown): boolean {
252 const message = error instanceof Error ? error.message : String(error);
253 return /sign_and_send_pubkey: signing failed(?: for [^\n]* from agent:|: (?:agent refused operation|communication with agent failed))/u.test(
254 message,
255 );
256}