Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
configureAuthorizedKeys,
copyKnownHosts,
devcontainerUp,
ensureManagedContainerSshMountCompatibility,
ensureSshAuthSockAccessible,
ensureGeneratedConfigIgnored,
ensureHostEnvironment,
Expand Down Expand Up @@ -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...",
Expand Down
74 changes: 73 additions & 1 deletion src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -604,6 +610,61 @@ export async function removeContainers(containerIds: string[]): Promise<void> {
});
}

export async function ensureManagedContainerSshMountCompatibility(
container: DockerInspect,
sshAuthSockSource: string | null,
): Promise<ManagedContainerSshMountCompatibility> {
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<ReturnType<typeof lstat>> | 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";
Comment on lines +632 to +660
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path comparisons here use path.resolve, which does not canonicalize symlinks. On macOS (e.g. /tmp vs /private/tmp) or when SSH_AUTH_SOCK itself is a symlink, this can cause an unnecessary updated-symlink outcome even though both paths refer to the same underlying socket. Consider comparing canonicalized paths (e.g. via the existing resolveComparablePath helper or realpath where available) before deciding to update the symlink.

Copilot uses AI. Check for mistakes.
}

await rm(resolvedMountedSource);
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rm(resolvedMountedSource) can throw ENOENT if the symlink is removed between the earlier lstat/readlink and this call (or if the path is concurrently modified). Using rm(..., { force: true }) (consistent with other rm usages in the repo) or unlink with a missing-path guard would make this update step more robust.

Suggested change
await rm(resolvedMountedSource);
await rm(resolvedMountedSource, { force: true });

Copilot uses AI. Check for mistakes.
await symlink(resolvedCurrentSshAuthSock, resolvedMountedSource);
return "updated-symlink";
}

export async function devcontainerUp(input: {
workspacePath: string;
generatedConfigPath: string;
Expand Down Expand Up @@ -1482,3 +1543,14 @@ export function labelsForWorkspaceHash(workspaceHash: string): Record<string, st
[WORKSPACE_LABEL_KEY]: workspaceHash,
};
}

function getContainerBindMountSource(container: DockerInspect, destination: string): string | null {
const matchingMount = (container.Mounts ?? []).find(
(mount) => 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";
}
104 changes: 103 additions & 1 deletion tests/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -29,6 +29,7 @@ import {
resolveSshPublicKey,
resolveShellContainerId,
resolveGhCliToken,
ensureManagedContainerSshMountCompatibility,
requiresSshAuthSockPermissionFix,
resolveSshAuthSockSource,
} from "../src/runtime";
Expand Down Expand Up @@ -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");
});

Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new suite doesn’t currently cover the branch where the existing mount source is a symlink that already points at the current SSH_AUTH_SOCK (the isSymbolicLink() + resolvedExistingTarget === resolvedCurrentSshAuthSock path). Adding a test for that scenario would help ensure the function doesn’t rewrite a correct symlink and keeps the outcome as unchanged.

Suggested change
test("does nothing when the existing mount source is a symlink that already resolves to 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");
const compatibilitySocketPath = path.join(tempDir, "compat", "agent.sock");
await mkdir(path.dirname(currentSocketPath), { recursive: true });
await mkdir(path.dirname(compatibilitySocketPath), { recursive: true });
await writeFile(currentSocketPath, "current");
await symlink(currentSocketPath, compatibilitySocketPath);
await expect(
ensureManagedContainerSshMountCompatibility(
{
Id: "container-1",
Mounts: [
{
Type: "bind",
Source: compatibilitySocketPath,
Destination: "/run/devbox-ssh-auth.sock",
},
],
},
currentSocketPath,
),
).resolves.toBe("unchanged");
expect(await readlink(compatibilitySocketPath)).toBe(currentSocketPath);
});

Copilot uses AI. Check for mistakes.
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);
Expand Down
Loading