From e589969398bb2deae6a6415ed7c74c378ad99f0b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:24:08 -0500 Subject: [PATCH] fix(core): resolve compatible shells for commands (#44485) Co-authored-by: rekram1-node Co-authored-by: Aiden Cline --- packages/core/src/config/plugin/command.ts | 2 +- packages/core/src/pty.ts | 2 +- packages/core/src/shell.ts | 14 ++--- packages/core/src/shell/select.ts | 60 ++++++++++------------ packages/core/src/tool/plugin/shell.ts | 6 ++- packages/core/test/config/command.test.ts | 2 +- packages/core/test/config/shell.test.ts | 4 +- packages/core/test/shell.test.ts | 38 +++++++------- packages/core/test/tool-shell.test.ts | 2 + 9 files changed, 64 insertions(+), 66 deletions(-) diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index ea4d872a900..4c3aba4f874 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -176,7 +176,7 @@ function evaluateTemplate( : withArguments.trim() const matches = Array.from(text.matchAll(shellRegex)) if (matches.length === 0) return text - const shell = yield* services.shell.preferred() + const shell = yield* services.shell.resolve({ priority: "config" }) const outputs = yield* Effect.forEach( matches, (match) => { diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index dc70c317f5c..b4df087b59a 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -164,7 +164,7 @@ const layer = () => const create = Effect.fn("Pty.create")(function* (input: CreateInput) { const id = PtyID.ascending() - const command = input.command || (yield* shell.preferred()) + const command = input.command || (yield* shell.resolve({ priority: "config" })) const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])] const cwd = input.cwd || location.directory const env = { diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 40d9b51c4d1..aad62a84823 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -30,6 +30,9 @@ export const RETENTION = Duration.days(7) export const DIRECTORY = "shell" type Info = Shell.Info +type CreateInput = Shell.CreateInput & { + shell?: string +} type Active = { // Immutable snapshot; lifecycle updates replace it via immer `produce`. @@ -52,9 +55,8 @@ type Active = { * here; callers (e.g. `ShellTool`) own that association and store the shell ID. */ export interface Interface { - readonly name: () => Effect.Effect readonly create: ( - input: Shell.CreateInput, + input: CreateInput, before?: (input: ShellCreateBefore) => Effect.Effect, ) => Effect.Effect // Currently running commands only; exited shells are retained for get/output but excluded here. @@ -185,8 +187,6 @@ const layer = () => return session.info }) - const name = () => shell.preferred().pipe(Effect.map(ShellSelect.name)) - const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) { const session = yield* require(id) const cursor = input?.cursor ?? 0 @@ -218,7 +218,7 @@ const layer = () => }) const create = Effect.fn("Shell.create")(function* ( - input: Shell.CreateInput, + input: CreateInput, before?: (input: ShellCreateBefore) => Effect.Effect, ) { const sessionID = input.metadata?.sessionID @@ -230,7 +230,7 @@ const layer = () => command: input.command, cwd: input.cwd ?? location.directory, timeout: input.timeout, - shell: yield* shell.preferred(), + shell: input.shell ?? (yield* shell.resolve({ priority: "config" })), env: { ...(sessionEnvironment ?? process.env), TERM: "xterm-256color", @@ -383,7 +383,7 @@ const layer = () => return session.info }) - return Service.of({ name, create, list, get, wait, timeout, output, remove }) + return Service.of({ create, list, get, wait, timeout, output, remove }) }), ) diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts index 8147156cffc..bbe538638b6 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -41,8 +41,12 @@ export type Draft = { configure: (shell: string) => void } +export type ResolveInput = { + priority: "config" | "compat" +} + export interface Interface extends State.Transformable { - readonly preferred: () => Effect.Effect + readonly resolve: (input: ResolveInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/ShellSelect") {} @@ -70,7 +74,7 @@ function meta(file: string) { return META[name(file)] } -function ok(file: string) { +function compatible(file: string) { return meta(file)?.deny !== true } @@ -78,7 +82,7 @@ function rooted(file: string) { return path.isAbsolute(FSUtil.windowsPath(file)) } -function resolve(file: string, options?: Options, bin?: string) { +function executable(file: string, options?: Options, bin?: string) { const shell = full(file, options, bin) if (rooted(shell)) { if (stat(shell)?.isFile()) return shell @@ -108,9 +112,9 @@ async function unix() { return ["/bin/bash", "/bin/zsh", "/bin/sh"] } -function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) { - if (file && (!opts?.acceptable || ok(file))) { - const shell = resolve(file, options, bin) +function select(file: string | undefined, options?: Options, opts?: { compatible?: boolean }, bin?: string) { + if (file && (!opts?.compatible || compatible(file))) { + const shell = executable(file, options, bin) if (shell) return shell } if (process.platform === "win32") return win(options, bin)[0] @@ -151,8 +155,8 @@ function info(file: string, options?: Options, bin?: string): Item { const n = name(item) return { path: item, - name: resolve(n, options, bin) ? n : item, - acceptable: ok(item), + name: executable(n, options, bin) ? n : item, + acceptable: compatible(item), } } @@ -163,38 +167,28 @@ export function args(file: string, command: string) { return ["-c", command] } -let defaultPreferred: { bin?: string; value: string } | undefined -let defaultAcceptable: { bin?: string; value: string } | undefined +let defaultConfigured: { bin?: string; value: string } | undefined +let defaultCompatible: { bin?: string; value: string } | undefined -export function preferred(configShell?: string, options?: Options, bin?: string) { - if (configShell) return select(configShell, options, undefined, bin) - if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin) - const cached = defaultPreferred +export function resolve(input: ResolveInput, configShell?: string, options?: Options, bin?: string) { + const filter = input.priority === "compat" ? { compatible: true } : undefined + if (configShell) return select(configShell, options, filter, bin) + if (options?.gitbash) return select(process.env.SHELL, options, filter, bin) + const cached = input.priority === "compat" ? defaultCompatible : defaultConfigured if (cached && cached.bin === bin) return cached.value - const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin) - defaultPreferred = { bin, value } + const value = select(process.env.SHELL, undefined, filter, bin) ?? fallback(bin) + if (input.priority === "compat") defaultCompatible = { bin, value } + if (input.priority === "config") defaultConfigured = { bin, value } return value } -preferred.reset = () => { - defaultPreferred = undefined -} - -export function acceptable(configShell?: string, options?: Options, bin?: string) { - if (configShell) return select(configShell, options, { acceptable: true }, bin) - if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin) - const cached = defaultAcceptable - if (cached && cached.bin === bin) return cached.value - const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin) - defaultAcceptable = { bin, value } - return value -} -acceptable.reset = () => { - defaultAcceptable = undefined +resolve.reset = () => { + defaultConfigured = undefined + defaultCompatible = undefined } export async function list(options?: Options, bin?: string): Promise { const shells = process.platform === "win32" ? win(options, bin) : await unix() - return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin)) + return shells.filter((shell) => executable(shell, options, bin)).map((shell) => info(shell, options, bin)) } const layer = (options?: Options) => @@ -214,7 +208,7 @@ const layer = (options?: Options) => return Service.of({ transform: state.transform, reload: state.reload, - preferred: () => Effect.sync(() => preferred(state.get().shell, options, global.bin)), + resolve: (input) => Effect.sync(() => resolve(input, state.get().shell, options, global.bin)), }) }), ) diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 1d91c8e58b0..f9dd1da03c2 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -13,6 +13,7 @@ import { NonNegativeInt } from "../../schema.js" import { SessionSchema } from "../../session/schema.js" import { Shell } from "../../shell.js" import { ShellParse } from "../../shell/parse.js" +import { ShellSelect } from "../../shell/select.js" import { ToolOutput } from "../../tool-output.js" export const name = "shell" @@ -109,6 +110,8 @@ export const Plugin = { const environment = yield* Environment.Service const mutation = yield* LocationMutation.Service const shell = yield* Shell.Service + const shellSelect = yield* ShellSelect.Service + const compatibleShell = shellSelect.resolve({ priority: "compat" }) const permission = yield* Permission.Service const config = yield* Config.Service @@ -185,6 +188,7 @@ export const Plugin = { command: input.command, cwd: input.workdir, timeout, + shell: yield* compatibleShell, metadata: { sessionID: context.sessionID }, }, (invocation) => @@ -340,7 +344,7 @@ export const Plugin = { Effect.gen(function* () { const tool = event.tools[name] if (!tool) return - tool.description = description(yield* shell.name()) + tool.description = description(ShellSelect.name(yield* compatibleShell)) }), ) }), diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index 91dfe2e9cb1..84fc167a9b6 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -33,7 +33,7 @@ import { host } from "../plugin/host" const shellLayer = Layer.succeed( ShellSelect.Service, ShellSelect.Service.of({ - preferred: () => Effect.succeed("sh"), + resolve: () => Effect.succeed("sh"), transform: () => Effect.die("unused shell.transform"), reload: () => Effect.die("unused shell.reload"), }), diff --git a/packages/core/test/config/shell.test.ts b/packages/core/test/config/shell.test.ts index 3eadf96b999..7ecb664cd85 100644 --- a/packages/core/test/config/shell.test.ts +++ b/packages/core/test/config/shell.test.ts @@ -24,12 +24,12 @@ describe("ConfigShellPlugin.Plugin", () => { yield* ConfigShellPlugin.Plugin.effect(yield* PluginHost.make(plugins)) const configured = process.platform === "win32" ? FSUtil.windowsPath(process.execPath) : process.execPath - expect(yield* shell.preferred()).toBe(configured) + expect(yield* shell.resolve({ priority: "config" })).toBe(configured) yield* config.setEntries([]) yield* bus.publish(Event.Updated, {}) for (let attempt = 0; attempt < 200; attempt++) { - if ((yield* shell.preferred()) !== configured) return + if ((yield* shell.resolve({ priority: "config" })) !== configured) return yield* Effect.sleep("10 millis") } yield* Effect.die(new Error("Timed out waiting for shell config reload")) diff --git a/packages/core/test/shell.test.ts b/packages/core/test/shell.test.ts index c2216ddde26..a9b3f17fff3 100644 --- a/packages/core/test/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -8,15 +8,13 @@ const withShell = async (shell: string | undefined, fn: () => void | Promise { test("falls back when configured shell cannot be resolved", async () => { await withShell(undefined, async () => { - const preferred = ShellSelect.preferred() - const acceptable = ShellSelect.acceptable() - expect(ShellSelect.preferred("opencode-missing-shell")).toBe(preferred) - expect(ShellSelect.acceptable("opencode-missing-shell")).toBe(acceptable) + const configured = ShellSelect.resolve({ priority: "config" }) + const compatible = ShellSelect.resolve({ priority: "compat" }) + expect(ShellSelect.resolve({ priority: "config" }, "opencode-missing-shell")).toBe(configured) + expect(ShellSelect.resolve({ priority: "compat" }, "opencode-missing-shell")).toBe(compatible) }) }) - test("falls back for terminal-only acceptable shells", () => { - expect(ShellSelect.name(ShellSelect.acceptable("fish"))).not.toBe("fish") - expect(ShellSelect.name(ShellSelect.acceptable("nu"))).not.toBe("nu") + test("falls back for terminal-only shells when compatibility is required", () => { + expect(ShellSelect.name(ShellSelect.resolve({ priority: "compat" }, "fish"))).not.toBe("fish") + expect(ShellSelect.name(ShellSelect.resolve({ priority: "compat" }, "nu"))).not.toBe("nu") }) test("builds command args per shell family", () => { @@ -65,14 +63,14 @@ describe("shell", () => { if (process.platform === "win32") { test("rejects blacklisted shells case-insensitively", async () => { await withShell("NU.EXE", async () => { - expect(ShellSelect.name(ShellSelect.acceptable())).not.toBe("nu") + expect(ShellSelect.name(ShellSelect.resolve({ priority: "compat" }))).not.toBe("nu") }) }) test("normalizes Git Bash shell paths from env", async () => { const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe" await withShell(shell, async () => { - expect(ShellSelect.preferred()).toBe(FSUtil.windowsPath(shell)) + expect(ShellSelect.resolve({ priority: "config" })).toBe(FSUtil.windowsPath(shell)) }) }) @@ -80,19 +78,19 @@ describe("shell", () => { const bash = ShellSelect.gitbash() if (!bash) return await withShell("/usr/bin/bash", async () => { - expect(ShellSelect.acceptable()).toBe(bash) - expect(ShellSelect.preferred()).toBe(bash) + expect(ShellSelect.resolve({ priority: "compat" })).toBe(bash) + expect(ShellSelect.resolve({ priority: "config" })).toBe(bash) }) }) test("resolves bare bash to Git Bash before PATH", async () => { const bash = ShellSelect.gitbash() if (!bash) return - expect(ShellSelect.acceptable("bash")).toBe(bash) - expect(ShellSelect.preferred("bash")).toBe(bash) + expect(ShellSelect.resolve({ priority: "compat" }, "bash")).toBe(bash) + expect(ShellSelect.resolve({ priority: "config" }, "bash")).toBe(bash) await withShell("bash", async () => { - expect(ShellSelect.acceptable()).toBe(bash) - expect(ShellSelect.preferred()).toBe(bash) + expect(ShellSelect.resolve({ priority: "compat" })).toBe(bash) + expect(ShellSelect.resolve({ priority: "config" })).toBe(bash) }) }) @@ -100,7 +98,7 @@ describe("shell", () => { const shell = which("pwsh") || which("powershell") if (!shell) return await withShell(path.win32.basename(shell), async () => { - expect(ShellSelect.preferred()).toBe(shell) + expect(ShellSelect.resolve({ priority: "config" })).toBe(shell) }) }) } diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 3abf49b44eb..c66b1ea2518 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -32,6 +32,7 @@ import { Permission } from "@opencode-ai/core/permission" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { Shell } from "@opencode-ai/core/shell" +import { ShellSelect } from "@opencode-ai/core/shell/select" import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { ShellTool } from "@opencode-ai/core/tool/plugin/shell" import { ToolOutput } from "@opencode-ai/core/tool-output" @@ -136,6 +137,7 @@ const shellPluginSupervisor = makeLocationNode({ Permission.node, PluginRuntime.node, Shell.node, + ShellSelect.node, Tool.node, ], })