From c24e5ba887e36494de799cff8011013dfaf6aa79 Mon Sep 17 00:00:00 2001 From: Mathew Jacob Date: Mon, 10 Aug 2026 17:09:34 -0500 Subject: [PATCH] fix(tui): isolate tool stdin --- packages/opencode/src/cli/cmd/run.ts | 6 +- .../opencode/src/cli/cmd/run/runtime.stdin.ts | 54 +++++++-- packages/opencode/src/cli/cmd/tui.ts | 23 +++- .../test/cli/run/runtime.stdin.test.ts | 65 ++++++++--- packages/tui/src/app.tsx | 24 ++-- packages/tui/src/component/prompt/index.tsx | 4 +- packages/tui/src/context/runtime.tsx | 17 +++ packages/tui/src/routes/session/index.tsx | 5 +- packages/tui/src/terminal-win32.ts | 104 ++++++++++++------ 9 files changed, 229 insertions(+), 73 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a080..879f5493494c 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -24,7 +24,7 @@ import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { FormatError, FormatUnknownError } from "../error" -import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" +import { INTERACTIVE_INPUT_REASON, resolveInteractiveStdin } from "./run/runtime.stdin" type ModelInput = Parameters[0]["model"] @@ -278,8 +278,8 @@ export const RunCommand = effectCmd({ process.exit(1) } const dieInteractive = (error: unknown): never => { - if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) { - die(error.message) + if (error instanceof Error && error.message === INTERACTIVE_INPUT_REASON) { + die(`--mini ${error.message}`) } throw error diff --git a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts index d236fb02c2ef..ed29f9a061c4 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts @@ -1,7 +1,8 @@ +import { dlopen } from "bun:ffi" import fs from "fs" -import * as tty from "node:tty" +import { ReadStream } from "node:tty" -export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input" +export const INTERACTIVE_INPUT_REASON = "requires a controlling terminal for input" type InteractiveStdin = { stdin: NodeJS.ReadStream @@ -9,22 +10,55 @@ type InteractiveStdin = { } function openTerminalStdin(path: string): NodeJS.ReadStream { - return new tty.ReadStream(fs.openSync(path, "r")) + return new ReadStream(fs.openSync(path, "r")) +} + +const duplicates = new Map number>() + +function duplicate(fd: number, platform: NodeJS.Platform) { + const cached = duplicates.get(platform) + if (cached) return cached(fd) + if (platform === "win32") { + const library = dlopen("ucrtbase.dll", { _dup2: { args: ["i32", "i32"], returns: "i32" } }) + const value = (source: number) => library.symbols._dup2(source, 0) + duplicates.set(platform, value) + return value(fd) + } + const library = dlopen(platform === "darwin" ? "libSystem.B.dylib" : "libc.so.6", { + dup2: { args: ["i32", "i32"], returns: "i32" }, + }) + const value = (source: number) => library.symbols.dup2(source, 0) + duplicates.set(platform, value) + return value(fd) +} + +function redirectStdin(_stdin: NodeJS.ReadStream, path: string, platform: NodeJS.Platform) { + const fd = fs.openSync(path, "r") + try { + const result = duplicate(fd, platform) + if (result !== 0) throw new Error(`Failed to redirect stdin: ${result}`) + } finally { + fs.closeSync(fd) + } } export function resolveInteractiveStdin( stdin: NodeJS.ReadStream = process.stdin, open: (path: string) => NodeJS.ReadStream = openTerminalStdin, platform = process.platform, + redirect: (stdin: NodeJS.ReadStream, path: string, platform: NodeJS.Platform) => void = redirectStdin, ): InteractiveStdin { - if (stdin.isTTY) { - return { stdin } - } - - const file = platform === "win32" ? "CONIN$" : "/dev/tty" + const terminal = platform === "win32" ? "CONIN$" : "/dev/tty" + const ignored = platform === "win32" ? "NUL" : "/dev/null" try { - const stream = open(file) + const stream = open(terminal) + try { + redirect(stdin, ignored, platform) + } catch (error) { + stream.destroy() + throw error + } return { stdin: stream, cleanup: () => { @@ -32,6 +66,6 @@ export function resolveInteractiveStdin( }, } } catch (error) { - throw new Error(INTERACTIVE_INPUT_ERROR, { cause: error }) + throw new Error(INTERACTIVE_INPUT_REASON, { cause: error }) } } diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 95ffac7ea51d..c49f6c3e9c7f 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -14,6 +14,7 @@ import { writeHeapSnapshot } from "v8" import { ServerAuth } from "@/server/auth" import { validateSession } from "../tui/validate-session" import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" +import { INTERACTIVE_INPUT_REASON, resolveInteractiveStdin } from "./run/runtime.stdin" declare global { const OPENCODE_WORKER_PATH: string @@ -186,9 +187,25 @@ export const TuiThreadCommand = cmd({ return } - const unguard = win32InstallCtrlCGuard() + let unguard: (() => void) | undefined + let interactiveStdin: ReturnType | undefined try { const { TuiConfig } = await import("@/config/tui") + const prompt = await input(args.prompt) + try { + interactiveStdin = resolveInteractiveStdin() + } catch (error) { + if (error instanceof Error && error.message === INTERACTIVE_INPUT_REASON) { + UI.error(`The TUI ${error.message}`) + process.exitCode = 1 + return + } + + throw error + } + + unguard = win32InstallCtrlCGuard(interactiveStdin.stdin) + if (args.fork && !args.continue && !args.session) { UI.error("--fork requires --continue or --session") process.exitCode = 1 @@ -227,7 +244,6 @@ export const TuiThreadCommand = cmd({ worker.terminate() } - const prompt = await input(args.prompt) const config = await TuiConfig.get() const network = resolveNetworkOptionsNoConfig(args) @@ -280,6 +296,7 @@ export const TuiThreadCommand = cmd({ }, config, pluginHost: createLegacyTuiPluginHost(), + stdin: interactiveStdin.stdin, directory: cwd, fetch: transport.fetch, headers: transport.headers, @@ -302,8 +319,8 @@ export const TuiThreadCommand = cmd({ try { unguard?.() } catch {} + interactiveStdin?.cleanup?.() } process.exit(0) }, }) -// scratch diff --git a/packages/opencode/test/cli/run/runtime.stdin.test.ts b/packages/opencode/test/cli/run/runtime.stdin.test.ts index 7c4a10e82db3..93ed6c1f0cd3 100644 --- a/packages/opencode/test/cli/run/runtime.stdin.test.ts +++ b/packages/opencode/test/cli/run/runtime.stdin.test.ts @@ -1,60 +1,93 @@ import { describe, expect, test } from "bun:test" import { Readable } from "node:stream" -import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin" +import { INTERACTIVE_INPUT_REASON, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin" function stream(isTTY: boolean) { return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream } +function redirect(seen: string[]) { + return (_stdin: NodeJS.ReadStream, path: string) => { + seen.push(path) + } +} + describe("run interactive stdin", () => { - test("reuses stdin when it is already a tty", () => { + test("opens the controlling terminal and redirects the original tty stdin", () => { const stdin = stream(true) - const seen: string[] = [] + const tty = stream(true) + const opened: string[] = [] + const redirected: string[] = [] const result = resolveInteractiveStdin( stdin, (path) => { - seen.push(path) - return stream(true) + opened.push(path) + return tty }, "linux", + redirect(redirected), ) - expect(result.stdin).toBe(stdin) - expect(result.cleanup).toBeUndefined() - expect(seen).toEqual([]) + expect(result.stdin).toBe(tty) + expect(opened).toEqual(["/dev/tty"]) + expect(redirected).toEqual(["/dev/null"]) + + result.cleanup?.() + expect(tty.destroyed).toBe(true) }) test("opens the controlling terminal when stdin is piped", () => { const tty = stream(true) - const seen: string[] = [] + const opened: string[] = [] + const redirected: string[] = [] const result = resolveInteractiveStdin( stream(false), (path) => { - seen.push(path) + opened.push(path) return tty }, "linux", + redirect(redirected), ) expect(result.stdin).toBe(tty) - expect(seen).toEqual(["/dev/tty"]) + expect(opened).toEqual(["/dev/tty"]) + expect(redirected).toEqual(["/dev/null"]) result.cleanup?.() expect(tty.destroyed).toBe(true) }) - test("uses CONIN$ on windows", () => { - const seen: string[] = [] + test("uses the windows console and null device", () => { + const opened: string[] = [] + const redirected: string[] = [] resolveInteractiveStdin( stream(false), (path) => { - seen.push(path) + opened.push(path) return stream(true) }, "win32", + redirect(redirected), ) - expect(seen).toEqual(["CONIN$"]) + expect(opened).toEqual(["CONIN$"]) + expect(redirected).toEqual(["NUL"]) + }) + + test("closes the controlling terminal when redirecting stdin fails", () => { + const tty = stream(true) + expect(() => + resolveInteractiveStdin( + stream(true), + () => tty, + "linux", + () => { + throw new Error("redirect failed") + }, + ), + ).toThrow(INTERACTIVE_INPUT_REASON) + expect(tty.destroyed).toBe(true) }) test("throws a clear error when no controlling terminal is available", () => { @@ -66,6 +99,6 @@ describe("run interactive stdin", () => { }, "linux", ), - ).toThrow(INTERACTIVE_INPUT_ERROR) + ).toThrow(INTERACTIVE_INPUT_REASON) }) }) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..fa68ae792210 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -24,7 +24,13 @@ import { Show, on, } from "solid-js" -import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" +import { + TuiPathsProvider, + TuiStartupProvider, + TuiStdinProvider, + TuiTerminalEnvironmentProvider, + useTuiStartup, +} from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" import { DialogProvider as DialogProviderList } from "./component/dialog-provider" import { ErrorComponent } from "./component/error-component" @@ -149,6 +155,7 @@ export type TuiInput = { headers?: RequestInit["headers"] events?: EventSource pluginHost: TuiPluginHost + stdin?: NodeJS.ReadStream } function errorMessage(error: unknown) { @@ -203,6 +210,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], }, + ...(input.stdin ? { stdin: input.stdin } : {}), }), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }), @@ -211,7 +219,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { destroyRenderer(renderer) }), ) - win32DisableProcessedInput() + win32DisableProcessedInput(input.stdin) const keymap = createDefaultOpenTuiKeymap(renderer) yield* Effect.acquireRelease( Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)), @@ -315,10 +323,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + + + @@ -355,7 +365,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }), ) yield* Effect.sync(() => { - win32FlushInputBuffer() + win32FlushInputBuffer(input.stdin) if (result.reason !== undefined) process.stderr.write((cliErrorMessage(result.reason) ?? errorFormat(result.reason)) + "\n") if (result.epilogue) process.stdout.write(result.epilogue + "\n") diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index fe7f4a22f75f..8e820c516792 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -17,7 +17,7 @@ import { useLocal } from "../../context/local" import { Flag } from "@opencode-ai/core/flag/flag" import { tint, useTheme } from "../../context/theme" import { EmptyBorder, SplitBorder } from "../../ui/border" -import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" +import { useTuiPaths, useTuiStdin, useTuiTerminalEnvironment } from "../../context/runtime" import { useClipboard } from "../../context/clipboard" import { Spinner } from "../spinner" import { useSDK } from "../../context/sdk" @@ -151,6 +151,7 @@ export function Prompt(props: PromptProps) { const paths = useTuiPaths() const location = useLocation() const terminalEnvironment = useTuiTerminalEnvironment() + const stdin = useTuiStdin() const clipboard = useClipboard() const sdk = useSDK() const editor = useEditorContext() @@ -442,6 +443,7 @@ export function Prompt(props: PromptProps) { const content = await openEditor({ renderer, value, + stdin, cwd: (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || project.instance.directory() || diff --git a/packages/tui/src/context/runtime.tsx b/packages/tui/src/context/runtime.tsx index 281049fe5786..8c0ca77df206 100644 --- a/packages/tui/src/context/runtime.tsx +++ b/packages/tui/src/context/runtime.tsx @@ -21,6 +21,7 @@ export type TuiStartup = Readonly<{ const PathsContext = createContext() const TerminalEnvironmentContext = createContext() const StartupContext = createContext() +const StdinContext = createContext() function provider(context: ReturnType>, value: T, children: () => JSX.Element) { return createComponent(context.Provider, { @@ -43,6 +44,18 @@ export function TuiStartupProvider(props: { value: TuiStartup; children: JSX.Ele return provider(StartupContext, props.value, () => props.children) } +export function TuiStdinProvider(props: { value?: NodeJS.ReadStream; children: JSX.Element }) { + // Streams must retain their identity; provider() freezes a shallow object copy. + return createComponent(StdinContext.Provider, { + get value() { + return props.value + }, + get children() { + return props.children + }, + }) +} + function required(context: ReturnType>, name: string) { const value = useContext(context) if (!value) throw new Error(`${name} is missing`) @@ -60,3 +73,7 @@ export function useTuiTerminalEnvironment() { export function useTuiStartup() { return required(StartupContext, "TuiStartupProvider") } + +export function useTuiStdin() { + return useContext(StdinContext) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 61abe4abd8d9..bbdf4eb239f8 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -22,7 +22,7 @@ import { useProject } from "../../context/project" import { useSync } from "../../context/sync" import { useEvent } from "../../context/event" import { SplitBorder } from "../../ui/border" -import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" +import { useTuiPaths, useTuiStdin, useTuiTerminalEnvironment } from "../../context/runtime" import { Spinner } from "../../component/spinner" import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" @@ -354,6 +354,7 @@ export function Session() { const keymap = useOpencodeKeymap() const dialog = useDialog() const renderer = useRenderer() + const stdin = useTuiStdin() event.on("session.status", (evt) => { if (evt.properties.sessionID !== route.sessionID) return @@ -986,6 +987,7 @@ export function Session() { await openEditor({ renderer, value: transcript, + stdin, cwd: (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || project.instance.directory() || @@ -1002,6 +1004,7 @@ export function Session() { const result = await openEditor({ renderer, value: transcript, + stdin, cwd: (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || project.instance.directory() || diff --git a/packages/tui/src/terminal-win32.ts b/packages/tui/src/terminal-win32.ts index 1aaa80aecd69..c20c8617df4a 100644 --- a/packages/tui/src/terminal-win32.ts +++ b/packages/tui/src/terminal-win32.ts @@ -1,9 +1,11 @@ import { dlopen, ptr } from "bun:ffi" -import type { ReadStream } from "node:tty" - const STD_INPUT_HANDLE = -10 const ENABLE_PROCESSED_INPUT = 0x0001 +type RawModeInput = NodeJS.ReadStream & { + setRawMode(mode: boolean): NodeJS.ReadStream +} + const kernel = () => dlopen("kernel32.dll", { GetStdHandle: { args: ["i32"], returns: "ptr" }, @@ -12,7 +14,13 @@ const kernel = () => FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" }, }) +const crt = () => + dlopen("ucrtbase.dll", { + _get_osfhandle: { args: ["i32"], returns: "ptr" }, + }) + let k32: ReturnType | undefined +let c32: ReturnType | undefined function load() { if (process.platform !== "win32") return false @@ -24,19 +32,51 @@ function load() { } } +function loadCrt() { + if (process.platform !== "win32") return false + try { + c32 ??= crt() + return true + } catch { + return false + } +} + +function readStreamFd(stdin: NodeJS.ReadStream) { + if (!("fd" in stdin)) return undefined + return typeof stdin.fd === "number" ? stdin.fd : undefined +} + +function inputHandle(stdin: NodeJS.ReadStream) { + if (!stdin.isTTY) return undefined + if (!load()) return undefined + if (stdin === process.stdin) return k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) + const fd = readStreamFd(stdin) + if (fd === undefined) return undefined + if (!loadCrt()) return undefined + return c32!.symbols._get_osfhandle(fd) +} + +function hasRawMode(input: NodeJS.ReadStream): input is RawModeInput { + return "setRawMode" in input && typeof input.setRawMode === "function" +} + +function getRawMode(input: RawModeInput) { + return input.setRawMode +} + /** * Clear ENABLE_PROCESSED_INPUT on the console stdin handle. */ -export function win32DisableProcessedInput() { +export function win32DisableProcessedInput(stdin: NodeJS.ReadStream = process.stdin) { if (process.platform !== "win32") return - if (!process.stdin.isTTY) return - if (!load()) return - const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) + const handle = inputHandle(stdin) + if (handle === undefined || handle === null) return const buf = new Uint32Array(1) if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return - const mode = buf[0]! + const mode = buf[0] if ((mode & ENABLE_PROCESSED_INPUT) === 0) return k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) } @@ -44,12 +84,11 @@ export function win32DisableProcessedInput() { /** * Discard any queued console input (mouse events, key presses, etc.). */ -export function win32FlushInputBuffer() { +export function win32FlushInputBuffer(stdin: NodeJS.ReadStream = process.stdin) { if (process.platform !== "win32") return - if (!process.stdin.isTTY) return - if (!load()) return - const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) + const handle = inputHandle(stdin) + if (handle === undefined || handle === null) return k32!.symbols.FlushConsoleInputBuffer(handle) } @@ -66,24 +105,27 @@ let unhook: (() => void) | undefined * - A `setRawMode(...)` hook to re-clear after known raw-mode toggles. * - A low-frequency poll as a backstop for native/external mode changes. */ -export function win32InstallCtrlCGuard() { - if (process.platform !== "win32") return - if (!process.stdin.isTTY) return - if (!load()) return +export function win32InstallCtrlCGuard(input: NodeJS.ReadStream = process.stdin): (() => void) | undefined { + if (process.platform !== "win32") return undefined + if (!input.isTTY) return undefined + if (!load()) return undefined + // The TUI owns one renderer and one input stream per process. if (unhook) return unhook - const stdin = process.stdin as ReadStream - const original = stdin.setRawMode + const handle = inputHandle(input) + if (handle === undefined || handle === null) return undefined + if (!hasRawMode(input)) return undefined + + const original = getRawMode(input) - const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) const buf = new Uint32Array(1) - if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return - const initial = buf[0]! + if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return undefined + const initial = buf[0] const enforce = () => { if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return - const mode = buf[0]! + const mode = buf[0] if ((mode & ENABLE_PROCESSED_INPUT) === 0) return k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) } @@ -94,18 +136,16 @@ export function win32InstallCtrlCGuard() { setImmediate(enforce) } - let wrapped: ReadStream["setRawMode"] | undefined + let wrapped: RawModeInput["setRawMode"] | undefined - if (typeof original === "function") { - wrapped = (mode: boolean) => { - const result = original.call(stdin, mode) - later() - return result - } - - stdin.setRawMode = wrapped + wrapped = (mode: boolean) => { + const result = original.call(input, mode) + later() + return result } + input.setRawMode = wrapped + // Ensure it's cleared immediately too (covers any earlier mode changes). later() @@ -118,8 +158,8 @@ export function win32InstallCtrlCGuard() { done = true clearInterval(interval) - if (wrapped && stdin.setRawMode === wrapped) { - stdin.setRawMode = original + if (wrapped && input.setRawMode === wrapped) { + input.setRawMode = original } k32!.symbols.SetConsoleMode(handle, initial)