mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Add Windows remote terminal support (#642)
This commit is contained in:
+2
-2
@@ -71,7 +71,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": ">=25",
|
||||
"bun-types": "^1.3.5",
|
||||
"bun-types": "^1.3.14",
|
||||
"dotenv": "^17.2.3",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.16"
|
||||
@@ -81,5 +81,5 @@
|
||||
"parse-path": "7.0.3",
|
||||
"@types/parse-path": "7.0.3"
|
||||
},
|
||||
"packageManager": "bun@1.3.5"
|
||||
"packageManager": "bun@1.3.14"
|
||||
}
|
||||
|
||||
@@ -25,4 +25,16 @@ describe('buildSessionMetadata', () => {
|
||||
|
||||
expect(metadata.host).toBe('custom-session-host')
|
||||
})
|
||||
|
||||
it('advertises remote terminal capability in session metadata', () => {
|
||||
const metadata = buildSessionMetadata({
|
||||
flavor: 'codex',
|
||||
startedBy: 'terminal',
|
||||
workingDirectory: '/tmp/project',
|
||||
machineId: 'machine-1',
|
||||
now: 123
|
||||
})
|
||||
|
||||
expect(metadata.capabilities?.terminal).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,6 +78,9 @@ export function buildSessionMetadata(options: {
|
||||
lifecycleState: 'running',
|
||||
lifecycleStateSince: now,
|
||||
flavor: options.flavor,
|
||||
capabilities: {
|
||||
terminal: true
|
||||
},
|
||||
worktree: worktreeInfo ?? undefined,
|
||||
...options.metadataOverrides
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TerminalManager,
|
||||
normalizeTerminalInputForHost,
|
||||
resolveShellCommand
|
||||
} from './TerminalManager'
|
||||
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const originalTerminalShell = process.env.HAPI_TERMINAL_SHELL
|
||||
const originalComSpec = process.env.ComSpec
|
||||
const globalWithBun = globalThis as unknown as {
|
||||
Bun?: {
|
||||
spawn?: unknown
|
||||
which?: unknown
|
||||
}
|
||||
}
|
||||
const originalBun = globalWithBun.Bun
|
||||
|
||||
function setPlatform(value: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
describe('TerminalManager Windows support', () => {
|
||||
beforeAll(() => {
|
||||
if (!originalPlatformDescriptor?.configurable) {
|
||||
throw new Error('process.platform is not configurable in this runtime')
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
|
||||
process.env.HAPI_TERMINAL_SHELL = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
setPlatform('win32')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor)
|
||||
}
|
||||
if (originalBun === undefined) {
|
||||
delete globalWithBun.Bun
|
||||
} else {
|
||||
globalWithBun.Bun = originalBun
|
||||
}
|
||||
if (originalTerminalShell === undefined) {
|
||||
delete process.env.HAPI_TERMINAL_SHELL
|
||||
} else {
|
||||
process.env.HAPI_TERMINAL_SHELL = originalTerminalShell
|
||||
}
|
||||
if (originalComSpec === undefined) {
|
||||
delete process.env.ComSpec
|
||||
} else {
|
||||
process.env.ComSpec = originalComSpec
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves an explicit Windows terminal shell command', () => {
|
||||
expect(resolveShellCommand()).toEqual(['C:\\Program Files\\PowerShell\\7\\pwsh.exe'])
|
||||
})
|
||||
|
||||
it('normalizes lone line feeds to carriage returns for Windows terminal input', () => {
|
||||
expect(normalizeTerminalInputForHost('echo one\nsecond\r\nthird\n')).toBe('echo one\rsecond\r\nthird\r')
|
||||
})
|
||||
|
||||
it('opens a Windows PTY instead of rejecting the request', () => {
|
||||
const terminal = {
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
close: vi.fn()
|
||||
} as unknown as Bun.Terminal
|
||||
|
||||
const proc = {
|
||||
terminal,
|
||||
killed: false,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
kill: vi.fn()
|
||||
} as unknown as Bun.Subprocess
|
||||
|
||||
const spawnMock = vi.fn(() => proc)
|
||||
globalWithBun.Bun = {
|
||||
spawn: spawnMock
|
||||
}
|
||||
|
||||
const ready: unknown[] = []
|
||||
const errors: unknown[] = []
|
||||
const manager = new TerminalManager({
|
||||
sessionId: 'session-1',
|
||||
getSessionPath: () => 'C:\\workspace\\project',
|
||||
onReady: (payload) => ready.push(payload),
|
||||
onOutput: () => {},
|
||||
onExit: () => {},
|
||||
onError: (payload) => errors.push(payload),
|
||||
idleTimeoutMs: 0
|
||||
})
|
||||
|
||||
manager.create('terminal-1', 120, 30)
|
||||
manager.write('terminal-1', 'echo one\n')
|
||||
|
||||
expect(errors).toEqual([])
|
||||
expect(ready).toEqual([{ sessionId: 'session-1', terminalId: 'terminal-1' }])
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
['C:\\Program Files\\PowerShell\\7\\pwsh.exe'],
|
||||
expect.objectContaining({
|
||||
cwd: 'C:\\workspace\\project',
|
||||
terminal: expect.objectContaining({
|
||||
cols: 120,
|
||||
rows: 30
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(terminal.write).toHaveBeenCalledWith('echo one\r')
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,10 @@ const SENSITIVE_ENV_KEYS = new Set([
|
||||
'GOOGLE_API_KEY'
|
||||
])
|
||||
|
||||
function getOptionalBun(): typeof Bun | null {
|
||||
return typeof Bun === 'undefined' ? null : Bun
|
||||
}
|
||||
|
||||
function resolveEnvNumber(name: string, fallback: number): number {
|
||||
const raw = process.env[name]
|
||||
if (!raw) {
|
||||
@@ -47,14 +51,56 @@ function resolveEnvNumber(name: string, fallback: number): number {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function resolveShell(): string {
|
||||
function resolveWindowsShellCommand(): string[] {
|
||||
const configuredShell = process.env.HAPI_TERMINAL_SHELL?.trim()
|
||||
if (configuredShell) {
|
||||
return [configuredShell]
|
||||
}
|
||||
|
||||
const bun = getOptionalBun()
|
||||
const candidates = ['pwsh.exe', 'powershell.exe']
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const resolved = bun?.which?.(candidate)
|
||||
if (resolved) {
|
||||
return [resolved, '-NoLogo']
|
||||
}
|
||||
} catch {
|
||||
// Ignore PATH lookup failures and try the next fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return [process.env.ComSpec || 'cmd.exe']
|
||||
}
|
||||
|
||||
export function resolveShellCommand(): string[] {
|
||||
if (process.platform === 'win32') {
|
||||
return resolveWindowsShellCommand()
|
||||
}
|
||||
if (process.env.SHELL) {
|
||||
return process.env.SHELL
|
||||
return [process.env.SHELL]
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return '/bin/zsh'
|
||||
return ['/bin/zsh']
|
||||
}
|
||||
return '/bin/bash'
|
||||
return ['/bin/bash']
|
||||
}
|
||||
|
||||
export function normalizeTerminalInputForHost(data: string): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return data
|
||||
}
|
||||
|
||||
let normalized = ''
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
const char = data[index]
|
||||
if (char === '\n' && data[index - 1] !== '\r') {
|
||||
normalized += '\r'
|
||||
} else {
|
||||
normalized += char
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function buildFilteredEnv(): NodeJS.ProcessEnv {
|
||||
@@ -75,7 +121,7 @@ function buildFilteredEnv(): NodeJS.ProcessEnv {
|
||||
env.COLORTERM = 'truecolor'
|
||||
}
|
||||
if (!env.LANG) {
|
||||
env.LANG = process.platform === 'darwin' ? 'en_US.UTF-8' : 'C.UTF-8'
|
||||
env.LANG = process.platform === 'darwin' || process.platform === 'win32' ? 'en_US.UTF-8' : 'C.UTF-8'
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -105,11 +151,6 @@ export class TerminalManager {
|
||||
}
|
||||
|
||||
create(terminalId: string, cols: number, rows: number): void {
|
||||
if (process.platform === 'win32') {
|
||||
this.emitError(terminalId, 'Remote terminal is not supported on Windows yet.')
|
||||
return
|
||||
}
|
||||
|
||||
const existing = this.terminals.get(terminalId)
|
||||
if (existing) {
|
||||
existing.cols = cols
|
||||
@@ -125,17 +166,18 @@ export class TerminalManager {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof Bun === 'undefined' || typeof Bun.spawn !== 'function') {
|
||||
const bun = getOptionalBun()
|
||||
if (!bun || typeof bun.spawn !== 'function') {
|
||||
this.emitError(terminalId, 'Terminal is unavailable in this runtime.')
|
||||
return
|
||||
}
|
||||
|
||||
const sessionPath = this.getSessionPath() ?? getInvokedCwd()
|
||||
const shell = resolveShell()
|
||||
const shellCommand = resolveShellCommand()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn([shell], {
|
||||
const proc = bun.spawn(shellCommand, {
|
||||
cwd: sessionPath,
|
||||
env: this.filteredEnv,
|
||||
terminal: {
|
||||
@@ -194,7 +236,12 @@ export class TerminalManager {
|
||||
this.onReady({ sessionId: this.sessionId, terminalId })
|
||||
} catch (error) {
|
||||
logger.debug('[TERMINAL] Failed to spawn terminal', { error })
|
||||
this.emitError(terminalId, 'Failed to spawn terminal.')
|
||||
const message = process.platform === 'win32'
|
||||
&& error instanceof Error
|
||||
&& error.message.includes('terminal option is not supported')
|
||||
? 'Remote terminal on Windows requires Bun 1.3.14 or newer.'
|
||||
: 'Failed to spawn terminal.'
|
||||
this.emitError(terminalId, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +251,7 @@ export class TerminalManager {
|
||||
this.emitError(terminalId, 'Terminal not found.')
|
||||
return
|
||||
}
|
||||
runtime.terminal.write(data)
|
||||
runtime.terminal.write(normalizeTerminalInputForHost(data))
|
||||
this.markActivity(runtime)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user