From 77df40169a449b39ae8634a763c96ea04fae548c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 20 Apr 2026 21:29:08 +0200 Subject: [PATCH] Fix WSL UNC OpenCode binaries on Windows (#341) ## Summary - support Windows validation and launch of OpenCode binaries stored under WSL UNC paths like \\wsl.localhost\... - harden the existing manual directory browser so absolute, UNC, and WSL paths can be pasted and navigated reliably - harden WSL env/path propagation, UNC workspace handling, runtime shutdown, and add targeted tests Partially addresses #5. ## Testing - node --test --import tsx src/workspaces/__tests__/spawn.test.ts - npm run typecheck --workspace @neuralnomads/codenomad - npm run typecheck --workspace @codenomad/ui --- packages/server/src/server/routes/settings.ts | 2 +- .../src/workspaces/__tests__/spawn.test.ts | 193 +++++++++++ packages/server/src/workspaces/runtime.ts | 164 ++++------ packages/server/src/workspaces/spawn.ts | 307 ++++++++++++++++++ .../components/directory-browser-dialog.tsx | 109 +++++-- 5 files changed, 651 insertions(+), 124 deletions(-) create mode 100644 packages/server/src/workspaces/__tests__/spawn.test.ts create mode 100644 packages/server/src/workspaces/spawn.ts diff --git a/packages/server/src/server/routes/settings.ts b/packages/server/src/server/routes/settings.ts index 1d6fe13a..5d18e4fd 100644 --- a/packages/server/src/server/routes/settings.ts +++ b/packages/server/src/server/routes/settings.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from "fastify" import { z } from "zod" -import { probeBinaryVersion } from "../../workspaces/runtime" +import { probeBinaryVersion } from "../../workspaces/spawn" import type { SettingsService } from "../../settings/service" import type { Logger } from "../../logger" import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config" diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts new file mode 100644 index 00000000..e91d4b2a --- /dev/null +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -0,0 +1,193 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn" + +describe("parseWslUncPath", () => { + it("parses WSL UNC paths into distro and linux path", () => { + assert.deepEqual(parseWslUncPath(String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), { + distro: "Ubuntu", + linuxPath: "/home/dev/.opencode/bin/opencode", + }) + }) + + it("supports the legacy wsl$ UNC prefix", () => { + assert.deepEqual(parseWslUncPath(String.raw`\\wsl$\Ubuntu\home\dev`), { + distro: "Ubuntu", + linuxPath: "/home/dev", + }) + }) +}) + +describe("resolveWslWorkingDirectory", () => { + it("keeps WSL workspace folders in the same distro", () => { + assert.equal( + JSON.stringify(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu")), + JSON.stringify({ kind: "linux", path: "/home/dev/workspace" }), + ) + }) + + it("keeps Windows drive paths so WSL can resolve them with wslpath", () => { + assert.equal( + JSON.stringify(resolveWslWorkingDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu")), + JSON.stringify({ kind: "windows", path: String.raw`C:\Users\dev\workspace` }), + ) + }) + + it("keeps UNC network paths so WSL can resolve them with wslpath", () => { + assert.equal( + JSON.stringify(resolveWslWorkingDirectory(String.raw`\\server\share\workspace`, "Ubuntu")), + JSON.stringify({ kind: "windows", path: String.raw`\\server\share\workspace` }), + ) + }) + + it("rejects WSL workspace folders from a different distro", () => { + assert.equal(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Debian\home\dev\workspace`, "Ubuntu"), null) + }) +}) + +describe("buildWindowsSpawnSpec", () => { + it("wraps WSL binaries with wsl.exe and propagates required env vars", () => { + const spec = buildWindowsSpawnSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + ["serve", "--port", "0"], + { + cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, + env: { + OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`, + CODENOMAD_INSTANCE_ID: "workspace-123", + OPENCODE_SERVER_PASSWORD: "secret", + }, + propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_PASSWORD"], + }, + ) + + assert.equal(spec.command, "wsl.exe") + assert.deepEqual(spec.args, [ + "--distribution", + "Ubuntu", + "--cd", + "/home/dev/workspace", + "--exec", + "/home/dev/.opencode/bin/opencode", + "serve", + "--port", + "0", + ]) + assert.equal(spec.cwd, undefined) + assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_PASSWORD") + }) + + it("upgrades existing WSLENV path entries to include /p", () => { + const spec = buildWindowsSpawnSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + ["serve"], + { + env: { + OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`, + WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u", + }, + propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"], + }, + ) + + assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u") + }) + + it("propagates inherited known path variables even when they are not explicitly requested", () => { + const spec = buildWindowsSpawnSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + ["serve"], + { + env: { + NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`, + }, + }, + ) + + assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p") + }) + + it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => { + const spec = buildWindowsSpawnSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + ["serve", "--port", "0"], + { + cwd: String.raw`C:\Users\dev\workspace`, + }, + ) + + assert.equal(spec.command, "wsl.exe") + assert.deepEqual(spec.args, [ + "--distribution", + "Ubuntu", + "--exec", + "sh", + "-lc", + 'cd "$(wslpath -au "$1")" && shift && exec "$@"', + "codenomad-wsl-launch", + String.raw`C:\Users\dev\workspace`, + "/home/dev/.opencode/bin/opencode", + "serve", + "--port", + "0", + ]) + }) + + it("uses wslpath for UNC network workspace folders", () => { + const spec = buildWindowsSpawnSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + ["serve"], + { + cwd: String.raw`\\server\share\workspace`, + }, + ) + + assert.equal(spec.command, "wsl.exe") + assert.deepEqual(spec.args, [ + "--distribution", + "Ubuntu", + "--exec", + "sh", + "-lc", + 'cd "$(wslpath -au "$1")" && shift && exec "$@"', + "codenomad-wsl-launch", + String.raw`\\server\share\workspace`, + "/home/dev/.opencode/bin/opencode", + "serve", + ]) + }) + + it("can wrap WSL launches to emit the Linux PID marker", () => { + const spec = buildWindowsSpawnSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + ["serve"], + { + cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, + wslPidMarker: "__CODENOMAD_WSL_PID__:", + }, + ) + + assert.equal(spec.command, "wsl.exe") + assert.deepEqual(spec.args, [ + "--distribution", + "Ubuntu", + "--exec", + "sh", + "-lc", + `printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`, + "codenomad-wsl-launch", + "/home/dev/workspace", + "/home/dev/.opencode/bin/opencode", + "serve", + ]) + assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:") + }) + + it("builds the WSL kill command for tracked Linux PIDs", () => { + const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM") + + assert.equal(spec.command, "wsl.exe") + assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"]) + }) +}) diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index 1269f0b7..efc77f9a 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -4,100 +4,10 @@ import path from "path" import { EventBus } from "../events/bus" import { LogLevel, WorkspaceLogEntry } from "../api-types" import { Logger } from "../logger" - -export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) -export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) - -const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/ - -export function buildSpawnSpec(binaryPath: string, args: string[]) { - if (process.platform !== "win32") { - return { command: binaryPath, args, options: {} as const } - } - - const extension = path.extname(binaryPath).toLowerCase() - - if (WINDOWS_CMD_EXTENSIONS.has(extension)) { - const comspec = process.env.ComSpec || "cmd.exe" - // cmd.exe requires the full command as a single string. - // Using the ""