Skip to content
Closed
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
3 changes: 3 additions & 0 deletions packages/opencode/src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { lookup } from "mime-types"
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "../util/glob"
import { Platform } from "../util/platform"

export namespace AppFileSystem {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
Expand Down Expand Up @@ -188,6 +189,8 @@ export namespace AppFileSystem {

export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
// WSL UNC paths are already valid Windows paths — skip realpathSync
if (Platform.isWslUncPath(p)) return p
const resolved = pathResolve(windowsPath(p))
try {
return realpathSync.native(resolved)
Expand Down
30 changes: 22 additions & 8 deletions packages/opencode/src/git/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Effect, Layer, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { Platform } from "@/util/platform"

export namespace Git {
const cfg = [
Expand Down Expand Up @@ -88,14 +89,27 @@ export namespace Git {

const run = Effect.fn("Git.run")(
function* (args: string[], opts: Options) {
const proc = ChildProcess.make("git", [...cfg, ...args], {
cwd: opts.cwd,
env: opts.env,
extendEnv: true,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
// When cwd is a WSL UNC path, run git inside WSL
const wslInfo = Platform.isWslUncPath(opts.cwd) ? Platform.parseWslUncPath(opts.cwd) : undefined

const proc =
wslInfo && process.platform === "win32"
? ChildProcess.make("wsl.exe", ["-d", wslInfo.distro, "-e", "git", ...cfg, ...args], {
cwd: process.env.SYSTEMROOT || "C:\\Windows",
env: opts.env,
extendEnv: true,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
: ChildProcess.make("git", [...cfg, ...args], {
cwd: opts.cwd,
env: opts.env,
extendEnv: true,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const handle = yield* spawner.spawn(proc)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Flag } from "@/flag/flag"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { which } from "../util/which"
import { Platform } from "../util/platform"
import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
Expand Down Expand Up @@ -149,6 +150,11 @@ export namespace Project {
if (!name) return cwd
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
// Don't convert WSL UNC paths through windowsPath — they're already valid
if (Platform.isWslUncPath(cwd)) {
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
return pathSvc.resolve(cwd, name)
}
name = AppFileSystem.windowsPath(name)
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
return pathSvc.resolve(cwd, name)
Expand Down
12 changes: 11 additions & 1 deletion packages/opencode/src/shell/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Flag } from "@/flag/flag"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { which } from "@/util/which"
import { Platform } from "@/util/platform"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"
Expand Down Expand Up @@ -61,9 +62,14 @@ export namespace Shell {
if (powershell) return powershell
}

function select(file: string | undefined, opts?: { acceptable?: boolean }) {
function select(file: string | undefined, opts?: { acceptable?: boolean; directory?: string }) {
if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
if (process.platform === "win32") {
// If the project is inside WSL filesystem, use wsl.exe as shell wrapper
if (opts?.directory && Platform.isWslUncPath(opts.directory)) {
const wsl = which("wsl.exe")
if (wsl) return wsl
}
const shell = pick()
if (shell) return shell
}
Expand Down Expand Up @@ -107,4 +113,8 @@ export namespace Shell {
export const preferred = lazy(() => select(process.env.SHELL))

export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))

export function forDirectory(directory: string): string {
return select(process.env.SHELL, { acceptable: true, directory })
}
}
32 changes: 23 additions & 9 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AppFileSystem } from "@/filesystem"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag"
import { Shell } from "@/shell/shell"
import { Platform } from "@/util/platform"

import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncate"
Expand All @@ -23,6 +24,7 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner
const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
const PS = new Set(["powershell", "pwsh"])
const WSL = new Set(["wsl.exe", "wsl"])
const CWD = new Set(["cd", "push-location", "set-location"])
const FILES = new Set([
...CWD,
Expand Down Expand Up @@ -243,8 +245,19 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan)
})
})

function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && PS.has(name)) {
function cmd(shell: string, shName: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && WSL.has(shName)) {
const info = Platform.parseWslUncPath(cwd)
const args = info ? ["-d", info.distro, "-e", "bash", "-lc", command] : ["-e", "bash", "-lc", command]
return ChildProcess.make(shell, args, {
cwd: process.env.SYSTEMROOT || "C:\\Windows",
env,
stdin: "ignore",
detached: false,
})
}

if (process.platform === "win32" && PS.has(shName)) {
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
cwd,
env,
Expand Down Expand Up @@ -455,18 +468,19 @@ export const BashTool = Tool.define(
})

return async () => {
const shell = Shell.acceptable()
const name = Shell.name(shell)
const chain =
name === "powershell"
const shell = Shell.forDirectory(Instance.directory)
const shName = Shell.name(shell)
const chain = WSL.has(shName)
? "Commands run inside WSL via bash. Use '&&' to chain dependent commands (e.g., `git add . && git commit -m \"message\"`)."
: shName === "powershell"
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })

return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform)
.replaceAll("${shell}", name)
.replaceAll("${shell}", shName)
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
Expand All @@ -480,7 +494,7 @@ export const BashTool = Tool.define(
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name)
const ps = PS.has(shName)
const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
Expand All @@ -489,7 +503,7 @@ export const BashTool = Tool.define(
return yield* run(
{
shell,
name,
name: shName,
command: params.command,
cwd,
env: yield* shellEnv(ctx, cwd),
Expand Down
76 changes: 76 additions & 0 deletions packages/opencode/src/util/platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { execSync } from "child_process"
import fs from "fs"
import { lazy } from "./lazy"

export namespace Platform {
export type Environment = "win32" | "wsl1" | "wsl2" | "linux" | "darwin"

const detect = lazy((): Environment => {
if (process.platform !== "linux") return process.platform === "win32" ? "win32" : "darwin"

try {
const version = fs.readFileSync("/proc/version", "utf8").toLowerCase()
if (version.includes("microsoft") || version.includes("wsl")) {
if (version.includes("microsoft-standard-wsl2")) return "wsl2"
if (fs.existsSync("/sys/module/vsock")) return "wsl2"
return "wsl1"
}
} catch {}

return "linux"
})

export const env = (): Environment => detect()

export const isWsl = (): boolean => {
const e = env()
return e === "wsl1" || e === "wsl2"
}

export const isWindows = (): boolean => process.platform === "win32" || isWsl()

export const wslVersion = (): 1 | 2 | undefined => {
const e = env()
if (e === "wsl1") return 1
if (e === "wsl2") return 2
return undefined
}

export const wslDistro = (): string | undefined => process.env.WSL_DISTRO_NAME

export function isWslUncPath(p: string): boolean {
if (process.platform !== "win32") return false
const lower = p.toLowerCase().replace(/\\/g, "/")
return lower.startsWith("//wsl$/") || lower.startsWith("//wsl.localhost/")
}

export function parseWslUncPath(p: string): { distro: string; linuxPath: string } | undefined {
if (!isWslUncPath(p)) return undefined
const normalized = p.replace(/\\/g, "/")
const match = normalized.match(/^\/\/wsl(?:\$|\.localhost)\/([^/]+)(\/.*)?$/i)
if (!match) return undefined
return { distro: match[1], linuxPath: match[2] || "/" }
}

export function toWslUncPath(linuxPath: string, distro: string): string {
const posix = linuxPath.replace(/\\/g, "/")
return `\\\\wsl$\\${distro}\\${posix.replace(/^\//, "")}`
}

export function wslDistros(): string[] {
if (process.platform !== "win32") return []
try {
const result = execSync("wsl.exe --list --quiet", {
encoding: "utf8",
windowsHide: true,
timeout: 5000,
})
return result
.split("\n")
.map((line) => line.trim().replace(/\0/g, ""))
.filter(Boolean)
} catch {
return []
}
}
}
Loading