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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': '在此粘贴终端输入…',
|
||||
|
||||
@@ -34,6 +34,9 @@ export type SessionMetadataSummary = {
|
||||
machineId?: string
|
||||
tools?: string[]
|
||||
flavor?: string | null
|
||||
capabilities?: {
|
||||
terminal?: boolean
|
||||
}
|
||||
worktree?: WorktreeMetadata
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user