From 68a5c42d66fa0cdb1b5b3e955a59287997a9e317 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 18:41:39 +0000 Subject: [PATCH] Fix devcontainer startup failure when node is managed by nvm When the devcontainer CLI path is absolute (e.g. installed via nvm), its #!/usr/bin/env node shebang requires node to be on PATH. VS Code child processes often don't inherit nvm's PATH entries when launched from the OS application launcher. Prepend the CLI binary's parent directory to PATH in execCommand so sibling binaries like node are discoverable. https://claude.ai/code/session_012wQn8VuzGxVXtDYbwmekUk --- src/devcontainerManager.test.ts | 69 +++++++++++++++++++++++++++++++++ src/devcontainerManager.ts | 15 ++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/devcontainerManager.test.ts b/src/devcontainerManager.test.ts index 7306184..7db794c 100644 --- a/src/devcontainerManager.test.ts +++ b/src/devcontainerManager.test.ts @@ -33,9 +33,11 @@ vi.mock("child_process", async () => { // Import after mocking import * as fs from "fs"; +import { exec } from "child_process"; const mockExistsSync = fs.existsSync as ReturnType; const mockReadFileSync = fs.readFileSync as ReturnType; +const mockExec = exec as unknown as ReturnType; let manager: DevcontainerManager; @@ -215,6 +217,73 @@ describe("stopDevcontainer", () => { }); }); +// --------------------------------------------------------------------------- +// startDevcontainer – PATH augmentation +// --------------------------------------------------------------------------- + +describe("startDevcontainer PATH handling", () => { + beforeEach(() => { + mockExistsSync.mockImplementation((p: unknown) => { + return String(p) === "/home/user/project/.devcontainer/devcontainer.json"; + }); + }); + + it("prepends bin directory to PATH when devcontainerCliPath is absolute", async () => { + __setMockConfig({ + "opencode-devcontainer.devcontainerCliPath": "/usr/local/nvm/versions/node/v20/bin/devcontainer", + }); + + // Make exec call succeed with a container ID + mockExec.mockImplementation( + ( + cmd: string, + opts: Record, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + if (cmd.includes("devcontainer up")) { + // Verify that the env.PATH starts with the binary's directory + const env = opts.env as Record; + expect(env).toBeDefined(); + expect(env.PATH).toMatch(/^\/usr\/local\/nvm\/versions\/node\/v20\/bin/); + cb(null, JSON.stringify({ containerId: "abc123" }), ""); + } else { + cb(null, "", ""); + } + } + ); + + await manager.startDevcontainer(); + expect(mockExec).toHaveBeenCalled(); + }); + + it("does not modify PATH when devcontainerCliPath is a bare command", async () => { + __setMockConfig({ + "opencode-devcontainer.devcontainerCliPath": "devcontainer", + }); + + const originalPath = process.env.PATH; + + mockExec.mockImplementation( + ( + cmd: string, + opts: Record, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + if (cmd.includes("devcontainer up")) { + const env = opts.env as Record; + expect(env.PATH).toBe(originalPath); + cb(null, JSON.stringify({ containerId: "abc123" }), ""); + } else { + cb(null, "", ""); + } + } + ); + + await manager.startDevcontainer(); + expect(mockExec).toHaveBeenCalled(); + }); +}); + // --------------------------------------------------------------------------- // dispose // --------------------------------------------------------------------------- diff --git a/src/devcontainerManager.ts b/src/devcontainerManager.ts index 671a1fd..21fa571 100644 --- a/src/devcontainerManager.ts +++ b/src/devcontainerManager.ts @@ -282,8 +282,21 @@ export class DevcontainerManager { command: string, options?: { cwd?: string } ): Promise<{ stdout: string; stderr: string }> { + // When the command binary is an absolute path, ensure its parent directory + // is on PATH. This fixes environments where node is managed by nvm and the + // devcontainer CLI (a #!/usr/bin/env node script) lives in the same bin + // directory as the node binary, but that directory isn't on the default + // PATH inherited by child processes (common when VS Code is launched from + // the OS application launcher rather than a terminal). + const env = { ...process.env }; + const binaryPath = command.split(" ")[0]; + if (path.isAbsolute(binaryPath)) { + const binDir = path.dirname(binaryPath); + env.PATH = binDir + path.delimiter + (env.PATH || ""); + } + return new Promise((resolve, reject) => { - exec(command, { cwd: options?.cwd, timeout: 120000 }, (error, stdout, stderr) => { + exec(command, { cwd: options?.cwd, timeout: 120000, env }, (error, stdout, stderr) => { if (error) { reject(new Error(`Command failed: ${command}\n${stderr || error.message}`)); return;