From 4d38741e95379653facddd3ad452d70ec1bf3f6c Mon Sep 17 00:00:00 2001 From: Pablo Zaidenvoren Date: Wed, 15 Apr 2026 12:23:27 +0000 Subject: [PATCH] fix socket up --- src/cli.ts | 10 ++++ src/runtime.ts | 74 +++++++++++++++++++++++++++++- tests/runtime.test.ts | 104 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 186 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 762e434..7c4b107 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,6 +35,7 @@ import { configureAuthorizedKeys, copyKnownHosts, devcontainerUp, + ensureManagedContainerSshMountCompatibility, ensureSshAuthSockAccessible, ensureGeneratedConfigIgnored, ensureHostEnvironment, @@ -206,6 +207,15 @@ async function handleUpLike( ); await assertPortAvailable(port, allowCurrentPort); + const sshMountCompatibility = existingInspects[0] + ? await ensureManagedContainerSshMountCompatibility(existingInspects[0], environment.sshAuthSock) + : "not-applicable"; + if (sshMountCompatibility === "created-symlink") { + console.log("Recreated the missing host SSH agent mount source as a symlink to the current SSH_AUTH_SOCK."); + } else if (sshMountCompatibility === "updated-symlink") { + console.log("Updated the stale host SSH agent mount symlink to point at the current SSH_AUTH_SOCK."); + } + console.log(`Starting workspace on port ${port}...`); const upResult = await runStepWithHeartbeat({ startMessage: "Preparing devcontainer. First builds with features may take several minutes...", diff --git a/src/runtime.ts b/src/runtime.ts index dccb0ac..f686668 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { access, appendFile, mkdir, readFile, realpath } from "node:fs/promises"; +import { access, appendFile, lstat, mkdir, readFile, readlink, realpath, rm, symlink } from "node:fs/promises"; import { accessSync, constants as fsConstants } from "node:fs"; import { createServer } from "node:net"; import os from "node:os"; @@ -77,6 +77,12 @@ export interface PortAvailability { pids: string[]; } +export type ManagedContainerSshMountCompatibility = + | "not-applicable" + | "unchanged" + | "created-symlink" + | "updated-symlink"; + export function isExecutableAvailable(command: string): boolean { return findExecutableOnPath(command) !== null; } @@ -604,6 +610,61 @@ export async function removeContainers(containerIds: string[]): Promise { }); } +export async function ensureManagedContainerSshMountCompatibility( + container: DockerInspect, + sshAuthSockSource: string | null, +): Promise { + const trimmedSshAuthSockSource = sshAuthSockSource?.trim() || null; + if (!trimmedSshAuthSockSource || trimmedSshAuthSockSource === DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE) { + return "not-applicable"; + } + + const containerSshAuthSock = getContainerSshAuthSockPath(trimmedSshAuthSockSource); + if (!containerSshAuthSock) { + return "not-applicable"; + } + + const mountedSource = getContainerBindMountSource(container, containerSshAuthSock); + if (!mountedSource) { + return "unchanged"; + } + + const resolvedMountedSource = path.resolve(mountedSource); + const resolvedCurrentSshAuthSock = path.resolve(trimmedSshAuthSockSource); + if (resolvedMountedSource === resolvedCurrentSshAuthSock) { + return "unchanged"; + } + + let mountedSourceStat: Awaited> | null = null; + try { + mountedSourceStat = await lstat(resolvedMountedSource); + } catch (error) { + if (!isMissingPathError(error)) { + throw error; + } + } + + if (!mountedSourceStat) { + await mkdir(path.dirname(resolvedMountedSource), { recursive: true }); + await symlink(resolvedCurrentSshAuthSock, resolvedMountedSource); + return "created-symlink"; + } + + if (!mountedSourceStat.isSymbolicLink()) { + return "unchanged"; + } + + const existingTarget = await readlink(resolvedMountedSource); + const resolvedExistingTarget = path.resolve(path.dirname(resolvedMountedSource), existingTarget); + if (resolvedExistingTarget === resolvedCurrentSshAuthSock) { + return "unchanged"; + } + + await rm(resolvedMountedSource); + await symlink(resolvedCurrentSshAuthSock, resolvedMountedSource); + return "updated-symlink"; +} + export async function devcontainerUp(input: { workspacePath: string; generatedConfigPath: string; @@ -1482,3 +1543,14 @@ export function labelsForWorkspaceHash(workspaceHash: string): Record mount?.Type === "bind" && typeof mount.Source === "string" && mount.Destination === destination, + ); + return matchingMount?.Source ? path.resolve(matchingMount.Source) : null; +} + +function isMissingPathError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index dbcae93..c1ff5ed 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readlink, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; @@ -29,6 +29,7 @@ import { resolveSshPublicKey, resolveShellContainerId, resolveGhCliToken, + ensureManagedContainerSshMountCompatibility, requiresSshAuthSockPermissionFix, resolveSshAuthSockSource, } from "../src/runtime"; @@ -110,6 +111,107 @@ describe("resolveSshAuthSockSource", () => { }); }); +describe("ensureManagedContainerSshMountCompatibility", () => { + test("creates a compatibility symlink when the previous ssh agent mount source is missing", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "devbox-test-")); + tempPaths.push(tempDir); + const currentSocketPath = path.join(tempDir, "current", "agent.sock"); + const previousSocketPath = path.join(tempDir, "old", "agent.sock"); + await mkdir(path.dirname(currentSocketPath), { recursive: true }); + await writeFile(currentSocketPath, "socket"); + + const outcome = await ensureManagedContainerSshMountCompatibility( + { + Id: "container-1", + Mounts: [ + { + Type: "bind", + Source: previousSocketPath, + Destination: "/run/devbox-ssh-auth.sock", + }, + ], + }, + currentSocketPath, + ); + + expect(outcome).toBe("created-symlink"); + expect(await readlink(previousSocketPath)).toBe(currentSocketPath); + }); + + test("updates an existing stale compatibility symlink to the current ssh agent socket", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "devbox-test-")); + tempPaths.push(tempDir); + const currentSocketPath = path.join(tempDir, "current", "agent.sock"); + const staleSocketPath = path.join(tempDir, "stale", "agent.sock"); + const compatibilityPath = path.join(tempDir, "old", "agent.sock"); + await mkdir(path.dirname(currentSocketPath), { recursive: true }); + await mkdir(path.dirname(staleSocketPath), { recursive: true }); + await mkdir(path.dirname(compatibilityPath), { recursive: true }); + await writeFile(currentSocketPath, "current"); + await writeFile(staleSocketPath, "stale"); + await symlink(staleSocketPath, compatibilityPath); + + const outcome = await ensureManagedContainerSshMountCompatibility( + { + Id: "container-1", + Mounts: [ + { + Type: "bind", + Source: compatibilityPath, + Destination: "/run/devbox-ssh-auth.sock", + }, + ], + }, + currentSocketPath, + ); + + expect(outcome).toBe("updated-symlink"); + expect(await readlink(compatibilityPath)).toBe(currentSocketPath); + }); + + test("does nothing when the existing mount already points at the current host ssh agent socket", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "devbox-test-")); + tempPaths.push(tempDir); + const currentSocketPath = path.join(tempDir, "current", "agent.sock"); + await mkdir(path.dirname(currentSocketPath), { recursive: true }); + await writeFile(currentSocketPath, "current"); + + await expect( + ensureManagedContainerSshMountCompatibility( + { + Id: "container-1", + Mounts: [ + { + Type: "bind", + Source: currentSocketPath, + Destination: "/run/devbox-ssh-auth.sock", + }, + ], + }, + currentSocketPath, + ), + ).resolves.toBe("unchanged"); + }); + + test("does nothing when ssh agent sharing is disabled or handled by Docker Desktop", async () => { + const container: DockerInspect = { + Id: "container-1", + Mounts: [ + { + Type: "bind", + Source: "/tmp/old.sock", + Destination: "/run/devbox-ssh-auth.sock", + }, + ], + }; + + await expect(ensureManagedContainerSshMountCompatibility(container, null)).resolves.toBe("not-applicable"); + await expect( + ensureManagedContainerSshMountCompatibility(container, DOCKER_DESKTOP_SSH_AUTH_SOCK_SOURCE), + ).resolves.toBe("not-applicable"); + }); +}); + describe("buildStopManagedSshdScript", () => { test("targets only sshd listeners on the managed port", () => { const script = buildStopManagedSshdScript(5001);