From 56cd985a520b400778792b32ea628ca4536a1153 Mon Sep 17 00:00:00 2001 From: Johann Berger Date: Wed, 21 Jan 2026 00:35:19 +0100 Subject: [PATCH 1/3] feat(opencode): add configurable shell resolution with plugin support - Add 'shell' config option to opencode.json for explicit shell override - Add 'shell.resolve' plugin hook for dynamic shell resolution - Convert Shell.preferred() and Shell.acceptable() to async - Update call sites in bash tool, pty, and prompt to await shell - Add comprehensive tests for config, plugin, and fallback scenarios - Regenerate SDK types to include new shell config property --- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/pty/index.ts | 2 +- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/shell/shell.ts | 29 +++++--- packages/opencode/src/tool/bash.ts | 2 +- packages/opencode/test/shell/shell.test.ts | 80 ++++++++++++++++++++++ packages/plugin/src/index.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++ packages/sdk/openapi.json | 4 ++ 9 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/test/shell/shell.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b2142e29b94..b8bfa04a3ea 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -880,6 +880,7 @@ 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("Shell to use for command execution"), 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..f6a4b20cb72 --- /dev/null +++ b/packages/opencode/test/shell/shell.test.ts @@ -0,0 +1,80 @@ +import { expect, test, mock } from "bun:test" +import { Shell } from "../../src/shell/shell" +import { Config } from "../../src/config/config" +import { Plugin } from "../../src/plugin" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import path from "path" + +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" + } + }) + + 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.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 + } +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 36a4657d74c..4f2c79c8b8d 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -173,6 +173,7 @@ export interface Hooks { output: { temperature: number; topP: number; topK: number; options: Record }, ) => Promise "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise + "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..a2796e754a3 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 + /** + * Shell to use for command execution + */ + shell?: string keybinds?: KeybindsConfig logLevel?: LogLevel /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 14008f32307..d933a6335e3 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": "Shell to use for command execution", + "type": "string" + }, "keybinds": { "$ref": "#/components/schemas/KeybindsConfig" }, From d56d4734cd1a504fb8ae7fdf4cfd457acb25bda9 Mon Sep 17 00:00:00 2001 From: Johann Berger Date: Wed, 21 Jan 2026 00:58:43 +0100 Subject: [PATCH 2/3] fix: address review comments on shell tests - Remove unused imports (Config, path) - Add return statement to mock function - Add test for Shell.acceptable() with plugin - Add test for Shell.acceptable() fallback on blacklisted shell --- packages/opencode/test/shell/shell.test.ts | 47 +++++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/shell/shell.test.ts b/packages/opencode/test/shell/shell.test.ts index f6a4b20cb72..01914e28344 100644 --- a/packages/opencode/test/shell/shell.test.ts +++ b/packages/opencode/test/shell/shell.test.ts @@ -1,10 +1,8 @@ import { expect, test, mock } from "bun:test" import { Shell } from "../../src/shell/shell" -import { Config } from "../../src/config/config" import { Plugin } from "../../src/plugin" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -import path from "path" test("Shell.preferred resolves from config", async () => { await using tmp = await tmpdir({ @@ -44,6 +42,7 @@ test("Shell.preferred resolves from plugin", async () => { if (name === "shell.resolve") { output.shell = "/plugin/resolved/shell" } + return output }) try { @@ -61,6 +60,29 @@ test("Shell.preferred resolves from plugin", async () => { } }) +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" @@ -78,3 +100,24 @@ test("Shell.preferred falls back to environment/system", async () => { 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 + } +}) From bfede1d3371e994632a50834eab117d4b3693f6f Mon Sep 17 00:00:00 2001 From: Johann Berger Date: Wed, 21 Jan 2026 01:19:41 +0100 Subject: [PATCH 3/3] fix: address additional review comments - Improve shell config description with priority info and examples - Add JSDoc documentation for shell.resolve plugin hook - Add test: config takes priority over plugin - Add test: config can override blacklist for Shell.acceptable --- packages/opencode/src/config/config.ts | 7 +++- packages/opencode/test/shell/shell.test.ts | 46 ++++++++++++++++++++++ packages/plugin/src/index.ts | 13 ++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 2 +- 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b8bfa04a3ea..c60b74bab3a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -880,7 +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("Shell to use for command execution"), + 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/test/shell/shell.test.ts b/packages/opencode/test/shell/shell.test.ts index 01914e28344..ec28f61e539 100644 --- a/packages/opencode/test/shell/shell.test.ts +++ b/packages/opencode/test/shell/shell.test.ts @@ -121,3 +121,49 @@ test("Shell.acceptable falls back when SHELL is blacklisted", async () => { 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 4f2c79c8b8d..ef444d87b78 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -173,6 +173,19 @@ 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 }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a2796e754a3..1ae8671552d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1587,7 +1587,7 @@ export type Config = { */ theme?: string /** - * Shell to use for command execution + * 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 diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index d933a6335e3..01873b82c38 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9314,7 +9314,7 @@ "type": "string" }, "shell": { - "description": "Shell to use for command execution", + "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": {