From 816899e0e1b1f3992250176d71d6588de0d5c558 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Mon, 19 Oct 2026 22:19:08 -0500 Subject: [PATCH 1/6] fix(ssh): reuse one askpass helper directory instead of a new temp dir per command Every interactive SSH command built its child environment through getDefaultSshAskpassDirectory, which made a new t3code-ssh-runtime-* temp directory each call and never removed it. After a password prompt, each connect, reconnect and stop left more of them behind. Keep the directory for the life of the process and make a fresh one only when it is gone or another user owns the path. Co-Authored-By: Claude Opus 5.5 --- packages/ssh/src/auth.test.ts | 30 ++++++++++++++++++++++++++++++ packages/ssh/src/auth.ts | 20 ++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index 2c507db668e7a..792e9fb5be9ae 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -55,6 +55,36 @@ describe("ssh auth", () => { ), ); + it.effect("reuses the default askpass directory across interactive commands", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const askpassPath = () => + buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe( + Effect.map((env) => env.SSH_ASKPASS ?? ""), + ); + const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass)); + + const first = yield* askpassPath(); + assert.equal(yield* askpassPath(), first); + + yield* fs.remove(runtimeDirectoryOf(first), { recursive: true }); + const recreated = yield* askpassPath(); + assert.notEqual(recreated, first); + assert.equal(yield* fs.exists(recreated), true); + + const otherUser = yield* askpassPath().pipe( + Effect.provideService(HostProcess.UserId, (process.getuid?.() ?? 0) + 1), + ); + assert.notEqual(otherUser, recreated); + + yield* fs.remove(runtimeDirectoryOf(recreated), { recursive: true }); + yield* fs.remove(runtimeDirectoryOf(otherUser), { recursive: true }); + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))), + ), + ); + it.effect("builds a windows askpass launcher pair", () => Effect.gen(function* () { const descriptor = yield* buildSshAskpassHelperDescriptor({ diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index a8bf22bf7e14f..c634741cbe30a 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -99,12 +99,28 @@ if ($null -ne $env:T3_SSH_AUTH_SECRET) {\r exit 1\r `; +// Nothing removes the runtime directory, so every interactive ssh command shares one per +// process. A fresh one replaces it if a temp cleaner deleted it or another user owns the path. +let defaultSshAskpassRuntimeDirectory: string | undefined; + const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")( function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const parentDirectory = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }); - return path.join(parentDirectory, SSH_ASKPASS_DIR_NAME); + const userId = yield* HostProcess.UserId; + const cached = defaultSshAskpassRuntimeDirectory; + if (cached !== undefined) { + const reusable = yield* fs.stat(cached).pipe( + Effect.map((info) => userId === undefined || Option.getOrUndefined(info.uid) === userId), + Effect.orElseSucceed(() => false), + ); + if (reusable) { + return path.join(cached, SSH_ASKPASS_DIR_NAME); + } + } + const runtimeDirectory = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }); + defaultSshAskpassRuntimeDirectory = runtimeDirectory; + return path.join(runtimeDirectory, SSH_ASKPASS_DIR_NAME); }, ); From 8f78741e1dfa47b26eab69636ec50c37f60ccf9c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Mon, 19 Oct 2026 22:56:29 -0500 Subject: [PATCH 2/6] fix(ssh): write askpass helpers before sharing the runtime directory The cached runtime directory was published before its helper existed, so two interactive commands to different hosts could share it while it was still being set up. Both saw the helper missing, and the later write truncated a helper the other command had already handed to ssh. Pick the directory and write its helpers under one in-process lock, and publish the directory only after the helpers exist. Reuse it only while it is still a directory owned by the current user. Co-Authored-By: Claude Opus 5.5 --- packages/ssh/src/auth.test.ts | 82 +++++++++++++++++++++++++++++++++++ packages/ssh/src/auth.ts | 70 ++++++++++++++++++------------ 2 files changed, 125 insertions(+), 27 deletions(-) diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index 792e9fb5be9ae..b5af5332e1876 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -1,7 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -85,6 +87,86 @@ describe("ssh auth", () => { ), ); + it.effect("writes the default askpass helper once when interactive commands overlap", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const runtimeDirectories: Array = []; + yield* Effect.addFinalizer(() => + Effect.forEach(runtimeDirectories, (directory) => + fs.remove(directory, { recursive: true }).pipe(Effect.ignore), + ), + ); + + // The first helper write of each round waits for the test, so the second command starts + // while the first is still setting up and must not touch the shared files until it is done. + let gate: { arrived: Deferred.Deferred; release: Deferred.Deferred } | undefined; + let fileCalls = 0; + let helperWrites = 0; + const counted = (effect: Effect.Effect) => + Effect.suspend(() => { + fileCalls += 1; + return effect; + }); + const trackedFs: FileSystem.FileSystem = { + ...fs, + makeTempDirectory: (options) => + counted(fs.makeTempDirectory(options)).pipe( + Effect.tap((directory) => Effect.sync(() => runtimeDirectories.push(directory))), + ), + stat: (path) => counted(fs.stat(path)), + exists: (path) => counted(fs.exists(path)), + writeFileString: (path, data, options) => + counted( + Effect.gen(function* () { + helperWrites += 1; + const held = gate; + gate = undefined; + if (held) { + yield* Deferred.succeed(held.arrived, undefined); + yield* Deferred.await(held.release); + } + yield* fs.writeFileString(path, data, options); + }), + ), + }; + const askpassPath = buildSshChildEnvironment({ + interactiveAuth: true, + authSecret: "pw", + }).pipe( + Effect.map((env) => env.SSH_ASKPASS ?? ""), + Effect.provideService(FileSystem.FileSystem, trackedFs), + ); + const twoOverlappingCommands = Effect.gen(function* () { + const held = { + arrived: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + gate = held; + const first = yield* Effect.forkChild(askpassPath); + yield* Deferred.await(held.arrived); + const callsBefore = fileCalls; + const second = yield* askpassPath.pipe(Effect.forkChild({ startImmediately: true })); + assert.equal(fileCalls, callsBefore, "second command touched the files mid-setup"); + yield* Deferred.succeed(held.release, undefined); + return [yield* Fiber.join(first), yield* Fiber.join(second)] as const; + }); + + const [first, second] = yield* twoOverlappingCommands; + assert.equal(second, first); + assert.equal(runtimeDirectories.length, 1); + assert.equal(helperWrites, 1); + + yield* fs.remove(first); + assert.deepStrictEqual(yield* twoOverlappingCommands, [first, first]); + assert.equal(runtimeDirectories.length, 1); + assert.equal(helperWrites, 2); + assert.include(yield* fs.readFileString(first), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"'); + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))), + Effect.scoped, + ), + ); + it.effect("builds a windows askpass launcher pair", () => Effect.gen(function* () { const descriptor = yield* buildSshAskpassHelperDescriptor({ diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index c634741cbe30a..401aaebebef86 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Semaphore from "effect/Semaphore"; import { SshPasswordPromptError } from "./errors.ts"; @@ -99,31 +100,6 @@ if ($null -ne $env:T3_SSH_AUTH_SECRET) {\r exit 1\r `; -// Nothing removes the runtime directory, so every interactive ssh command shares one per -// process. A fresh one replaces it if a temp cleaner deleted it or another user owns the path. -let defaultSshAskpassRuntimeDirectory: string | undefined; - -const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")( - function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const userId = yield* HostProcess.UserId; - const cached = defaultSshAskpassRuntimeDirectory; - if (cached !== undefined) { - const reusable = yield* fs.stat(cached).pipe( - Effect.map((info) => userId === undefined || Option.getOrUndefined(info.uid) === userId), - Effect.orElseSucceed(() => false), - ); - if (reusable) { - return path.join(cached, SSH_ASKPASS_DIR_NAME); - } - } - const runtimeDirectory = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }); - defaultSshAskpassRuntimeDirectory = runtimeDirectory; - return path.join(runtimeDirectory, SSH_ASKPASS_DIR_NAME); - }, -); - export const buildSshAskpassHelperDescriptor = Effect.fn( "ssh/auth.buildSshAskpassHelperDescriptor", )(function* (input: { @@ -186,6 +162,44 @@ const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(fu return descriptor.launcherPath; }); +// Nothing removes the runtime directory, so every interactive ssh command shares one per +// process. A fresh one replaces it if a temp cleaner deleted it or another user owns the path. +// The lock publishes a directory only after its helpers exist, so one command never rewrites +// a helper that another command has already handed to ssh. +let defaultSshAskpassRuntimeDirectory: string | undefined; +const defaultSshAskpassLock = Semaphore.makeUnsafe(1); + +const ensureDefaultSshAskpassHelpers = Effect.fn("ssh/auth.ensureDefaultSshAskpassHelpers")( + function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userId = yield* HostProcess.UserId; + const cached = defaultSshAskpassRuntimeDirectory; + if (cached !== undefined) { + const reusable = yield* fs.stat(cached).pipe( + Effect.map( + (info) => + info.type === "Directory" && + (userId === undefined || Option.getOrUndefined(info.uid) === userId), + ), + Effect.orElseSucceed(() => false), + ); + if (reusable) { + return yield* ensureSshAskpassHelpers({ + directory: path.join(cached, SSH_ASKPASS_DIR_NAME), + }); + } + } + const runtimeDirectory = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }); + const launcherPath = yield* ensureSshAskpassHelpers({ + directory: path.join(runtimeDirectory, SSH_ASKPASS_DIR_NAME), + }); + defaultSshAskpassRuntimeDirectory = runtimeDirectory; + return launcherPath; + }, + (effect) => defaultSshAskpassLock.withPermit(effect), +); + export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnvironment")(function* ( input: SshChildEnvironmentOptions = {}, ): Effect.fn.Return< @@ -206,8 +220,10 @@ export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnviron Effect.orElseSucceed(() => Option.none()), Effect.map(Option.getOrUndefined), ); - const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory()); - const sshAskpass = yield* ensureSshAskpassHelpers({ directory }); + const sshAskpass = + input.askpassDirectory === undefined + ? yield* ensureDefaultSshAskpassHelpers() + : yield* ensureSshAskpassHelpers({ directory: input.askpassDirectory }); return { ...baseEnv, From f70e95a02428148c546a4b74561899c052782dfa Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Mon, 19 Oct 2026 23:15:55 -0500 Subject: [PATCH 3/6] fix(ssh): do not reuse an askpass runtime path that is now a symlink The reuse check used stat, which follows symlinks. If a temp cleaner removed the cached runtime directory and someone created a symlink with the same name pointing at a directory the user owns, the check passed and the helper was written through the symlink. Whoever owns the symlink could then repoint it before ssh runs the helper. Cache the resolved path of the new temp directory, and reuse it only while it still resolves to itself and is a directory owned by the current user. Resolving the new directory keeps reuse working when TMPDIR is itself a symlink, as on macOS. Co-Authored-By: Claude Opus 5.5 --- packages/ssh/src/auth.test.ts | 27 +++++++++++++++++++++++++++ packages/ssh/src/auth.ts | 9 ++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index b5af5332e1876..c4af4f03f1034 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -87,6 +87,33 @@ describe("ssh auth", () => { ), ); + it.effect("does not reuse a symlink left at the default askpass directory path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const askpassPath = () => + buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe( + Effect.map((env) => env.SSH_ASKPASS ?? ""), + ); + const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass)); + + const first = yield* askpassPath(); + const ownedTarget = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-symlink-target-" }); + yield* fs.remove(runtimeDirectoryOf(first), { recursive: true }); + yield* fs.symlink(ownedTarget, runtimeDirectoryOf(first)); + + const next = yield* askpassPath(); + assert.notEqual(next, first); + assert.equal(yield* fs.exists(path.join(ownedTarget, "t3code-ssh-askpass")), false); + + yield* fs.remove(runtimeDirectoryOf(first)); + yield* fs.remove(ownedTarget, { recursive: true }); + yield* fs.remove(runtimeDirectoryOf(next), { recursive: true }); + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))), + ), + ); + it.effect("writes the default askpass helper once when interactive commands overlap", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index 401aaebebef86..37095b3741c48 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -176,9 +176,10 @@ const ensureDefaultSshAskpassHelpers = Effect.fn("ssh/auth.ensureDefaultSshAskpa const userId = yield* HostProcess.UserId; const cached = defaultSshAskpassRuntimeDirectory; if (cached !== undefined) { - const reusable = yield* fs.stat(cached).pipe( + const reusable = yield* Effect.all([fs.realPath(cached), fs.stat(cached)]).pipe( Effect.map( - (info) => + ([resolved, info]) => + resolved === cached && info.type === "Directory" && (userId === undefined || Option.getOrUndefined(info.uid) === userId), ), @@ -190,7 +191,9 @@ const ensureDefaultSshAskpassHelpers = Effect.fn("ssh/auth.ensureDefaultSshAskpa }); } } - const runtimeDirectory = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }); + const runtimeDirectory = yield* fs.realPath( + yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }), + ); const launcherPath = yield* ensureSshAskpassHelpers({ directory: path.join(runtimeDirectory, SSH_ASKPASS_DIR_NAME), }); From e30026a088f9a5387dc589b941e1e0539528aad7 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 21:41:07 -0500 Subject: [PATCH 4/6] test(ssh): base the other-user askpass check on the directory owner The other-user case added one to process.getuid(), which can equal the cached directory's owner when the real and effective UIDs differ. It now adds one to the owner read from stat. The symlink case skips on hosts that cannot create symlinks, using the shared symlinksSupported probe. Co-Authored-By: Claude Opus 5.5 --- packages/ssh/src/auth.test.ts | 62 ++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index b2ecaceaa7c1b..deee73a4c1f7f 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -6,8 +6,10 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { buildSshChildEnvironment, isSshAuthFailure } from "./auth.ts"; @@ -71,8 +73,12 @@ describe("ssh auth", () => { assert.notEqual(recreated, first); assert.equal(yield* fs.exists(recreated), true); + const cachedOwner = yield* fs.stat(runtimeDirectoryOf(recreated)); const otherUser = yield* askpassPath().pipe( - Effect.provideService(HostProcess.UserId, (process.getuid?.() ?? 0) + 1), + Effect.provideService( + HostProcess.UserId, + (Option.getOrUndefined(cachedOwner.uid) ?? 0) + 1, + ), ); assert.notEqual(otherUser, recreated); @@ -83,31 +89,35 @@ describe("ssh auth", () => { ), ); - it.effect("does not reuse a symlink left at the default askpass directory path", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const askpassPath = () => - buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe( - Effect.map((env) => env.SSH_ASKPASS ?? ""), - ); - const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass)); - - const first = yield* askpassPath(); - const ownedTarget = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-symlink-target-" }); - yield* fs.remove(runtimeDirectoryOf(first), { recursive: true }); - yield* fs.symlink(ownedTarget, runtimeDirectoryOf(first)); - - const next = yield* askpassPath(); - assert.notEqual(next, first); - assert.equal(yield* fs.exists(path.join(ownedTarget, "t3code-ssh-askpass")), false); - - yield* fs.remove(runtimeDirectoryOf(first)); - yield* fs.remove(ownedTarget, { recursive: true }); - yield* fs.remove(runtimeDirectoryOf(next), { recursive: true }); - }).pipe( - Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))), - ), + it.effect.skipIf(!symlinksSupported)( + "does not reuse a symlink left at the default askpass directory path", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const askpassPath = () => + buildSshChildEnvironment({ interactiveAuth: true, authSecret: "pw" }).pipe( + Effect.map((env) => env.SSH_ASKPASS ?? ""), + ); + const runtimeDirectoryOf = (askpass: string) => path.dirname(path.dirname(askpass)); + + const first = yield* askpassPath(); + const ownedTarget = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-symlink-target-" }); + yield* fs.remove(runtimeDirectoryOf(first), { recursive: true }); + yield* fs.symlink(ownedTarget, runtimeDirectoryOf(first)); + + const next = yield* askpassPath(); + assert.notEqual(next, first); + assert.equal(yield* fs.exists(path.join(ownedTarget, "t3code-ssh-askpass")), false); + + yield* fs.remove(runtimeDirectoryOf(first)); + yield* fs.remove(ownedTarget, { recursive: true }); + yield* fs.remove(runtimeDirectoryOf(next), { recursive: true }); + }).pipe( + Effect.provide( + Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux")), + ), + ), ); it.effect("writes the default askpass helper once when interactive commands overlap", () => From ea544111ede8d896ecd6554fc752e35ae64dc255 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 10:59:06 +0000 Subject: [PATCH 5/6] refactor(ssh): share cached askpass helper setup --- packages/ssh/src/auth.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index 293360e1b64ee..74cac7f814ecc 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -164,10 +164,7 @@ const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(fu return descriptor.launcherPath; }); -// Nothing removes the runtime directory, so every interactive ssh command shares one per -// process. A fresh one replaces it if a temp cleaner deleted it or another user owns the path. -// The lock publishes a directory only after its helpers exist, so one command never rewrites -// a helper that another command has already handed to ssh. +// Serialize setup so concurrent commands cannot truncate a helper already handed to ssh. let defaultSshAskpassRuntimeDirectory: string | undefined; const defaultSshAskpassLock = Semaphore.makeUnsafe(1); @@ -176,8 +173,9 @@ const ensureDefaultSshAskpassHelpers = Effect.fn("ssh/auth.ensureDefaultSshAskpa const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const userId = yield* HostProcess.UserId; - const cached = defaultSshAskpassRuntimeDirectory; - if (cached !== undefined) { + let runtimeDirectory = defaultSshAskpassRuntimeDirectory; + if (runtimeDirectory !== undefined) { + const cached = runtimeDirectory; const reusable = yield* Effect.all([fs.realPath(cached), fs.stat(cached)]).pipe( Effect.map( ([resolved, info]) => @@ -187,13 +185,11 @@ const ensureDefaultSshAskpassHelpers = Effect.fn("ssh/auth.ensureDefaultSshAskpa ), Effect.orElseSucceed(() => false), ); - if (reusable) { - return yield* ensureSshAskpassHelpers({ - directory: path.join(cached, SSH_ASKPASS_DIR_NAME), - }); + if (!reusable) { + runtimeDirectory = undefined; } } - const runtimeDirectory = yield* fs.realPath( + runtimeDirectory ??= yield* fs.realPath( yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" }), ); const launcherPath = yield* ensureSshAskpassHelpers({ From 56b70a4832df233ec0165e7690ea575bb95e8f5a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 11:05:35 +0000 Subject: [PATCH 6/6] fix(ssh): finish askpass setup before releasing a cancelled command --- packages/ssh/src/auth.test.ts | 20 ++++++++++++++++++++ packages/ssh/src/auth.ts | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index deee73a4c1f7f..439350d6010a1 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -193,6 +193,26 @@ describe("ssh auth", () => { assert.deepStrictEqual(yield* twoOverlappingCommands, [first, first]); assert.equal(runtimeDirectories.length, 1); assert.equal(helperWrites, 2); + + yield* fs.remove(first); + const held = { + arrived: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + gate = held; + const cancelled = yield* Effect.forkChild(askpassPath); + yield* Deferred.await(held.arrived); + const callsBeforeCancellation = fileCalls; + const interruption = yield* Fiber.interrupt(cancelled).pipe( + Effect.forkChild({ startImmediately: true }), + ); + const next = yield* askpassPath.pipe(Effect.forkChild({ startImmediately: true })); + const callsDuringCancellation = fileCalls; + yield* Deferred.succeed(held.release, undefined); + yield* Fiber.join(interruption); + assert.equal(yield* Fiber.join(next), first); + assert.equal(callsDuringCancellation, callsBeforeCancellation); + assert.equal(helperWrites, 3); assert.include(yield* fs.readFileString(first), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"'); }).pipe( Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcess.Platform, "linux"))), diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index 74cac7f814ecc..a759063e1df8d 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -164,7 +164,7 @@ const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(fu return descriptor.launcherPath; }); -// Serialize setup so concurrent commands cannot truncate a helper already handed to ssh. +// Keep setup serialized through cancellation so writes finish before another command uses the helper. let defaultSshAskpassRuntimeDirectory: string | undefined; const defaultSshAskpassLock = Semaphore.makeUnsafe(1); @@ -198,7 +198,7 @@ const ensureDefaultSshAskpassHelpers = Effect.fn("ssh/auth.ensureDefaultSshAskpa defaultSshAskpassRuntimeDirectory = runtimeDirectory; return launcherPath; }, - (effect) => defaultSshAskpassLock.withPermit(effect), + (effect) => defaultSshAskpassLock.withPermit(Effect.uninterruptible(effect)), ); export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnvironment")(function* (