From 0d36b8d4416ee5b0f39003ab3f9109d55a450ce0 Mon Sep 17 00:00:00 2001 From: SmallSpider0 <568442079@qq.com> Date: Wed, 13 May 2026 08:56:11 +0800 Subject: [PATCH] Fix Codex MCP startup and remote terminal spawning --- cli/src/codex/utils/buildHapiMcpBridge.ts | 68 +++- cli/src/terminal/TerminalManager.ts | 425 ++++++++++++++++++---- 2 files changed, 428 insertions(+), 65 deletions(-) diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index f1f544b26..142d3018e 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -7,6 +7,7 @@ import { startHappyServer } from '@/claude/utils/startHappyServer'; import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; +import { spawnSync } from 'node:child_process'; import type { ApiSessionClient } from '@/api/apiSession'; /** @@ -39,6 +40,61 @@ export interface HapiMcpBridgeOptions { emitTitleSummary?: boolean; } +// Codex app-server 0.130 can close a Bun-backed MCP stdio server while +// processing the initialize response. Keep a tiny Node stdio shim in front of +// the real hapi MCP bridge so Codex talks to a stable Node process. This is +// intentionally inline instead of a sidecar file so compiled/release HAPI can +// spawn it without needing extra packaged assets. +const HAPI_MCP_STDIO_PROXY_SCRIPT = String.raw` +const { spawn } = require('node:child_process'); +const [command, ...args] = process.argv.slice(1); +if (!command) { + process.stderr.write('[hapi-mcp-proxy] Missing child command\n'); + process.exit(2); +} +const child = spawn(command, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: process.env, + windowsHide: process.platform === 'win32' +}); +let exiting = false; +function safeEnd(stream) { try { stream.end(); } catch {} } +function safeKill() { + if (child.killed || child.exitCode !== null) return; + try { child.kill(); } catch {} +} +process.stdin.on('data', (chunk) => { + if (!child.stdin.destroyed) child.stdin.write(chunk); +}); +process.stdin.on('end', () => safeEnd(child.stdin)); +process.stdin.on('error', () => safeEnd(child.stdin)); +child.stdout.on('data', (chunk) => process.stdout.write(chunk)); +child.stderr.on('data', (chunk) => process.stderr.write(chunk)); +child.on('error', (error) => { + process.stderr.write('[hapi-mcp-proxy] Failed to start child: ' + (error instanceof Error ? error.message : String(error)) + '\n'); + if (!exiting) { exiting = true; process.exit(1); } +}); +child.on('exit', (code, signal) => { + if (exiting) return; + exiting = true; + if (signal) { process.kill(process.pid, signal); return; } + process.exit(code ?? 0); +}); +process.on('SIGTERM', () => { safeKill(); process.exit(143); }); +process.on('SIGINT', () => { safeKill(); process.exit(130); }); +process.on('exit', safeKill); +`; + +function resolveNodeExecutable(): string | null { + const override = process.env.HAPI_NODE_EXECUTABLE?.trim(); + if (override) { + return override; + } + + const result = spawnSync('node', ['--version'], { stdio: 'ignore' }); + return result.status === 0 ? 'node' : null; +} + /** * Start the hapi MCP bridge server and return the configuration * needed to connect Codex to it. @@ -54,6 +110,13 @@ export async function buildHapiMcpBridge( emitTitleSummary: options.emitTitleSummary }); const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]); + const nodeCommand = resolveNodeExecutable(); + const hapiMcpServer = nodeCommand + ? { + command: nodeCommand, + args: ['-e', HAPI_MCP_STDIO_PROXY_SCRIPT, bridgeCommand.command, ...bridgeCommand.args] + } + : bridgeCommand; return { server: { @@ -61,10 +124,7 @@ export async function buildHapiMcpBridge( stop: happyServer.stop }, mcpServers: { - hapi: { - command: bridgeCommand.command, - args: bridgeCommand.args - } + hapi: hapiMcpServer } }; } diff --git a/cli/src/terminal/TerminalManager.ts b/cli/src/terminal/TerminalManager.ts index c3c45c047..cc60c61c7 100644 --- a/cli/src/terminal/TerminalManager.ts +++ b/cli/src/terminal/TerminalManager.ts @@ -1,3 +1,5 @@ +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { existsSync } from 'node:fs' import { logger } from '@/ui/logger' import { getInvokedCwd } from '@/utils/invokedCwd' import type { @@ -8,9 +10,15 @@ import type { } from '@hapi/protocol' import type { TerminalSession } from './types' +type TerminalHandle = { + write: (data: string) => void + resize: (cols: number, rows: number) => void + close: () => void +} + type TerminalRuntime = TerminalSession & { - proc: Bun.Subprocess - terminal: Bun.Terminal + proc: { killed?: boolean; exitCode?: number | null; kill: () => unknown } + terminal: TerminalHandle idleTimer: ReturnType | null } @@ -27,6 +35,96 @@ type TerminalManagerOptions = { const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60_000 const DEFAULT_MAX_TERMINALS = 4 + +const PYTHON_PTY_BRIDGE_SCRIPT = String.raw` +import base64 +import errno +import fcntl +import json +import os +import pty +import select +import signal +import struct +import subprocess +import sys +import threading +import termios + +shell = sys.argv[1] +cols = int(sys.argv[2]) +rows = int(sys.argv[3]) +name = os.path.basename(shell) +argv = [shell, '-i'] if name in ('bash', 'zsh', 'fish') else [shell] +master_fd, slave_fd = pty.openpty() + +def set_size(next_cols, next_rows): + winsize = struct.pack('HHHH', int(next_rows), int(next_cols), 0, 0) + fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize) + try: + fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, winsize) + except OSError: + pass + +set_size(cols, rows) +proc = subprocess.Popen(argv, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, preexec_fn=os.setsid) +os.close(slave_fd) + +def input_loop(): + for raw in sys.stdin.buffer: + try: + msg = json.loads(raw.decode('utf-8')) + msg_type = msg.get('type') + if msg_type == 'write': + os.write(master_fd, base64.b64decode(msg.get('data', ''))) + elif msg_type == 'resize': + set_size(msg.get('cols', cols), msg.get('rows', rows)) + try: + os.killpg(proc.pid, signal.SIGWINCH) + except OSError: + pass + elif msg_type == 'close': + break + except Exception as exc: + print('[hapi-terminal-pty] input error: %s' % exc, file=sys.stderr) + break + try: + proc.terminate() + except OSError: + pass + +threading.Thread(target=input_loop, daemon=True).start() + +while True: + if proc.poll() is not None: + timeout = 0 + else: + timeout = 0.1 + try: + readable, _, _ = select.select([master_fd], [], [], timeout) + except OSError: + break + if master_fd in readable: + try: + data = os.read(master_fd, 65536) + except OSError as exc: + if exc.errno == errno.EIO: + break + raise + if not data: + break + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + elif proc.poll() is not None: + break + +try: + os.close(master_fd) +except OSError: + pass +os._exit(proc.wait() if proc.poll() is None else proc.returncode) +`; + const SENSITIVE_ENV_KEYS = new Set([ 'CLI_API_TOKEN', 'HAPI_API_URL', @@ -57,6 +155,35 @@ function resolveShell(): string { return '/bin/bash' } +function shellArgs(shell: string): string[] { + const name = shell.split(/[\\/]/).pop() ?? shell + if (name === 'bash' || name === 'zsh' || name === 'fish') { + return [shell, '-i'] + } + return [shell] +} + +function quotePosixShellArg(value: string): string { + return "'" + value.replace(/'/g, "'\\''") + "'" +} + +function resolvePythonExecutable(): string | null { + for (const candidate of ['python3', 'python']) { + const result = spawnSync(candidate, ['--version'], { stdio: 'ignore' }) + if (result.status === 0) { + return candidate + } + } + return null +} + +function resolveTerminalCommand(shell: string): string[] { + if (process.platform !== 'darwin' && process.env.HAPI_TERMINAL_DISABLE_SCRIPT_PTY !== '1' && existsSync('/usr/bin/script')) { + return ['/usr/bin/script', '-q', '/dev/null', '-c', `${quotePosixShellArg(shell)} -i`] + } + return shellArgs(shell) +} + function buildFilteredEnv(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} for (const [key, value] of Object.entries(process.env)) { @@ -125,77 +252,253 @@ export class TerminalManager { return } + const sessionPath = this.getSessionPath() ?? getInvokedCwd() + const shell = resolveShell() + + try { + if (process.platform === 'darwin' || process.env.HAPI_TERMINAL_USE_BUN_PTY === '1') { + this.createBunTerminal(terminalId, cols, rows, sessionPath, shell) + return + } + const python = resolvePythonExecutable() + if (python) { + this.createPythonPtyTerminal(terminalId, cols, rows, sessionPath, shell, python) + return + } + this.createPipeTerminal(terminalId, cols, rows, sessionPath, shell) + } catch (error) { + logger.debug('[TERMINAL] Failed to spawn terminal', { error }) + this.emitError(terminalId, 'Failed to spawn terminal.') + } + } + + private createBunTerminal(terminalId: string, cols: number, rows: number, cwd: string, shell: string): void { if (typeof Bun === 'undefined' || typeof Bun.spawn !== 'function') { - this.emitError(terminalId, 'Terminal is unavailable in this runtime.') + this.createPipeTerminal(terminalId, cols, rows, cwd, shell) return } - const sessionPath = this.getSessionPath() ?? getInvokedCwd() - const shell = resolveShell() const decoder = new TextDecoder() - - try { - const proc = Bun.spawn([shell], { - cwd: sessionPath, - env: this.filteredEnv, - terminal: { - cols, - rows, - data: (terminal, data) => { - const text = decoder.decode(data, { stream: true }) - if (text) { - this.onOutput({ sessionId: this.sessionId, terminalId, data: text }) - } - const active = this.terminals.get(terminalId) - if (active) { - this.markActivity(active) - } - }, - exit: (terminal, exitCode) => { - if (exitCode === 1) { - this.emitError(terminalId, 'Terminal stream closed unexpectedly.') - } + const proc = Bun.spawn(shellArgs(shell), { + cwd, + env: this.filteredEnv, + terminal: { + cols, + rows, + data: (_terminal, data) => { + const text = decoder.decode(data, { stream: true }) + if (text) { + this.onOutput({ sessionId: this.sessionId, terminalId, data: text }) + } + const active = this.terminals.get(terminalId) + if (active) { + this.markActivity(active) } - }, - onExit: (subprocess, exitCode) => { - const signal = subprocess.signalCode ?? null - this.onExit({ - sessionId: this.sessionId, - terminalId, - code: exitCode ?? null, - signal - }) - this.cleanup(terminalId) } + }, + onExit: (subprocess, exitCode) => { + const signal = subprocess.signalCode ?? null + this.onExit({ + sessionId: this.sessionId, + terminalId, + code: exitCode ?? null, + signal + }) + this.cleanup(terminalId) + } + }) + + const bunTerminal = proc.terminal + if (!bunTerminal) { + try { + proc.kill() + } catch (error) { + logger.debug('[TERMINAL] Failed to kill process after missing Bun terminal', { error }) + } + this.createPipeTerminal(terminalId, cols, rows, cwd, shell) + return + } + + const terminal: TerminalHandle = { + write: (data: string) => bunTerminal.write(data), + resize: (nextCols: number, nextRows: number) => bunTerminal.resize(nextCols, nextRows), + close: () => bunTerminal.close() + } + + const runtime: TerminalRuntime = { + terminalId, + cols, + rows, + proc, + terminal, + idleTimer: null + } + + this.terminals.set(terminalId, runtime) + this.markActivity(runtime) + this.onReady({ sessionId: this.sessionId, terminalId }) + } + + private createPythonPtyTerminal( + terminalId: string, + cols: number, + rows: number, + cwd: string, + shell: string, + python: string + ): void { + const proc = spawn(python, ['-c', PYTHON_PTY_BRIDGE_SCRIPT, shell, String(cols), String(rows)], { + cwd, + env: { + ...this.filteredEnv, + COLUMNS: String(cols), + LINES: String(rows) + }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: process.platform === 'win32' + }) + + const emitOutput = (data: Buffer | string) => { + const text = data.toString() + if (text) { + this.onOutput({ sessionId: this.sessionId, terminalId, data: text }) + } + const active = this.terminals.get(terminalId) + if (active) { + this.markActivity(active) + } + } + + proc.stdout.on('data', emitOutput) + proc.stderr.on('data', emitOutput) + proc.on('error', (error) => { + logger.debug('[TERMINAL] Python PTY bridge process error', { error }) + this.emitError(terminalId, 'Terminal process failed.') + this.cleanup(terminalId) + }) + proc.on('exit', (exitCode, signal) => { + this.onExit({ + sessionId: this.sessionId, + terminalId, + code: exitCode ?? null, + signal: signal ?? null }) + this.cleanup(terminalId) + }) + + const writeControl = (payload: Record) => { + if (!proc.stdin.destroyed) { + proc.stdin.write(`${JSON.stringify(payload)}\n`) + } + } - const terminal = proc.terminal - if (!terminal) { + const terminal: TerminalHandle = { + write: (data: string) => writeControl({ type: 'write', data: Buffer.from(data).toString('base64') }), + resize: (nextCols: number, nextRows: number) => writeControl({ type: 'resize', cols: nextCols, rows: nextRows }), + close: () => { + writeControl({ type: 'close' }) try { - proc.kill() + if (!proc.stdin.destroyed) { + proc.stdin.end() + } } catch (error) { - logger.debug('[TERMINAL] Failed to kill process after missing terminal', { error }) + logger.debug('[TERMINAL] Failed to close Python PTY stdin', { error }) } - this.emitError(terminalId, 'Failed to attach terminal.') - return } + } + + const runtime: TerminalRuntime = { + terminalId, + cols, + rows, + proc: proc as ChildProcessWithoutNullStreams, + terminal, + idleTimer: null + } + + this.terminals.set(terminalId, runtime) + this.markActivity(runtime) + this.onReady({ sessionId: this.sessionId, terminalId }) + } + + private createPipeTerminal(terminalId: string, cols: number, rows: number, cwd: string, shell: string): void { + const [command, ...args] = resolveTerminalCommand(shell) + const env = { + ...this.filteredEnv, + COLUMNS: String(cols), + LINES: String(rows) + } + const proc = spawn(command, args, { + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: process.platform === 'win32' + }) + + const emitOutput = (data: Buffer | string) => { + const text = data.toString() + if (text) { + this.onOutput({ sessionId: this.sessionId, terminalId, data: text }) + } + const active = this.terminals.get(terminalId) + if (active) { + this.markActivity(active) + } + } - const runtime: TerminalRuntime = { + proc.stdout.on('data', emitOutput) + proc.stderr.on('data', emitOutput) + proc.on('error', (error) => { + logger.debug('[TERMINAL] Pipe terminal process error', { error }) + this.emitError(terminalId, 'Terminal process failed.') + this.cleanup(terminalId) + }) + proc.on('exit', (exitCode, signal) => { + this.onExit({ + sessionId: this.sessionId, terminalId, - cols, - rows, - proc, - terminal, - idleTimer: null + code: exitCode ?? null, + signal: signal ?? null + }) + this.cleanup(terminalId) + }) + + const terminal: TerminalHandle = { + write: (data: string) => { + if (!proc.stdin.destroyed) { + proc.stdin.write(data) + } + }, + resize: (nextCols: number, nextRows: number) => { + // Pipes cannot resize a pseudo terminal. Preserve dimensions for + // reconnect/idleness bookkeeping and expose them to child shells + // that consult COLUMNS/LINES before prompt redraws. + env.COLUMNS = String(nextCols) + env.LINES = String(nextRows) + }, + close: () => { + try { + if (!proc.stdin.destroyed) { + proc.stdin.end() + } + } catch (error) { + logger.debug('[TERMINAL] Failed to close pipe stdin', { error }) + } } + } - this.terminals.set(terminalId, runtime) - this.markActivity(runtime) - this.onReady({ sessionId: this.sessionId, terminalId }) - } catch (error) { - logger.debug('[TERMINAL] Failed to spawn terminal', { error }) - this.emitError(terminalId, 'Failed to spawn terminal.') + const runtime: TerminalRuntime = { + terminalId, + cols, + rows, + proc: proc as ChildProcessWithoutNullStreams, + terminal, + idleTimer: null } + + this.terminals.set(terminalId, runtime) + this.markActivity(runtime) + this.onReady({ sessionId: this.sessionId, terminalId }) } write(terminalId: string, data: string): void { @@ -259,6 +562,12 @@ export class TerminalManager { clearTimeout(runtime.idleTimer) } + try { + runtime.terminal.close() + } catch (error) { + logger.debug('[TERMINAL] Failed to close terminal stream', { error }) + } + if (!runtime.proc.killed && runtime.proc.exitCode === null) { try { runtime.proc.kill() @@ -266,12 +575,6 @@ export class TerminalManager { logger.debug('[TERMINAL] Failed to kill process', { error }) } } - - try { - runtime.terminal.close() - } catch (error) { - logger.debug('[TERMINAL] Failed to close terminal', { error }) - } } private emitError(terminalId: string, message: string): void {