From ce2e76a42e5b77823b126da7e7e21ac982d8a2d8 Mon Sep 17 00:00:00 2001 From: leko Date: Tue, 19 May 2026 07:54:12 +0800 Subject: [PATCH] Add Windows remote terminal support (#642) --- .github/workflows/release.yml | 2 + .github/workflows/test.yml | 2 + .github/workflows/webapp.yml | 2 + bun.lock | 8 +- cli/package.json | 4 +- cli/src/agent/sessionFactory.test.ts | 12 +++ cli/src/agent/sessionFactory.ts | 3 + cli/src/terminal/TerminalManager.test.ts | 118 +++++++++++++++++++++++ cli/src/terminal/TerminalManager.ts | 77 ++++++++++++--- docs/guide/faq.md | 4 +- hub/package.json | 2 +- shared/src/schemas.ts | 5 + web/src/lib/locales/en.ts | 2 +- web/src/lib/locales/zh-CN.ts | 2 +- web/src/types/api.ts | 3 + web/src/utils/terminalSupport.test.ts | 21 +++- web/src/utils/terminalSupport.ts | 2 +- 17 files changed, 240 insertions(+), 29 deletions(-) create mode 100644 cli/src/terminal/TerminalManager.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76eabda7..8adbc2e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a4a2856..8fac2755 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.github/workflows/webapp.yml b/.github/workflows/webapp.yml index 2902da0a..f5993cbb 100644 --- a/.github/workflows/webapp.yml +++ b/.github/workflows/webapp.yml @@ -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: diff --git a/bun.lock b/bun.lock index d9f8214b..4eda3744 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/cli/package.json b/cli/package.json index 9bd00ad9..6f387a1b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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" } diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index f6352ce2..1fdd9434 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -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) + }) }) diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index b1ee7399..949e0551 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -78,6 +78,9 @@ export function buildSessionMetadata(options: { lifecycleState: 'running', lifecycleStateSince: now, flavor: options.flavor, + capabilities: { + terminal: true + }, worktree: worktreeInfo ?? undefined, ...options.metadataOverrides } diff --git a/cli/src/terminal/TerminalManager.test.ts b/cli/src/terminal/TerminalManager.test.ts new file mode 100644 index 00000000..83821ac4 --- /dev/null +++ b/cli/src/terminal/TerminalManager.test.ts @@ -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') + }) +}) diff --git a/cli/src/terminal/TerminalManager.ts b/cli/src/terminal/TerminalManager.ts index c3c45c04..47594875 100644 --- a/cli/src/terminal/TerminalManager.ts +++ b/cli/src/terminal/TerminalManager.ts @@ -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) } diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 968a9144..095ba251 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -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? diff --git a/hub/package.json b/hub/package.json index aa7c78fa..900d6f1d 100644 --- a/hub/package.json +++ b/hub/package.json @@ -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" } } diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index aa634bd1..32264cb1 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -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() }) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7a1190ef..5f05af46 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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…', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 3f6ca797..7476d7f7 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '在此粘贴终端输入…', diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 9675648b..793ee268 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -34,6 +34,9 @@ export type SessionMetadataSummary = { machineId?: string tools?: string[] flavor?: string | null + capabilities?: { + terminal?: boolean + } worktree?: WorktreeMetadata } diff --git a/web/src/utils/terminalSupport.test.ts b/web/src/utils/terminalSupport.test.ts index 23967e65..61d4f210 100644 --- a/web/src/utils/terminalSupport.test.ts +++ b/web/src/utils/terminalSupport.test.ts @@ -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) + }) }) diff --git a/web/src/utils/terminalSupport.ts b/web/src/utils/terminalSupport.ts index 142186ad..172362d6 100644 --- a/web/src/utils/terminalSupport.ts +++ b/web/src/utils/terminalSupport.ts @@ -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 }