diff --git a/src/tools/interactive-bash/constants.ts b/src/tools/interactive-bash/constants.ts index 67570e4c8..d9fd10853 100644 --- a/src/tools/interactive-bash/constants.ts +++ b/src/tools/interactive-bash/constants.ts @@ -11,6 +11,10 @@ export const BLOCKED_TMUX_SUBCOMMANDS = [ "pipep", ] +export const PROHIBITED_TMUX_SUBCOMMANDS = [ + "kill-server", +] + export const INTERACTIVE_BASH_DESCRIPTION = `WARNING: This is TMUX ONLY. Pass tmux subcommands directly (without 'tmux' prefix). Examples: new-session -d -s omo-dev, send-keys -t omo-dev "vim" Enter diff --git a/src/tools/interactive-bash/tools.test.ts b/src/tools/interactive-bash/tools.test.ts new file mode 100644 index 000000000..9c0d20249 --- /dev/null +++ b/src/tools/interactive-bash/tools.test.ts @@ -0,0 +1,42 @@ +/// + +import { describe, expect, test } from "bun:test" +import type { ToolContext } from "@opencode-ai/plugin/tool" +import { interactive_bash } from "./tools" + +const mockContext = { + sessionID: "test-session", + messageID: "test-message", + agent: "test-agent", + directory: "/project", + worktree: "/project", + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, +} satisfies ToolContext + +describe("interactive_bash", () => { + test("#given kill-server command #when executed #then returns a strong prohibition without running tmux", async () => { + // given + const args = { tmux_command: "kill-server" } + + // when + const output = await interactive_bash.execute(args, mockContext) + + // then + expect(output).toContain("Error: 'kill-server' is prohibited in interactive_bash.") + expect(output).toContain("NEVER EVER run tmux kill-server from interactive_bash.") + expect(output).toContain("Do not retry kill-server with Bash or any other tool.") + }) + + test("#given kill-server after tmux global options #when executed #then still prohibits it", async () => { + // given + const args = { tmux_command: "-L omo-socket kill-server" } + + // when + const output = await interactive_bash.execute(args, mockContext) + + // then + expect(output).toContain("Error: 'kill-server' is prohibited in interactive_bash.") + }) +}) diff --git a/src/tools/interactive-bash/tools.ts b/src/tools/interactive-bash/tools.ts index 21a03cf7f..ba6eee2be 100644 --- a/src/tools/interactive-bash/tools.ts +++ b/src/tools/interactive-bash/tools.ts @@ -1,9 +1,16 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" import { isCmuxCompatEnvironment } from "../../shared/tmux/cmux-detect" -import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants" +import { + BLOCKED_TMUX_SUBCOMMANDS, + DEFAULT_TIMEOUT_MS, + INTERACTIVE_BASH_DESCRIPTION, + PROHIBITED_TMUX_SUBCOMMANDS, +} from "./constants" import { getCachedTmuxPath } from "./tmux-path-resolver" +const GLOBAL_TMUX_OPTIONS_WITH_ARGS = new Set(["-L", "-S", "-f", "-c", "-T"]) + function resolveTmuxExecutable(tmuxPath: string): string[] { if (!isCmuxCompatEnvironment()) { return [tmuxPath] @@ -59,6 +66,84 @@ export function tokenizeCommand(cmd: string): string[] { return tokens } +function findSubcommandIndex(parts: string[]): number { + let index = 0 + while (index < parts.length) { + const part = parts[index] ?? "" + + if (part === "--") { + return index + 1 < parts.length ? index + 1 : -1 + } + + if (GLOBAL_TMUX_OPTIONS_WITH_ARGS.has(part)) { + index += 2 + continue + } + + if (part.startsWith("-")) { + index++ + continue + } + + return index + } + + return -1 +} + +function getTargetSessionName(parts: string[]): string { + const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t")) + if (sessionIdx === -1) { + return "omo-session" + } + + const sessionToken = parts[sessionIdx] ?? "" + const nextToken = parts[sessionIdx + 1] + if (sessionToken === "-t" && nextToken) { + return nextToken + } + + if (sessionToken.startsWith("-t")) { + return sessionToken.slice(2) + } + + return "omo-session" +} + +function buildBlockedTmuxCommandMessage(command: string, parts: string[]): string { + const sessionName = getTargetSessionName(parts) + + return `Error: '${command}' is blocked in interactive_bash. + +**USE BASH TOOL INSTEAD:** + +\`\`\`bash +# Capture terminal output +tmux capture-pane -p -t ${sessionName} + +# Or capture with history (last 1000 lines) +tmux capture-pane -p -t ${sessionName} -S -1000 +\`\`\` + +The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.` +} + +function buildProhibitedTmuxCommandMessage(command: string): string { + return `Error: '${command}' is prohibited in interactive_bash. + +NEVER EVER run tmux kill-server from interactive_bash. + +It terminates the entire tmux server, destroying every tmux session and pane that the user, Codex, or other agents may be using. + +Use scoped cleanup only: + +\`\`\`bash +tmux kill-session -t +\`\`\` + +If you created an omo-* session, kill only that exact session. Do not retry kill-server with Bash or any other tool.` +} + export const interactive_bash: ToolDefinition = tool({ description: INTERACTIVE_BASH_DESCRIPTION, args: { @@ -74,31 +159,16 @@ export const interactive_bash: ToolDefinition = tool({ return "Error: Empty tmux command" } - const subcommand = parts[0].toLowerCase() + const subcommandIndex = findSubcommandIndex(parts) + const rawSubcommand = subcommandIndex === -1 ? "" : parts[subcommandIndex] + const subcommand = rawSubcommand.toLowerCase() + + if (PROHIBITED_TMUX_SUBCOMMANDS.includes(subcommand)) { + return buildProhibitedTmuxCommandMessage(rawSubcommand) + } + if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) { - const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t")) - let sessionName = "omo-session" - if (sessionIdx !== -1) { - if (parts[sessionIdx] === "-t" && parts[sessionIdx + 1]) { - sessionName = parts[sessionIdx + 1] - } else if (parts[sessionIdx].startsWith("-t")) { - sessionName = parts[sessionIdx].slice(2) - } - } - - return `Error: '${parts[0]}' is blocked in interactive_bash. - -**USE BASH TOOL INSTEAD:** - -\`\`\`bash -# Capture terminal output -tmux capture-pane -p -t ${sessionName} - -# Or capture with history (last 1000 lines) -tmux capture-pane -p -t ${sessionName} -S -1000 -\`\`\` - -The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.` + return buildBlockedTmuxCommandMessage(rawSubcommand, parts) } const proc = spawnWithWindowsHide([...resolveTmuxExecutable(tmuxPath), ...parts], {