fix(server): support WSL UNC opencode binaries on Windows
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
import { probeBinaryVersion } from "../../workspaces/spawn"
|
||||||
import type { SettingsService } from "../../settings/service"
|
import type { SettingsService } from "../../settings/service"
|
||||||
import type { Logger } from "../../logger"
|
import type { Logger } from "../../logger"
|
||||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||||
|
|||||||
70
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
70
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
|
import { buildWindowsSpawnSpec, 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(
|
||||||
|
resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu"),
|
||||||
|
"/home/dev/workspace",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("maps Windows drive paths into /mnt when launching through WSL", () => {
|
||||||
|
assert.equal(resolveWslWorkingDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu"), "/mnt/c/Users/dev/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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,98 +4,7 @@ import path from "path"
|
|||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
import { buildSpawnSpec } from "./spawn"
|
||||||
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 ""<script> <args>"" pattern ensures paths with spaces are handled.
|
|
||||||
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: comspec,
|
|
||||||
args: ["/d", "/s", "/c", commandLine],
|
|
||||||
options: { windowsVerbatimArguments: true } as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
|
||||||
// powershell.exe ships with Windows. (pwsh may not.)
|
|
||||||
return {
|
|
||||||
command: "powershell.exe",
|
|
||||||
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
|
||||||
options: {} as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { command: binaryPath, args, options: {} as const }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function probeBinaryVersion(binaryPath: string): {
|
|
||||||
valid: boolean
|
|
||||||
version?: string
|
|
||||||
reported?: string
|
|
||||||
error?: string
|
|
||||||
} {
|
|
||||||
if (!binaryPath) {
|
|
||||||
return { valid: false, error: "Missing binary path" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync(spec.command, spec.args, {
|
|
||||||
encoding: "utf8",
|
|
||||||
windowsVerbatimArguments: Boolean(
|
|
||||||
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return { valid: false, error: result.error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
const stderr = result.stderr?.trim()
|
|
||||||
const stdout = result.stdout?.trim()
|
|
||||||
const combined = stderr || stdout
|
|
||||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
|
||||||
return { valid: false, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdoutLines = String(result.stdout ?? "")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
const stderrLines = String(result.stderr ?? "")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
|
|
||||||
// Prefer stdout; fall back to stderr (some tools report version there).
|
|
||||||
const reported = stdoutLines[0] ?? stderrLines[0]
|
|
||||||
if (!reported) {
|
|
||||||
return { valid: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionMatch = reported.match(VERSION_REGEX)
|
|
||||||
const version = versionMatch?.[1]
|
|
||||||
return { valid: true, version, reported }
|
|
||||||
} catch (error) {
|
|
||||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||||
|
|
||||||
@@ -167,7 +76,12 @@ export class WorkspaceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const spec = buildSpawnSpec(options.binaryPath, args)
|
const propagatedEnvKeys = Object.keys(options.environment ?? {})
|
||||||
|
const spec = buildSpawnSpec(options.binaryPath, args, {
|
||||||
|
cwd: options.folder,
|
||||||
|
env,
|
||||||
|
propagateEnvKeys: propagatedEnvKeys,
|
||||||
|
})
|
||||||
const commandLine = [spec.command, ...spec.args].join(" ")
|
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{
|
{
|
||||||
@@ -197,8 +111,8 @@ export class WorkspaceRuntime {
|
|||||||
)
|
)
|
||||||
const detached = process.platform !== "win32"
|
const detached = process.platform !== "win32"
|
||||||
const child = spawn(spec.command, spec.args, {
|
const child = spawn(spec.command, spec.args, {
|
||||||
cwd: options.folder,
|
cwd: spec.cwd,
|
||||||
env,
|
env: spec.env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached,
|
detached,
|
||||||
...spec.options,
|
...spec.options,
|
||||||
|
|||||||
229
packages/server/src/workspaces/spawn.ts
Normal file
229
packages/server/src/workspaces/spawn.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { spawnSync } from "child_process"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
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.-]+)/
|
||||||
|
const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i
|
||||||
|
const WINDOWS_DRIVE_PATH_REGEX = /^([A-Za-z]):[\\/]*(.*)$/
|
||||||
|
const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"])
|
||||||
|
|
||||||
|
export interface SpawnSpec {
|
||||||
|
command: string
|
||||||
|
args: string[]
|
||||||
|
options: {
|
||||||
|
windowsVerbatimArguments?: boolean
|
||||||
|
}
|
||||||
|
cwd?: string
|
||||||
|
env?: NodeJS.ProcessEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildSpawnSpecOptions {
|
||||||
|
cwd?: string
|
||||||
|
env?: NodeJS.ProcessEnv
|
||||||
|
propagateEnvKeys?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WslPath {
|
||||||
|
distro: string
|
||||||
|
linuxPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWslUncPath(input: string): WslPath | null {
|
||||||
|
const normalized = input.trim().replace(/\//g, "\\")
|
||||||
|
const match = normalized.match(WSL_UNC_PATH_REGEX)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const distro = match[1] ?? ""
|
||||||
|
const remainder = match[2] ?? ""
|
||||||
|
const segments = remainder.split(/\\+/).filter((segment) => segment.length > 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
distro,
|
||||||
|
linuxPath: segments.length > 0 ? `/${segments.join("/")}` : "/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWslWorkingDirectory(folder: string, distro: string): string | null {
|
||||||
|
const wslFolder = parseWslUncPath(folder)
|
||||||
|
if (wslFolder) {
|
||||||
|
return wslFolder.distro.toLowerCase() === distro.toLowerCase() ? wslFolder.linuxPath : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return translateWindowsDrivePath(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWindowsSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||||
|
const wslPath = parseWslUncPath(binaryPath)
|
||||||
|
if (wslPath) {
|
||||||
|
return buildWslSpawnSpec(wslPath, args, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||||
|
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: comspec,
|
||||||
|
args: ["/d", "/s", "/c", commandLine],
|
||||||
|
options: { windowsVerbatimArguments: true },
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||||
|
// powershell.exe ships with Windows. (pwsh may not.)
|
||||||
|
return {
|
||||||
|
command: "powershell.exe",
|
||||||
|
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||||
|
options: {},
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: binaryPath,
|
||||||
|
args,
|
||||||
|
options: {},
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return {
|
||||||
|
command: binaryPath,
|
||||||
|
args,
|
||||||
|
options: {},
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildWindowsSpawnSpec(binaryPath, args, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function probeBinaryVersion(binaryPath: string): {
|
||||||
|
valid: boolean
|
||||||
|
version?: string
|
||||||
|
reported?: string
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
if (!binaryPath) {
|
||||||
|
return { valid: false, error: "Missing binary path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
||||||
|
const result = spawnSync(spec.command, spec.args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
cwd: spec.cwd,
|
||||||
|
env: spec.env,
|
||||||
|
windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return { valid: false, error: result.error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr?.trim()
|
||||||
|
const stdout = result.stdout?.trim()
|
||||||
|
const combined = stderr || stdout
|
||||||
|
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||||
|
return { valid: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdoutLines = String(result.stdout ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
const stderrLines = String(result.stderr ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
|
||||||
|
// Prefer stdout; fall back to stderr (some tools report version there).
|
||||||
|
const reported = stdoutLines[0] ?? stderrLines[0]
|
||||||
|
if (!reported) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionMatch = reported.match(VERSION_REGEX)
|
||||||
|
const version = versionMatch?.[1]
|
||||||
|
return { valid: true, version, reported }
|
||||||
|
} catch (error) {
|
||||||
|
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec {
|
||||||
|
const linuxCwd = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined
|
||||||
|
if (options.cwd && !linuxCwd) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wslArgs = ["--distribution", wslPath.distro]
|
||||||
|
if (linuxCwd) {
|
||||||
|
wslArgs.push("--cd", linuxCwd)
|
||||||
|
}
|
||||||
|
wslArgs.push("--exec", wslPath.linuxPath, ...args)
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: "wsl.exe",
|
||||||
|
args: wslArgs,
|
||||||
|
options: {},
|
||||||
|
env: buildWslEnvironment(options.env, options.propagateEnvKeys),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateWindowsDrivePath(input: string): string | null {
|
||||||
|
const normalized = input.trim().replace(/\//g, "\\")
|
||||||
|
const match = normalized.match(WINDOWS_DRIVE_PATH_REGEX)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const driveLetter = (match[1] ?? "").toLowerCase()
|
||||||
|
const remainder = match[2] ?? ""
|
||||||
|
const segments = remainder.split(/\\+/).filter((segment) => segment.length > 0)
|
||||||
|
|
||||||
|
return segments.length > 0 ? `/mnt/${driveLetter}/${segments.join("/")}` : `/mnt/${driveLetter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined {
|
||||||
|
if (!env) {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysToPropagate = (propagateEnvKeys ?? []).filter((key) => env[key] !== undefined)
|
||||||
|
if (keysToPropagate.length === 0) {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = { ...env }
|
||||||
|
const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0)
|
||||||
|
const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry]))
|
||||||
|
|
||||||
|
for (const key of keysToPropagate) {
|
||||||
|
if (byName.has(key)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key)
|
||||||
|
}
|
||||||
|
|
||||||
|
next.WSLENV = Array.from(byName.values()).join(":")
|
||||||
|
return next
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user