diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b2142e29b94..c60b74bab3a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -880,6 +880,12 @@ export namespace Config { .object({ $schema: z.string().optional().describe("JSON schema reference for configuration validation"), theme: z.string().optional().describe("Theme name to use for the interface"), + shell: z + .string() + .optional() + .describe( + 'Absolute path to the shell binary to use for command execution. This value takes priority over the $SHELL environment variable and any shell resolved via plugin hooks. Example: "/bin/bash" or "/usr/bin/zsh"', + ), keybinds: Keybinds.optional().describe("Custom keybind configurations"), logLevel: Log.Level.optional().describe("Log level"), tui: TUI.optional().describe("TUI specific settings"), diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 6edff32e132..da2a6754ebc 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -95,7 +95,7 @@ export namespace Pty { export async function create(input: CreateInput) { const id = Identifier.create("pty", false) - const command = input.command || Shell.preferred() + const command = input.command || (await Shell.preferred()) const args = input.args || [] if (command.endsWith("sh")) { args.push("-l") diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 57ef0ef5ed4..871b6729282 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1430,7 +1430,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, } await Session.updatePart(part) - const shell = Shell.preferred() + const shell = await Shell.preferred() const shellName = ( process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell) ).toLowerCase() diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index 2e8d48bfd92..2479f349175 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -1,7 +1,8 @@ import { Flag } from "@/flag/flag" -import { lazy } from "@/util/lazy" import path from "path" import { spawn, type ChildProcess } from "child_process" +import { Config } from "@/config/config" +import { Plugin } from "@/plugin" const SIGKILL_TIMEOUT_MS = 200 @@ -53,15 +54,27 @@ export namespace Shell { return "/bin/sh" } - export const preferred = lazy(() => { - const s = process.env.SHELL - if (s) return s - return fallback() - }) + async function fromConfigOrPlugin() { + const config = await Config.get().catch(() => undefined) + if (config?.shell) return config.shell + + const result = { shell: "" } + await Plugin.trigger("shell.resolve", { platform: process.platform }, result) + return result.shell || undefined + } + + export async function preferred() { + const override = await fromConfigOrPlugin() + if (override) return override + return process.env.SHELL || fallback() + } + + export async function acceptable() { + const override = await fromConfigOrPlugin() + if (override) return override - export const acceptable = lazy(() => { const s = process.env.SHELL if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s) : path.basename(s))) return s return fallback() - }) + } } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f3a1b04d431..32af349fa16 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -52,7 +52,7 @@ const parser = lazy(async () => { // TODO: we may wanna rename this tool so it works better on other shells export const BashTool = Tool.define("bash", async () => { - const shell = Shell.acceptable() + const shell = await Shell.acceptable() log.info("bash tool using shell", { shell }) return { diff --git a/packages/opencode/test/shell/shell.test.ts b/packages/opencode/test/shell/shell.test.ts new file mode 100644 index 00000000000..ec28f61e539 --- /dev/null +++ b/packages/opencode/test/shell/shell.test.ts @@ -0,0 +1,169 @@ +import { expect, test, mock } from "bun:test" +import { Shell } from "../../src/shell/shell" +import { Plugin } from "../../src/plugin" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +test("Shell.preferred resolves from config", async () => { + await using tmp = await tmpdir({ + config: { + shell: "/custom/shell", + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const shell = await Shell.preferred() + expect(shell).toBe("/custom/shell") + }, + }) +}) + +test("Shell.acceptable resolves from config", async () => { + await using tmp = await tmpdir({ + config: { + shell: "/custom/acceptable/shell", + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const shell = await Shell.acceptable() + expect(shell).toBe("/custom/acceptable/shell") + }, + }) +}) + +test("Shell.preferred resolves from plugin", async () => { + const originalTrigger = Plugin.trigger + Plugin.trigger = mock(async (name, input, output) => { + if (name === "shell.resolve") { + output.shell = "/plugin/resolved/shell" + } + return output + }) + + try { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const shell = await Shell.preferred() + expect(shell).toBe("/plugin/resolved/shell") + expect(Plugin.trigger).toHaveBeenCalledWith("shell.resolve", expect.anything(), expect.anything()) + }, + }) + } finally { + Plugin.trigger = originalTrigger + } +}) + +test("Shell.acceptable resolves from plugin", async () => { + const originalTrigger = Plugin.trigger + Plugin.trigger = mock(async (name, input, output) => { + if (name === "shell.resolve") { + output.shell = "/plugin/resolved/shell" + } + return output + }) + + try { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const shell = await Shell.acceptable() + expect(shell).toBe("/plugin/resolved/shell") + }, + }) + } finally { + Plugin.trigger = originalTrigger + } +}) + +test("Shell.preferred falls back to environment/system", async () => { + const originalEnvShell = process.env.SHELL + process.env.SHELL = "/env/shell" + + try { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const shell = await Shell.preferred() + expect(shell).toBe("/env/shell") + }, + }) + } finally { + process.env.SHELL = originalEnvShell + } +}) + +test("Shell.acceptable falls back when SHELL is blacklisted", async () => { + const originalEnvShell = process.env.SHELL + process.env.SHELL = "/usr/bin/fish" + + try { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const shell = await Shell.acceptable() + // Should NOT return fish since it's blacklisted + expect(shell).not.toBe("/usr/bin/fish") + // Should return a fallback shell + expect(shell).toBeTruthy() + }, + }) + } finally { + process.env.SHELL = originalEnvShell + } +}) + +test("Config takes priority over plugin", async () => { + const originalTrigger = Plugin.trigger + Plugin.trigger = mock(async (name, input, output) => { + if (name === "shell.resolve") { + output.shell = "/plugin/shell" + } + return output + }) + + try { + await using tmp = await tmpdir({ + config: { + shell: "/config/shell", + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const shell = await Shell.preferred() + // Config should win over plugin + expect(shell).toBe("/config/shell") + }, + }) + } finally { + Plugin.trigger = originalTrigger + } +}) + +test("Config can override blacklist for Shell.acceptable", async () => { + // fish is normally blacklisted, but config should allow explicit override + await using tmp = await tmpdir({ + config: { + shell: "/usr/bin/fish", + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const shell = await Shell.acceptable() + // Config explicitly sets fish, so it should be allowed despite blacklist + expect(shell).toBe("/usr/bin/fish") + }, + }) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 36a4657d74c..ef444d87b78 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -173,6 +173,20 @@ export interface Hooks { output: { temperature: number; topP: number; topK: number; options: Record }, ) => Promise "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise + /** + * Called when resolving which shell to use for command execution. + * + * This hook participates in the shell resolution priority chain and allows + * plugins to override the default shell selection based on the current + * platform or runtime environment. + * + * Example use cases: + * - Selecting a different shell when running inside a container or VM + * - Routing commands to a remote shell for specific platforms + * - Enforcing a particular shell (e.g., `bash`, `zsh`, `powershell`) for + * consistency across environments + */ + "shell.resolve"?: (input: { platform: string }, output: { shell: string }) => Promise "command.execute.before"?: ( input: { command: string; sessionID: string; arguments: string }, output: { parts: Part[] }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8442889020f..1ae8671552d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1586,6 +1586,10 @@ export type Config = { * Theme name to use for the interface */ theme?: string + /** + * Absolute path to the shell binary to use for command execution. This value takes priority over the $SHELL environment variable and any shell resolved via plugin hooks. Example: "/bin/bash" or "/usr/bin/zsh" + */ + shell?: string keybinds?: KeybindsConfig logLevel?: LogLevel /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 14008f32307..01873b82c38 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9313,6 +9313,10 @@ "description": "Theme name to use for the interface", "type": "string" }, + "shell": { + "description": "Absolute path to the shell binary to use for command execution. This value takes priority over the $SHELL environment variable and any shell resolved via plugin hooks. Example: \"/bin/bash\" or \"/usr/bin/zsh\"", + "type": "string" + }, "keybinds": { "$ref": "#/components/schemas/KeybindsConfig" },