diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 848d06c888..bfc84df1ba 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -22,6 +22,7 @@ import { Auth } from "@/auth" import { Installation } from "@/installation" import { InstallationVersion } from "@/installation/version" import { EffectBridge } from "@/effect" +import { ShellToolID } from "@/tool/shell/id" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" @@ -453,7 +454,12 @@ function resolveTools(input: Pick input.user.tools?.[k] !== false && !disabled.has(k)) + return Record.filter(input.tools, (_, k) => { + const userTool = input.user.tools?.[k] + if (userTool !== undefined) return userTool !== false && !disabled.has(k) + if (k === ShellToolID.id && input.user.tools?.[ShellToolID.legacy] === false) return false + return !disabled.has(k) + }) } // Check if messages contain any tool-call content diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 97e447df6a..8dc7d7ccb8 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -13,14 +13,14 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" -import { ShellToolID } from "./shell/id" +import { ShellKind, ShellToolID } from "./shell/id" -import { BashArity } from "@/permission/arity" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { ShellArity } from "./shell/arity" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -551,6 +551,7 @@ export const ShellTool = Tool.define( patterns: new Set(), always: new Set(), } + const shellKind = ShellKind.from(Shell.name(shell)) for (const node of commands(root)) { const command = parts(node) @@ -569,7 +570,7 @@ export const ShellTool = Tool.define( if (tokens.length && (!cmd || !CWD.has(cmd))) { scan.patterns.add(source(node)) - scan.always.add(BashArity.prefix(tokens).join(" ") + " *") + scan.always.add(ShellArity.prefix(tokens, shellKind).join(" ") + " *") } } diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 2b1df92131..663bfe3218 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -561,6 +561,100 @@ describe("session.llm.stream", () => { }) }) + test("disables shell when user message uses legacy bash override", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const providerID = "alibaba" + const modelID = "qwen-plus" + const fixture = await loadFixture(providerID, modelID) + const model = fixture.model + + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-legacy-bash-tools") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [], + } satisfies Agent.Info + + const user = { + id: MessageID.make("user-legacy-bash-tools"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, + tools: { bash: false }, + } satisfies MessageV2.User + + await drain({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: { + shell: tool({ + description: "Run a shell command", + inputSchema: z.object({ command: z.string() }), + execute: async () => ({ output: "" }), + }), + read: tool({ + description: "Read a file", + inputSchema: z.object({ filePath: z.string() }), + execute: async () => ({ output: "" }), + }), + }, + }) + + const capture = await request + const names = + (capture.body.tools as Array<{ function?: { name?: string } }> | undefined)?.flatMap((item) => + item.function?.name ? [item.function.name] : [], + ) ?? [] + + expect(names).not.toContain("shell") + expect(names).toContain("read") + }, + }) + }) + test("sends responses API payload for OpenAI models", async () => { const server = state.server if (!server) { diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index b06bee2ddc..6f1366ff53 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -237,6 +237,38 @@ describe("tool.shell permissions", () => { ) } + for (const item of ps) { + test( + `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "Remove-Item -Recurse tmp", + description: "Remove a temp directory", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const bashReq = requests.find((r) => r.permission === expectedPermission) + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("Remove-Item *") + expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") + }, + }) + }), + ) + } + each("asks for external_directory permission for wildcard external paths", async () => { await Instance.provide({ directory: projectRoot,