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