Add Windows remote terminal support (#642)

This commit is contained in:
leko
2026-05-19 07:54:12 +08:00
committed by GitHub
parent bb04247127
commit ce2e76a42e
17 changed files with 240 additions and 29 deletions
+2 -2
View File
@@ -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"
}
+12
View File
@@ -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)
})
})
+3
View File
@@ -78,6 +78,9 @@ export function buildSessionMetadata(options: {
lifecycleState: 'running',
lifecycleStateSince: now,
flavor: options.flavor,
capabilities: {
terminal: true
},
worktree: worktreeInfo ?? undefined,
...options.metadataOverrides
}
+118
View File
@@ -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')
})
})
+62 -15
View File
@@ -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)
}