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
View File
@@ -14,6 +14,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- run: bun install
- run: bun run build:single-exe:all
- name: Package release artifacts
+2
View File
@@ -10,6 +10,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- run: bun install
- run: bun typecheck
- name: Create integration test env
+2
View File
@@ -24,6 +24,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- run: bun install
- name: Build web app
env:
+5 -3
View File
@@ -38,7 +38,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",
@@ -76,7 +76,7 @@
"@types/node": ">=25",
"@types/qrcode": "^1.5.6",
"@types/web-push": "^3.6.4",
"bun-types": "^1.3.5",
"bun-types": "^1.3.14",
"typescript": "^5",
},
},
@@ -1064,6 +1064,8 @@
"@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.18.1", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-y8hzgUN/f+4ibvnfGEfAm+Ck2UzR2ejW6r/9gJShKRAR/yf7TVsa0rXtLjvfET2qOUgQQRvT1QohFTdbNVQg5g=="],
"@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.18.1", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-PqFUcolcJwdSQJp3jqMv82SQ5VKfKSdZr3GjFzy7C9qZnbzycXKmQttXAaLyD98PGSNsV3rj84VB5uN9KHa09w=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -1386,7 +1388,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
+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)
}
+2 -2
View File
@@ -97,9 +97,9 @@ Yes. Open any session and use the chat interface to send messages directly to th
### Can I access a terminal remotely?
Yes, on Linux and macOS hosts. Open a session in the web app and tap the Terminal tab for a remote shell.
Yes. Open a session in the web app and tap the Terminal tab for a remote shell.
Windows hosts do not support the remote Terminal yet because the Bun PTY API used by HAPI is currently POSIX-only.
Linux and macOS hosts use Bun's POSIX PTY support. Windows hosts use Bun's ConPTY support, which requires Bun 1.3.14 or newer.
### How do I use voice control?
+1 -1
View File
@@ -29,7 +29,7 @@
"@types/node": ">=25",
"@types/qrcode": "^1.5.6",
"@types/web-push": "^3.6.4",
"bun-types": "^1.3.5",
"bun-types": "^1.3.14",
"typescript": "^5"
}
}
+5
View File
@@ -9,6 +9,10 @@ const MetadataSummarySchema = z.object({
updatedAt: z.number()
})
const SessionCapabilitiesSchema = z.object({
terminal: z.boolean().optional()
})
export const WorktreeMetadataSchema = z.object({
basePath: z.string(),
branch: z.string(),
@@ -46,6 +50,7 @@ export const MetadataSchema = z.object({
archivedBy: z.string().optional(),
archiveReason: z.string().optional(),
flavor: z.string().nullish(),
capabilities: SessionCapabilitiesSchema.optional(),
worktree: WorktreeMetadataSchema.optional()
})
+1 -1
View File
@@ -179,7 +179,7 @@ export default {
'terminal.commandArgs': 'Command args',
'terminal.stdout': 'Stdout',
'terminal.stderr': 'Stderr',
'terminal.unsupportedWindows': 'Remote terminal is unavailable on Windows hosts.',
'terminal.unsupportedWindows': 'Remote terminal is unavailable on this host.',
'terminal.paste.fallbackTitle': 'Paste input',
'terminal.paste.fallbackDescription': 'Clipboard read is unavailable. Paste your text below.',
'terminal.paste.placeholder': 'Paste terminal input here…',
+1 -1
View File
@@ -181,7 +181,7 @@ export default {
'terminal.commandArgs': '命令参数',
'terminal.stdout': '标准输出',
'terminal.stderr': '标准错误',
'terminal.unsupportedWindows': 'Windows 主机暂不支持远程终端。',
'terminal.unsupportedWindows': '主机暂不支持远程终端。',
'terminal.paste.fallbackTitle': '粘贴输入',
'terminal.paste.fallbackDescription': '无法读取剪贴板,请在下方粘贴文本。',
'terminal.paste.placeholder': '在此粘贴终端输入…',
+3
View File
@@ -34,6 +34,9 @@ export type SessionMetadataSummary = {
machineId?: string
tools?: string[]
flavor?: string | null
capabilities?: {
terminal?: boolean
}
worktree?: WorktreeMetadata
}
+18 -3
View File
@@ -2,14 +2,29 @@ import { describe, expect, it } from 'vitest'
import { isRemoteTerminalSupported, isWindowsHostOs } from './terminalSupport'
describe('terminal support helpers', () => {
it('detects Windows session hosts as unsupported', () => {
it('does not disable remote terminal only because the session host is Windows', () => {
expect(isWindowsHostOs('win32')).toBe(true)
expect(isRemoteTerminalSupported({ os: 'win32', path: '', host: '' })).toBe(false)
expect(isRemoteTerminalSupported({ os: 'win32', path: '', host: '' })).toBe(true)
})
it('keeps remote terminal enabled for non-Windows or unknown hosts', () => {
it('keeps remote terminal enabled for non-Windows or unknown hosts by default', () => {
expect(isWindowsHostOs('linux')).toBe(false)
expect(isRemoteTerminalSupported({ os: 'linux', path: '', host: '' })).toBe(true)
expect(isRemoteTerminalSupported(null)).toBe(true)
})
it('respects explicit terminal capability metadata when present', () => {
expect(isRemoteTerminalSupported({
os: 'win32',
path: '',
host: '',
capabilities: { terminal: false }
})).toBe(false)
expect(isRemoteTerminalSupported({
os: 'win32',
path: '',
host: '',
capabilities: { terminal: true }
})).toBe(true)
})
})
+1 -1
View File
@@ -5,5 +5,5 @@ export function isWindowsHostOs(os: string | null | undefined): boolean {
}
export function isRemoteTerminalSupported(metadata: SessionMetadataSummary | null | undefined): boolean {
return !isWindowsHostOs(metadata?.os)
return metadata?.capabilities?.terminal ?? true
}