feat: add multi-terminal workbar and resizable sidebars

The assistant workbar now uses persistent application tabs with a permanent catalog entry point and supports multiple local or managed SSH terminals bound to the current project. Terminal sessions flow through the Main, preload, and renderer boundaries with bounded output, resize, lifecycle, and cleanup behavior.

The main sidebar and Magic Notes list now support pointer and keyboard resizing. Magic Notes can also hide and restore its list, remembers the chosen layout, and stacks its panes when the available width is narrow.

Closing a terminal tab ends its Shell session. Restored terminal tabs do not restart prior Shell processes and require an explicit reconnect.

Release note: 助手工作栏现可通过“+”打开多个本机或当前远程项目终端;主侧栏和魔法笔记列表支持拖拽调宽,魔法笔记列表可隐藏并记住布局。
This commit is contained in:
mesalogo
2026-09-01 00:49:49 +08:00
parent 73cc55bf75
commit 27055693bf
45 changed files with 8814 additions and 192 deletions
@@ -0,0 +1,318 @@
import type { IDisposable, IPty } from 'node-pty'
import { describe, expect, it, vi } from 'vitest'
import {
TERMINAL_LIMITS,
type TerminalEvent
} from '../../shared/terminal-contracts'
import {
LocalTerminalSession,
buildLocalTerminalEnvironment,
resolveLocalTerminalLaunch,
resolveLocalTerminalShell
} from './local-terminal-session'
class FakePty implements IPty {
readonly pid = 4321
readonly cols = 80
readonly rows = 24
readonly process = 'fake-shell'
handleFlowControl = false
readonly write = vi.fn()
readonly resize = vi.fn()
readonly clear = vi.fn()
readonly kill = vi.fn()
readonly pause = vi.fn()
readonly resume = vi.fn()
private dataListeners = new Set<(data: string) => void>()
private exitListeners = new Set<
(event: { exitCode: number; signal?: number }) => void
>()
readonly onData = (
listener: (data: string) => void
): IDisposable => {
this.dataListeners.add(listener)
return {
dispose: () => this.dataListeners.delete(listener)
}
}
readonly onExit = (
listener: (event: {
exitCode: number
signal?: number
}) => void
): IDisposable => {
this.exitListeners.add(listener)
return {
dispose: () => this.exitListeners.delete(listener)
}
}
emitData(data: string): void {
for (const listener of this.dataListeners) {
listener(data)
}
}
emitExit(exitCode: number, signal?: number): void {
for (const listener of [...this.exitListeners]) {
listener({ exitCode, signal })
}
}
}
const baseOptions = {
sessionId: '5d8f47a2-c681-4c26-95a2-2ed1a086fc78',
target: { type: 'local' } as const,
targetLabel: '本机',
title: '终端 · 本机 1',
size: { cols: 80, rows: 24 }
}
function sessionDependencies(
pty: FakePty,
overrides: Record<string, unknown> = {}
) {
return {
platform: 'linux' as const,
environment: {
SHELL: '/bin/bash',
PATH: '/bin',
SAFE_VALUE: 'visible'
},
executableExists: async (path: string) => path === '/bin/bash',
directoryExists: async () => true,
homeDirectory: () => '/home/tester',
spawn: vi.fn(() => pty),
terminate: vi.fn(async () => {
pty.emitExit(0)
}),
...overrides
}
}
describe('local terminal launch resolution', () => {
it('prefers pwsh, then PowerShell, then COMSPEC on Windows', async () => {
const available = new Set(['C:\\bin\\powershell.EXE'])
const shell = await resolveLocalTerminalShell({
platform: 'win32',
environment: {
Path: 'C:\\bin',
PATHEXT: '.EXE',
COMSPEC: 'C:\\Windows\\cmd.exe'
},
executableExists: async (path) => available.has(path)
})
expect(shell).toEqual({
shell: 'C:\\bin\\powershell.EXE',
shellLabel: 'powershell.EXE'
})
})
it('uses a valid POSIX SHELL and falls back to home for invalid cwd', async () => {
const launch = await resolveLocalTerminalLaunch('/missing', {
platform: 'linux',
environment: {
SHELL: '/usr/bin/fish',
PATH: '/usr/bin',
LANG: 'zh_CN.UTF-8',
GOODBUDDY_MODEL_API_KEY: 'secret',
ANTHROPIC_API_KEY: 'secret'
},
executableExists: async (path) => path === '/usr/bin/fish',
directoryExists: async (path) => path === '/home/tester',
homeDirectory: () => '/home/tester'
})
expect(launch.cwd).toBe('/home/tester')
expect(launch.shell).toBe('/usr/bin/fish')
expect(launch.env).toMatchObject({
SHELL: '/usr/bin/fish',
LANG: 'zh_CN.UTF-8'
})
expect(launch.env).not.toHaveProperty('GOODBUDDY_MODEL_API_KEY')
expect(launch.env).not.toHaveProperty('ANTHROPIC_API_KEY')
})
it('filters product and model credentials without dropping normal variables', () => {
expect(
buildLocalTerminalEnvironment({
PATH: '/bin',
CUSTOM_TOKEN: 'user-owned',
FACTORY_API_KEY: 'internal',
OPENAI_API_KEY: 'model'
})
).toEqual({
PATH: '/bin',
CUSTOM_TOKEN: 'user-owned'
})
})
})
describe('LocalTerminalSession', () => {
it('spawns the PTY and preserves ordered state, output, exit events', async () => {
const pty = new FakePty()
const dependencies = sessionDependencies(pty)
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies
})
const events: TerminalEvent[] = []
session.onEvent((event) => events.push(event))
pty.emitData('你好')
pty.emitExit(7, 15)
expect(dependencies.spawn).toHaveBeenCalledWith(
'/bin/bash',
[],
expect.objectContaining({
cols: 80,
rows: 24,
cwd: '/home/tester',
name: 'xterm-256color'
})
)
expect(events.map((event) => event.type)).toEqual([
'state',
'state',
'output',
'exit',
'state'
])
expect(events.map((event) => event.sequence)).toEqual([
1, 2, 3, 4, 5
])
expect(session.snapshot()).toMatchObject({
state: 'exited',
exit: { exitCode: 7, signal: '15' },
lastSequence: 5
})
})
it('forwards writes and deduplicates resize while running', async () => {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty)
})
expect(session.write('echo ok\r')).toBe(true)
expect(session.resize({ cols: 80, rows: 24 })).toBe(true)
expect(session.resize({ cols: 120, rows: 40 })).toBe(true)
expect(pty.write).toHaveBeenCalledWith('echo ok\r')
expect(pty.resize).toHaveBeenCalledTimes(1)
expect(pty.resize).toHaveBeenCalledWith(120, 40)
expect(session.snapshot().size).toEqual({
cols: 120,
rows: 40
})
})
it('splits output events at the UTF-8 boundary', async () => {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty)
})
const output: string[] = []
session.onEvent((event) => {
if (event.type === 'output') {
output.push(event.data)
}
})
const data =
'a'.repeat(TERMINAL_LIMITS.maximumEventBytes - 1) + '你'
pty.emitData(data)
expect(output.join('')).toBe(data)
expect(
output.every(
(chunk) =>
Buffer.byteLength(chunk) <=
TERMINAL_LIMITS.maximumEventBytes
)
).toBe(true)
})
it('pauses at the pending-event boundary and resumes after ACK', async () => {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty)
})
let lastSequence = 0
session.onEvent((event) => {
lastSequence = event.sequence
})
for (
let index = 0;
index < TERMINAL_LIMITS.maximumPendingEvents;
index += 1
) {
pty.emitData('x')
if (pty.pause.mock.calls.length > 0) {
break
}
}
expect(pty.pause).toHaveBeenCalledTimes(1)
session.acknowledge(lastSequence)
expect(pty.resume).toHaveBeenCalledTimes(1)
})
it('reports launch failures with stable failed state', async () => {
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: {
platform: 'linux',
environment: { SHELL: '/missing' },
executableExists: async () => false,
directoryExists: async () => true,
homeDirectory: () => '/home/tester'
}
})
const events: TerminalEvent[] = []
session.onEvent((event) => events.push(event))
expect(session.snapshot()).toMatchObject({
state: 'failed',
error: { code: 'launch-failed', retryable: true }
})
expect(events.map((event) => event.type)).toEqual([
'state',
'error',
'state'
])
expect(session.write('ignored')).toBe(false)
})
it('closes once, rejects late operations, and keeps a stable exit', async () => {
const pty = new FakePty()
const dependencies = sessionDependencies(pty)
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies
})
const firstClose = session.close()
const secondClose = session.close()
expect(secondClose).toBe(firstClose)
await firstClose
expect(dependencies.terminate).toHaveBeenCalledTimes(1)
expect(session.write('late')).toBe(false)
expect(session.resize({ cols: 90, rows: 30 })).toBe(false)
expect(session.snapshot()).toMatchObject({
state: 'exited',
exit: { exitCode: 0, signal: null }
})
await session.close()
expect(dependencies.terminate).toHaveBeenCalledTimes(1)
})
})
+672
View File
@@ -0,0 +1,672 @@
import { randomUUID } from 'node:crypto'
import { constants as fileSystemConstants } from 'node:fs'
import { access, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { posix, win32 } from 'node:path'
import * as nodePty from 'node-pty'
import type { IDisposable, IPty } from 'node-pty'
import {
TERMINAL_LIMITS,
type TerminalError,
type TerminalEvent,
type TerminalExit,
type TerminalSize,
type TerminalSnapshot,
type TerminalTarget
} from '../../shared/terminal-contracts'
import { requestProcessTreeTermination } from '../agent/child-process-termination'
import { runtimeProviderEnvironmentNames } from '../agent/process-environment'
export type LocalTerminalTarget = Extract<
TerminalTarget,
{ type: 'local' | 'project' }
>
export type LocalTerminalLaunch = {
cwd: string
shell: string
shellLabel: string
env: NodeJS.ProcessEnv
}
export type LocalPtySpawn = (
file: string,
args: string[],
options: nodePty.IPtyForkOptions
) => IPty
export type LocalTerminalDependencies = {
platform?: NodeJS.Platform
environment?: NodeJS.ProcessEnv
homeDirectory?: () => string
executableExists?: (path: string) => Promise<boolean>
directoryExists?: (path: string) => Promise<boolean>
spawn?: LocalPtySpawn
terminate?: (pty: IPty, platform: NodeJS.Platform) => Promise<void>
}
export type LocalTerminalSessionOptions = {
sessionId?: string
target: LocalTerminalTarget
targetLabel: string
title: string
size: TerminalSize
projectDirectory?: string
dependencies?: LocalTerminalDependencies
}
export type LocalTerminalEventListener = (event: TerminalEvent) => void
type UnsequencedTerminalEvent<T> = T extends TerminalEvent
? Omit<T, 'sessionId' | 'sequence'>
: never
type LocalTerminalEvent = UnsequencedTerminalEvent<TerminalEvent>
const CONTROL_EVENT_RESERVE = 2
const PAUSE_PENDING_EVENT_COUNT =
TERMINAL_LIMITS.maximumPendingEvents - CONTROL_EVENT_RESERVE
const RESUME_PENDING_EVENT_COUNT = Math.floor(
PAUSE_PENDING_EVENT_COUNT / 2
)
const CLOSE_WAIT_MS = 1_500
const FORCE_WAIT_MS = 250
const credentialEnvironmentNames = new Set([
...runtimeProviderEnvironmentNames,
'DEEPSEEK_API_KEY',
'DASHSCOPE_API_KEY',
'TOGETHER_API_KEY',
'FIREWORKS_API_KEY',
'PERPLEXITY_API_KEY',
'CEREBRAS_API_KEY',
'CLAUDE_CODE_OAUTH_TOKEN'
])
function isCredentialEnvironmentName(name: string): boolean {
const upperName = name.toUpperCase()
return (
upperName.startsWith('GOODBUDDY_') ||
upperName.startsWith('FACTORY_') ||
credentialEnvironmentNames.has(upperName)
)
}
/**
* Keep the user's normal interactive environment while removing credentials
* inherited by Electron Main for GoodBuddy and model-provider operation.
*/
export function buildLocalTerminalEnvironment(
source: NodeJS.ProcessEnv = process.env
): NodeJS.ProcessEnv {
const environment: NodeJS.ProcessEnv = {}
for (const [name, value] of Object.entries(source)) {
if (value !== undefined && !isCredentialEnvironmentName(name)) {
environment[name] = value
}
}
return environment
}
async function defaultExecutableExists(path: string): Promise<boolean> {
try {
await access(
path,
process.platform === 'win32'
? fileSystemConstants.F_OK
: fileSystemConstants.X_OK
)
return true
} catch {
return false
}
}
async function defaultDirectoryExists(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory()
} catch {
return false
}
}
async function findExecutableOnPath(
name: string,
environment: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
exists: (path: string) => Promise<boolean>
): Promise<string | undefined> {
const pathApi = platform === 'win32' ? win32 : posix
if (pathApi.isAbsolute(name)) {
return (await exists(name)) ? name : undefined
}
const pathValue =
environment.PATH ?? environment.Path ?? environment.path ?? ''
const extensions =
platform === 'win32'
? (environment.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
.split(';')
.filter(Boolean)
: ['']
const pathDelimiter = platform === 'win32' ? ';' : ':'
for (const directory of pathValue.split(pathDelimiter).filter(Boolean)) {
for (const extension of extensions) {
const candidate = pathApi.join(directory, `${name}${extension}`)
if (await exists(candidate)) {
return candidate
}
}
}
return undefined
}
export async function resolveLocalTerminalShell(
dependencies: LocalTerminalDependencies = {}
): Promise<{ shell: string; shellLabel: string }> {
const platform = dependencies.platform ?? process.platform
const environment = dependencies.environment ?? process.env
const exists = dependencies.executableExists ?? defaultExecutableExists
let shell: string | undefined
if (platform === 'win32') {
shell =
(await findExecutableOnPath(
'pwsh',
environment,
platform,
exists
)) ??
(await findExecutableOnPath(
'powershell',
environment,
platform,
exists
))
if (!shell) {
const comspec = environment.COMSPEC?.trim()
shell = comspec
? await findExecutableOnPath(
comspec,
environment,
platform,
exists
)
: undefined
}
} else {
const configuredShell = environment.SHELL?.trim()
if (
configuredShell &&
posix.isAbsolute(configuredShell) &&
(await exists(configuredShell))
) {
shell = configuredShell
} else {
const fallbacks =
platform === 'darwin'
? ['/bin/zsh', '/bin/bash', '/bin/sh']
: ['/bin/bash', '/bin/sh']
for (const candidate of fallbacks) {
if (await exists(candidate)) {
shell = candidate
break
}
}
}
}
if (!shell) {
throw new Error('No supported interactive shell is available')
}
return {
shell,
shellLabel:
platform === 'win32'
? win32.basename(shell)
: posix.basename(shell)
}
}
export async function resolveLocalTerminalWorkingDirectory(
projectDirectory: string | undefined,
dependencies: LocalTerminalDependencies = {}
): Promise<string> {
const isDirectory =
dependencies.directoryExists ?? defaultDirectoryExists
const home = (dependencies.homeDirectory ?? homedir)()
if (
projectDirectory?.trim() &&
(await isDirectory(projectDirectory))
) {
return projectDirectory
}
if (await isDirectory(home)) {
return home
}
throw new Error('No valid local terminal working directory is available')
}
export async function resolveLocalTerminalLaunch(
projectDirectory: string | undefined,
dependencies: LocalTerminalDependencies = {}
): Promise<LocalTerminalLaunch> {
const [shell, cwd] = await Promise.all([
resolveLocalTerminalShell(dependencies),
resolveLocalTerminalWorkingDirectory(
projectDirectory,
dependencies
)
])
return {
...shell,
cwd,
env: buildLocalTerminalEnvironment(
dependencies.environment ?? process.env
)
}
}
function splitBoundedOutput(data: string): string[] {
const encoded = Buffer.from(data)
const chunks: string[] = []
let offset = 0
while (offset < encoded.byteLength) {
let end = Math.min(
offset + TERMINAL_LIMITS.maximumEventBytes,
encoded.byteLength
)
if (end < encoded.byteLength) {
while (
end > offset &&
(encoded[end]! & 0xc0) === 0x80
) {
end -= 1
}
}
if (end === offset) {
end = Math.min(offset + 1, encoded.byteLength)
}
chunks.push(encoded.subarray(offset, end).toString('utf8'))
offset = end
}
return chunks
}
async function waitUntil(
predicate: () => boolean,
milliseconds: number
): Promise<void> {
if (predicate()) {
return
}
await new Promise<void>((resolve) => {
const finish = (): void => {
clearInterval(interval)
clearTimeout(timeout)
resolve()
}
const interval = setInterval(() => {
if (predicate()) {
finish()
}
}, 10)
const timeout = setTimeout(finish, milliseconds)
interval.unref?.()
timeout.unref?.()
})
}
async function defaultTerminate(
pty: IPty,
platform: NodeJS.Platform
): Promise<void> {
let exited = false
const exitSubscription = pty.onExit(() => {
exited = true
})
requestProcessTreeTermination(
{
pid: pty.pid,
killed: false,
exitCode: null,
kill: (signal) =>
platform === 'win32' ? pty.kill() : pty.kill(signal)
},
{
platform,
processGroup: platform !== 'win32',
waitMs: CLOSE_WAIT_MS
}
)
await waitUntil(() => exited, CLOSE_WAIT_MS)
if (!exited) {
requestProcessTreeTermination(
{
pid: pty.pid,
killed: false,
exitCode: null,
kill: () =>
platform === 'win32'
? pty.kill()
: pty.kill('SIGKILL')
},
{
platform,
processGroup: platform !== 'win32',
signal: 'SIGKILL',
waitMs: FORCE_WAIT_MS
}
)
await waitUntil(() => exited, FORCE_WAIT_MS)
}
exitSubscription.dispose()
}
export class LocalTerminalSession {
static async create(
options: LocalTerminalSessionOptions
): Promise<LocalTerminalSession> {
const session = new LocalTerminalSession(options)
await session.start()
return session
}
readonly sessionId: string
private readonly options: LocalTerminalSessionOptions
private readonly dependencies: LocalTerminalDependencies
private readonly listeners = new Set<LocalTerminalEventListener>()
private readonly pendingEvents: TerminalEvent[] = []
private readonly subscriptions: IDisposable[] = []
private pty: IPty | undefined
private state: TerminalSnapshot['state'] = 'starting'
private launch: LocalTerminalLaunch | undefined
private size: TerminalSize
private lastSequence = 0
private pendingOutputBytes = 0
private paused = false
private exit: TerminalExit | null = null
private error: TerminalError | null = null
private closePromise: Promise<TerminalSnapshot> | undefined
private constructor(options: LocalTerminalSessionOptions) {
this.options = options
this.dependencies = options.dependencies ?? {}
this.sessionId = options.sessionId ?? randomUUID()
this.size = { ...options.size }
this.emit({ type: 'state', state: 'starting' })
}
private async start(): Promise<void> {
try {
this.launch = await resolveLocalTerminalLaunch(
this.options.target.type === 'project'
? this.options.projectDirectory
: undefined,
this.dependencies
)
const spawn = this.dependencies.spawn ?? nodePty.spawn
this.pty = spawn(this.launch.shell, [], {
name: 'xterm-256color',
cols: this.size.cols,
rows: this.size.rows,
cwd: this.launch.cwd,
env: this.launch.env
})
this.subscriptions.push(
this.pty.onData((data) => this.handleOutput(data)),
this.pty.onExit((result) =>
this.handleExit(result.exitCode, result.signal)
)
)
this.state = 'running'
this.emit({ type: 'state', state: 'running' })
} catch (cause) {
this.fail(
'launch-failed',
cause instanceof Error
? cause.message
: 'Failed to launch the local terminal'
)
}
}
snapshot(): TerminalSnapshot {
const launch = this.launch
return {
sessionId: this.sessionId,
target: this.options.target,
targetLabel: this.options.targetLabel,
title: this.options.title,
state: this.state,
shell: launch?.shellLabel ?? '未知',
workingDirectory:
launch?.cwd ??
this.options.projectDirectory ??
(this.dependencies.homeDirectory ?? homedir)(),
size: { ...this.size },
lastSequence: this.lastSequence,
exit: this.exit,
error: this.error
}
}
onEvent(listener: LocalTerminalEventListener): () => void {
this.listeners.add(listener)
for (const event of this.pendingEvents) {
listener(event)
}
return () => {
this.listeners.delete(listener)
}
}
write(data: string): boolean {
if (this.state !== 'running' || !this.pty) {
return false
}
this.pty.write(data)
return true
}
resize(size: TerminalSize): boolean {
if (this.state !== 'running' || !this.pty) {
return false
}
if (
this.size.cols === size.cols &&
this.size.rows === size.rows
) {
return true
}
this.pty.resize(size.cols, size.rows)
this.size = { ...size }
return true
}
acknowledge(sequence: number): void {
while (
this.pendingEvents[0] &&
this.pendingEvents[0].sequence <= sequence
) {
const event = this.pendingEvents.shift()
if (event?.type === 'output') {
this.pendingOutputBytes -= Buffer.byteLength(event.data)
}
}
if (
this.paused &&
this.pendingEvents.length <= RESUME_PENDING_EVENT_COUNT &&
this.pendingOutputBytes <=
TERMINAL_LIMITS.maximumBufferedOutputBytes / 2
) {
this.paused = false
this.pty?.resume()
}
}
close(): Promise<TerminalSnapshot> {
if (this.closePromise) {
return this.closePromise
}
this.closePromise = this.closeInternal()
return this.closePromise
}
private async closeInternal(): Promise<TerminalSnapshot> {
if (
this.state === 'exited' ||
this.state === 'interrupted' ||
(this.state === 'failed' && !this.pty)
) {
return this.snapshot()
}
const preserveFailedState = this.state === 'failed'
if (!preserveFailedState) {
this.state = 'closing'
this.emit({ type: 'state', state: 'closing' })
}
const pty = this.pty
if (pty) {
try {
await (
this.dependencies.terminate ?? defaultTerminate
)(pty, this.dependencies.platform ?? process.platform)
} catch (cause) {
this.error = {
code: 'internal-error',
message: this.boundErrorMessage(cause),
retryable: false
}
this.emit({ type: 'error', error: this.error })
} finally {
if (preserveFailedState) {
this.disposeSubscriptions()
}
}
}
if (this.state === 'closing') {
this.handleExit(null, 'SIGTERM')
}
return this.snapshot()
}
private handleOutput(data: string): void {
if (this.state !== 'running' || data.length === 0) {
return
}
for (const chunk of splitBoundedOutput(data)) {
if (
this.pendingEvents.length >= PAUSE_PENDING_EVENT_COUNT ||
this.pendingOutputBytes + Buffer.byteLength(chunk) >
TERMINAL_LIMITS.maximumBufferedOutputBytes
) {
this.fail(
'output-limit-exceeded',
'Terminal output exceeded the bounded Main process buffer'
)
void this.close()
return
}
this.pendingOutputBytes += Buffer.byteLength(chunk)
this.emit({ type: 'output', data: chunk })
this.updateBackpressure()
}
}
private updateBackpressure(): void {
if (
!this.paused &&
(this.pendingEvents.length >= PAUSE_PENDING_EVENT_COUNT ||
this.pendingOutputBytes >=
TERMINAL_LIMITS.maximumBufferedOutputBytes)
) {
this.paused = true
this.pty?.pause()
}
}
private handleExit(
exitCode: number | null,
signal?: number | string
): void {
if (this.state === 'exited' || this.state === 'failed') {
return
}
this.disposeSubscriptions()
this.exit = {
exitCode:
typeof exitCode === 'number' &&
Number.isSafeInteger(exitCode)
? exitCode
: null,
signal:
signal === undefined || signal === null
? null
: String(signal)
}
if (this.exit.exitCode === null && this.exit.signal === null) {
this.exit.signal = 'unknown'
}
this.state = 'exited'
this.emit({ type: 'exit', exit: this.exit })
this.emit({ type: 'state', state: 'exited' })
}
private fail(
code: TerminalError['code'],
message: string
): void {
if (this.state === 'failed' || this.state === 'exited') {
return
}
this.error = {
code,
message: Buffer.from(message)
.subarray(0, TERMINAL_LIMITS.maximumErrorMessageBytes)
.toString('utf8') || 'Local terminal failed',
retryable: code === 'launch-failed'
}
this.state = 'failed'
this.emit({ type: 'error', error: this.error })
this.emit({ type: 'state', state: 'failed' })
}
private emit(
event: LocalTerminalEvent
): void {
if (this.lastSequence >= Number.MAX_SAFE_INTEGER) {
this.state = 'failed'
this.error = {
code: 'internal-error',
message: 'Terminal event sequence was exhausted',
retryable: false
}
return
}
const sequenced = {
...event,
sessionId: this.sessionId,
sequence: ++this.lastSequence
} as TerminalEvent
this.pendingEvents.push(sequenced)
for (const listener of this.listeners) {
listener(sequenced)
}
}
private boundErrorMessage(cause: unknown): string {
const message =
cause instanceof Error
? cause.message
: 'Failed to close the local terminal'
return (
Buffer.from(message)
.subarray(0, TERMINAL_LIMITS.maximumErrorMessageBytes)
.toString('utf8') || 'Failed to close the local terminal'
)
}
private disposeSubscriptions(): void {
for (const subscription of this.subscriptions.splice(0)) {
subscription.dispose()
}
}
}
@@ -0,0 +1,271 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import type {
AuthenticatedSshConnection,
SshConnectionPoolTarget,
SshPoolConnectionIdentity,
SshTerminalShellOptions,
} from "../ssh/ssh-connection-pool";
import { SshConnectionPool } from "../ssh/ssh-connection-pool";
import {
createSshTerminalSession,
sshTerminalInternals,
} from "./ssh-terminal-session";
const sessionId = "00000000-0000-4000-8000-000000000501";
const hostId = "00000000-0000-4000-8000-000000000502";
const projectId = "00000000-0000-4000-8000-000000000503";
function createTarget(): SshConnectionPoolTarget {
return {
host: {
id: hostId,
name: "Build host",
hostname: "build.example.com",
port: 22,
username: "builder",
authentication: "password",
password: "private password",
hostKey: {
algorithm: "ssh-ed25519",
publicKeyBase64: "pinned",
fingerprintSha256: `SHA256:${"A".repeat(43)}`,
generation: 3,
},
},
hostRevision: 7,
hostKeyGeneration: 3,
};
}
function createChannel() {
const emitter = new EventEmitter();
return Object.assign(emitter, {
write: vi.fn(() => true),
setWindow: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
close: vi.fn(),
destroy: vi.fn(),
});
}
function createHarness(idleTimeoutMs = 60_000) {
const channel = createChannel();
let usable = true;
const disconnectListeners = new Set<(error?: Error) => void>();
let shellOptions: SshTerminalShellOptions | undefined;
const identity: SshPoolConnectionIdentity = {
hostId,
hostRevision: 7,
hostKeyGeneration: 3,
authenticationIdentity: "a".repeat(64),
};
const connection = {
identity,
isUsable: vi.fn(() => usable),
onDisconnect: vi.fn((listener: (error?: Error) => void) => {
disconnectListeners.add(listener);
return () => disconnectListeners.delete(listener);
}),
openTerminalShell: vi.fn(
async (options: SshTerminalShellOptions) => {
shellOptions = options;
return channel as never;
},
),
openAgentAttach: vi.fn(),
runAgentDoctor: vi.fn(),
runAgentBootstrapProbe: vi.fn(),
probeRemotePackageBootstrap: vi.fn(),
createRemotePackageUploadStaging: vi.fn(),
prepareRemotePackageBootstrap: vi.fn(),
prepareUploadedRemotePackageBootstrap: vi.fn(),
commitRemotePackageBootstrap: vi.fn(),
cleanupRemotePackageBootstrap: vi.fn(),
runAgentLifecycleAction: vi.fn(),
runAgentRuntimeAction: vi.fn(),
openStagedSftp: vi.fn(),
listDirectories: vi.fn(),
dispose: vi.fn(() => {
usable = false;
}),
} satisfies AuthenticatedSshConnection;
const pool = new SshConnectionPool(async () => connection, {
idleTimeoutMs,
});
const events: Array<{
type: string;
sequence: number;
[key: string]: unknown;
}> = [];
return {
channel,
connection,
pool,
events,
shellOptions: () => shellOptions,
disconnect(error?: Error) {
usable = false;
for (const listener of [...disconnectListeners]) {
listener(error);
}
},
};
}
async function openSession(harness: ReturnType<typeof createHarness>) {
return createSshTerminalSession(harness.pool, {
sessionId,
target: { type: "project", projectId },
targetLabel: "Build host",
title: "终端 · Build host",
shell: "/bin/bash",
workingDirectory: "/srv/team's app",
size: { cols: 120, rows: 40 },
poolTarget: createTarget(),
onEvent: (event) => harness.events.push(event),
});
}
describe("SSH terminal session", () => {
it("holds a pool lease and opens a login shell with the requested PTY and quoted cwd", async () => {
vi.useFakeTimers();
try {
const harness = createHarness(100);
const session = await openSession(harness);
expect(harness.shellOptions()).toEqual({
cols: 120,
rows: 40,
term: "xterm-256color",
});
expect(harness.channel.write).toHaveBeenCalledWith(
"cd -- '/srv/team'\"'\"'s app' || exit\r",
);
await vi.advanceTimersByTimeAsync(1_000);
expect(harness.connection.dispose).not.toHaveBeenCalled();
expect(session.close()).toMatchObject({
state: "exited",
exit: { exitCode: null, signal: "SIGHUP" },
});
expect(session.close()).toMatchObject({ state: "exited" });
expect(harness.channel.close).toHaveBeenCalledOnce();
expect(harness.channel.destroy).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(100);
expect(harness.connection.dispose).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("forwards writes and resizes while running and returns an authoritative snapshot", async () => {
const harness = createHarness();
const session = await openSession(harness);
session.write("printf '你好'\\r");
session.resize({ cols: 90, rows: 28 });
expect(harness.channel.write).toHaveBeenLastCalledWith(
"printf '你好'\\r",
);
expect(harness.channel.setWindow).toHaveBeenCalledWith(28, 90, 0, 0);
expect(session.snapshot()).toMatchObject({
sessionId,
state: "running",
shell: "/bin/bash",
workingDirectory: "/srv/team's app",
size: { cols: 90, rows: 28 },
lastSequence: 1,
});
session.close();
});
it("emits ordered bounded output and pauses until acknowledged", async () => {
const harness = createHarness();
const session = await openSession(harness);
for (let index = 0; index < 255; index += 1) {
harness.channel.emit("data", `chunk-${index}`);
}
expect(harness.events).toHaveLength(256);
expect(harness.events.map((event) => event.sequence)).toEqual(
Array.from({ length: 256 }, (_, index) => index + 1),
);
expect(harness.channel.pause).toHaveBeenCalledOnce();
session.acknowledge(256);
expect(harness.channel.resume).toHaveBeenCalledOnce();
session.close();
});
it("marks a disconnected SSH transport interrupted immediately and rejects late I/O", async () => {
const harness = createHarness();
const session = await openSession(harness);
harness.disconnect(new Error("socket reset"));
expect(session.snapshot()).toMatchObject({
state: "interrupted",
error: {
code: "interrupted",
message: "socket reset",
retryable: true,
},
});
expect(harness.events.at(-1)).toMatchObject({
type: "state",
state: "interrupted",
});
const writes = harness.channel.write.mock.calls.length;
session.write("late");
session.resize({ cols: 80, rows: 24 });
expect(harness.channel.write).toHaveBeenCalledTimes(writes);
expect(harness.channel.setWindow).not.toHaveBeenCalled();
session.close();
});
it("records normal shell exit before releasing the channel lease", async () => {
const harness = createHarness();
const session = await openSession(harness);
harness.channel.emit("exit", 7);
harness.channel.emit("close");
expect(session.snapshot()).toMatchObject({
state: "exited",
exit: { exitCode: 7, signal: null },
});
expect(harness.events.slice(-2)).toMatchObject([
{ type: "exit", exit: { exitCode: 7, signal: null } },
{ type: "state", state: "exited" },
]);
session.close();
});
it("strictly quotes POSIX paths and rejects line-based command injection", () => {
expect(sshTerminalInternals.quotePosixShellArgument("a'b")).toBe(
"'a'\"'\"'b'",
);
expect(() =>
sshTerminalInternals.quotePosixShellArgument("/srv/app\nwhoami"),
).toThrow("无效字符");
});
it("opens a Host-home shell without injecting a cwd command", async () => {
const harness = createHarness();
const session = await createSshTerminalSession(harness.pool, {
sessionId,
target: { type: "project", projectId },
targetLabel: "Build host",
title: "终端 · Build host",
size: { cols: 80, rows: 24 },
poolTarget: createTarget(),
onEvent: (event) => harness.events.push(event),
});
expect(harness.channel.write).not.toHaveBeenCalled();
expect(session.snapshot().workingDirectory).toBe("~");
session.close();
});
});
+316
View File
@@ -0,0 +1,316 @@
import { StringDecoder } from "node:string_decoder";
import type { ClientChannel } from "ssh2";
import {
TERMINAL_LIMITS,
terminalSizeSchema,
type TerminalError,
type TerminalEvent,
type TerminalExit,
type TerminalSessionId,
type TerminalSize,
type TerminalSnapshot,
type TerminalTarget,
} from "../../shared/terminal-contracts";
import {
type SshConnectionPool,
type SshConnectionPoolTarget,
type SshTerminalConnectionLease,
} from "../ssh/ssh-connection-pool";
const SSH_TERMINAL_TERM = "xterm-256color";
export type SshTerminalSessionOptions = {
sessionId: TerminalSessionId;
target: TerminalTarget;
targetLabel: string;
title: string;
shell?: string;
workingDirectory?: string;
size: TerminalSize;
poolTarget: SshConnectionPoolTarget;
onEvent: (event: TerminalEvent) => void;
signal?: AbortSignal;
};
export type SshTerminalSession = {
write(data: string): void;
resize(size: TerminalSize): void;
acknowledge(sequence: number): void;
snapshot(): TerminalSnapshot;
close(): TerminalSnapshot;
};
type RunningSession = {
lease: SshTerminalConnectionLease;
channel: ClientChannel;
removeDisconnectListener: () => void;
};
type TerminalEventPayload =
| { type: "output"; data: string }
| { type: "state"; state: TerminalSnapshot["state"] }
| { type: "exit"; exit: TerminalExit }
| { type: "error"; error: TerminalError };
function quotePosixShellArgument(value: string): string {
if (value.includes("\0") || value.includes("\r") || value.includes("\n")) {
throw new Error("远程工作目录包含无效字符");
}
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
function eventByteLength(event: TerminalEvent): number {
switch (event.type) {
case "output":
return Buffer.byteLength(event.data);
case "error":
return Buffer.byteLength(event.error.message) + 64;
default:
return 64;
}
}
function splitUtf8(text: string, maximumBytes: number): string[] {
const chunks: string[] = [];
let chunk = "";
let chunkBytes = 0;
for (const character of text) {
const characterBytes = Buffer.byteLength(character);
if (chunk && chunkBytes + characterBytes > maximumBytes) {
chunks.push(chunk);
chunk = "";
chunkBytes = 0;
}
chunk += character;
chunkBytes += characterBytes;
}
if (chunk) {
chunks.push(chunk);
}
return chunks;
}
export async function createSshTerminalSession(
pool: SshConnectionPool,
options: SshTerminalSessionOptions,
): Promise<SshTerminalSession> {
const initialSize = terminalSizeSchema.parse(options.size);
const workingDirectoryCommand = options.workingDirectory
? `cd -- ${quotePosixShellArgument(options.workingDirectory)} || exit\r`
: undefined;
const lease = await pool.acquireTerminal(options.poolTarget, options.signal);
let channel: ClientChannel;
try {
channel = await lease.openTerminalShell(
{
cols: initialSize.cols,
rows: initialSize.rows,
term: SSH_TERMINAL_TERM,
},
options.signal,
);
} catch (error) {
lease.release();
throw error;
}
let state: TerminalSnapshot["state"] = "running";
let size = initialSize;
let sequence = 0;
let exit: TerminalExit | null = null;
let error: TerminalError | null = null;
let closed = false;
let paused = false;
let pendingBytes = 0;
const pendingEvents: Array<{ event: TerminalEvent; bytes: number }> = [];
const decoder = new StringDecoder("utf8");
let running: RunningSession | undefined;
const emit = (event: TerminalEventPayload): void => {
sequence += 1;
const sequenced = {
...event,
sessionId: options.sessionId,
sequence,
} as TerminalEvent;
const bytes = eventByteLength(sequenced);
pendingEvents.push({ event: sequenced, bytes });
pendingBytes += bytes;
options.onEvent(sequenced);
if (
!paused &&
(pendingEvents.length >= TERMINAL_LIMITS.maximumPendingEvents ||
pendingBytes >=
TERMINAL_LIMITS.maximumBufferedOutputBytes -
TERMINAL_LIMITS.maximumEventBytes)
) {
paused = true;
channel.pause();
}
};
const release = (): void => {
const active = running;
running = undefined;
active?.removeDisconnectListener();
active?.lease.release();
};
const interrupt = (disconnectError?: Error): void => {
if (closed || state !== "running") {
return;
}
state = "interrupted";
error = {
code: "interrupted",
message: disconnectError?.message || "SSH 连接已中断",
retryable: true,
};
emit({ type: "error", error });
emit({ type: "state", state });
release();
channel.destroy();
};
const removeDisconnectListener = lease.onDisconnect(interrupt);
running = { lease, channel, removeDisconnectListener };
channel.on("data", (chunk: Buffer | string) => {
if (closed || state !== "running") {
return;
}
const text =
typeof chunk === "string" ? chunk : decoder.write(Buffer.from(chunk));
for (const data of splitUtf8(
text,
TERMINAL_LIMITS.maximumEventBytes,
)) {
emit({ type: "output", data });
}
});
channel.once(
"exit",
(code: number | null, signal?: string) => {
if (closed || state !== "running") {
return;
}
exit =
typeof code === "number"
? { exitCode: code, signal: null }
: { exitCode: null, signal: signal || "UNKNOWN" };
},
);
channel.once("error", (channelError: Error) => {
interrupt(channelError);
});
channel.once("close", () => {
if (closed || state !== "running") {
release();
return;
}
const finalText = decoder.end();
if (finalText) {
for (const data of splitUtf8(
finalText,
TERMINAL_LIMITS.maximumEventBytes,
)) {
emit({ type: "output", data });
}
}
if (!lease.isUsable()) {
interrupt();
return;
}
state = "exited";
exit ??= { exitCode: 0, signal: null };
emit({ type: "exit", exit });
emit({ type: "state", state });
release();
});
if (workingDirectoryCommand) {
channel.write(workingDirectoryCommand);
}
emit({ type: "state", state });
const currentSnapshot = (): TerminalSnapshot => ({
sessionId: options.sessionId,
target: options.target,
targetLabel: options.targetLabel,
title: options.title,
state,
shell: options.shell ?? "login shell",
workingDirectory: options.workingDirectory || "~",
size,
lastSequence: sequence,
exit,
error,
});
return {
write(data) {
if (closed || state !== "running") {
return;
}
channel.write(data);
},
resize(nextSize) {
if (closed || state !== "running") {
return;
}
size = terminalSizeSchema.parse(nextSize);
channel.setWindow(size.rows, size.cols, 0, 0);
},
acknowledge(acknowledgedSequence) {
if (
!Number.isSafeInteger(acknowledgedSequence) ||
acknowledgedSequence < 0
) {
throw new Error("终端输出确认序号无效");
}
while (
pendingEvents[0] &&
pendingEvents[0].event.sequence <= acknowledgedSequence
) {
pendingBytes -= pendingEvents.shift()?.bytes ?? 0;
}
if (
paused &&
pendingEvents.length < TERMINAL_LIMITS.maximumPendingEvents &&
pendingBytes <
TERMINAL_LIMITS.maximumBufferedOutputBytes -
TERMINAL_LIMITS.maximumEventBytes
) {
paused = false;
channel.resume();
}
},
snapshot() {
return currentSnapshot();
},
close() {
if (closed) {
return currentSnapshot();
}
closed = true;
if (state === "running") {
state = "closing";
emit({ type: "state", state });
exit = { exitCode: null, signal: "SIGHUP" };
}
release();
channel.close();
channel.destroy();
if (state === "closing") {
state = "exited";
emit({ type: "exit", exit: exit! });
emit({ type: "state", state });
}
return currentSnapshot();
},
};
}
export const sshTerminalInternals = {
quotePosixShellArgument,
};
@@ -0,0 +1,353 @@
import { describe, expect, it, vi } from 'vitest'
import {
TERMINAL_LIMITS,
type TerminalEvent,
type TerminalSnapshot,
type TerminalTarget
} from '../../shared/terminal-contracts'
import {
TerminalSessionManager,
TerminalSessionManagerError,
type ManagedTerminalSession,
type TerminalSessionManagerDependencies
} from './terminal-session-manager'
const projectId = '00000000-0000-4000-8000-000000000601'
const hostId = '00000000-0000-4000-8000-000000000602'
function snapshot(
sessionId: string,
target: TerminalTarget,
workingDirectory = '/home/tester'
): TerminalSnapshot {
return {
sessionId,
target,
targetLabel: target.type === 'local' ? '本机' : 'Build host',
title: '终端',
state: 'running',
shell: 'bash',
workingDirectory,
size: { cols: 80, rows: 24 },
lastSequence: 1,
exit: null,
error: null
}
}
function fakeSession(
value: TerminalSnapshot,
initialEvents: TerminalEvent[] = []
): ManagedTerminalSession & {
emit(event: TerminalEvent): void
close: ReturnType<typeof vi.fn>
write: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
} {
let current = value
const listeners = new Set<(event: TerminalEvent) => void>()
return {
snapshot: () => current,
write: vi.fn(() => true),
resize: vi.fn(() => true),
acknowledge: vi.fn(),
close: vi.fn(async () => {
current = { ...current, state: 'closing' }
return current
}),
onEvent(listener) {
listeners.add(listener)
initialEvents.forEach(listener)
return () => listeners.delete(listener)
},
emit(event) {
for (const listener of listeners) {
listener(event)
}
}
}
}
function harness(
overrides: Partial<TerminalSessionManagerDependencies> = {}
) {
let nextId = 700
const delivered: Array<{ ownerId: number; event: TerminalEvent }> = []
const localSessions: ReturnType<typeof fakeSession>[] = []
const sshSessions: ReturnType<typeof fakeSession>[] = []
const createLocalSession = vi.fn(async (options) => {
const event = {
sessionId: options.sessionId!,
sequence: 1,
type: 'output' as const,
data: 'prompt'
}
const session = fakeSession(
snapshot(
options.sessionId!,
options.target,
options.projectDirectory ?? '/home/tester'
),
[event]
)
localSessions.push(session)
return session
})
const createSshSession = vi.fn(async (_pool, options) => {
options.onEvent({
sessionId: options.sessionId,
sequence: 1,
type: 'output',
data: 'remote prompt'
})
const session = fakeSession(
snapshot(
options.sessionId,
options.target,
options.workingDirectory
)
)
sshSessions.push(session)
return session
})
const dependencies = {
database: {
getProject: vi.fn(() => ({
id: projectId,
name: 'Workspace',
rootPath: 'legacy',
executionSpace: { kind: 'local', rootPath: '/authoritative/root' }
}))
},
executionSpaceResolver: {
resolveProject: vi.fn((project: {
executionSpace: { kind: 'local'; rootPath: string }
}) => ({
kind: 'local' as const,
rootPath: project.executionSpace.rootPath,
cacheIdentity: 'local',
routeIdentity: 'local',
workspaceAccess: {}
}))
},
targetResolver: {
resolve: vi.fn(async () => ({
host: { id: hostId, name: 'Build host' },
hostRevision: 2,
hostKeyGeneration: 3
}))
},
sshPool: {} as never,
remoteEnabled: vi.fn(() => true),
deliverEvent: vi.fn((ownerId, event) => {
delivered.push({ ownerId, event })
}),
createLocalSession,
createSshSession,
createSessionId: () =>
`00000000-0000-4000-8000-${String(nextId++).padStart(12, '0')}`,
...overrides
} as unknown as TerminalSessionManagerDependencies
return {
manager: new TerminalSessionManager(dependencies),
dependencies,
delivered,
localSessions,
sshSessions,
createLocalSession,
createSshSession
}
}
describe('TerminalSessionManager routing and delivery', () => {
it('routes Home local and authoritative local project roots', async () => {
const test = harness()
const local = await test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})
const project = await test.manager.create(1, {
target: { type: 'project', projectId },
cols: 90,
rows: 30
})
expect(local.workingDirectory).toBe('/home/tester')
expect(project.workingDirectory).toBe('/authoritative/root')
expect(test.createLocalSession.mock.calls[0]?.[0].projectDirectory)
.toBeUndefined()
expect(test.createLocalSession.mock.calls[1]?.[0].projectDirectory)
.toBe('/authoritative/root')
})
it('routes SSH projects to their authoritative remote root', async () => {
const test = harness()
vi.mocked(test.dependencies.executionSpaceResolver.resolveProject)
.mockReturnValue({
kind: 'ssh',
hostId,
remoteRootPath: '/srv/project',
cacheIdentity: 'ssh',
routeIdentity: 'ssh',
workspaceAccess: {} as never
})
const remote = await test.manager.create(1, {
target: { type: 'project', projectId },
cols: 80,
rows: 24
})
expect(test.createSshSession.mock.calls[0]?.[1].workingDirectory)
.toBe('/srv/project')
expect(remote.workingDirectory).toBe('/srv/project')
expect(test.dependencies.targetResolver.resolve).toHaveBeenCalledWith(hostId)
})
it('gates all remote targets', async () => {
const test = harness({ remoteEnabled: () => false })
vi.mocked(test.dependencies.executionSpaceResolver.resolveProject)
.mockReturnValue({
kind: 'ssh',
hostId,
remoteRootPath: '/srv/project',
cacheIdentity: 'ssh',
routeIdentity: 'ssh',
workspaceAccess: {} as never
})
await expect(test.manager.create(1, {
target: { type: 'project', projectId },
cols: 80,
rows: 24
})).rejects.toMatchObject({ code: 'target-unavailable' })
expect(test.createSshSession).not.toHaveBeenCalled()
})
it('delays startup events until delivery is explicitly enabled', async () => {
const test = harness()
const created = await test.manager.create(7, {
target: { type: 'local' },
cols: 80,
rows: 24
})
expect(test.delivered).toEqual([])
test.manager.enableEventDelivery(7, created.sessionId)
expect(test.delivered).toEqual([
{
ownerId: 7,
event: expect.objectContaining({ data: 'prompt', sequence: 1 })
}
])
})
})
describe('TerminalSessionManager ownership and lifecycle', () => {
it('revalidates ownership for every session operation', async () => {
const test = harness()
const created = await test.manager.create(11, {
target: { type: 'local' },
cols: 80,
rows: 24
})
const calls = [
() => test.manager.write(12, created.sessionId, 'x'),
() => test.manager.resize(12, created.sessionId, { cols: 90, rows: 30 }),
() => test.manager.snapshot(12, created.sessionId),
() => test.manager.acknowledge(12, created.sessionId, 1),
() => test.manager.close(12, created.sessionId),
() => test.manager.enableEventDelivery(12, created.sessionId)
]
for (const call of calls) {
expect(call).toThrowError(
expect.objectContaining({ code: 'session-not-found' })
)
}
expect(test.localSessions[0]?.write).not.toHaveBeenCalled()
})
it('enforces the per-window limit without affecting another owner', async () => {
const test = harness()
for (let index = 0;
index < TERMINAL_LIMITS.maximumSessionsPerWindow;
index += 1) {
await test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})
}
await expect(test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})).rejects.toBeInstanceOf(TerminalSessionManagerError)
await expect(test.manager.create(2, {
target: { type: 'local' },
cols: 80,
rows: 24
})).resolves.toBeDefined()
})
it('releases closed records and ignores late delivery ACKs', async () => {
const test = harness()
const created = await test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})
const first = await test.manager.close(1, created.sessionId)
expect(first.state).toBe('closing')
expect(test.localSessions[0]?.close).toHaveBeenCalledTimes(1)
expect(() =>
test.manager.acknowledge(1, created.sessionId, 1)
).not.toThrow()
expect(() => test.manager.close(1, created.sessionId))
.toThrowError(expect.objectContaining({ code: 'session-not-found' }))
expect(() => test.manager.write(1, created.sessionId, 'late'))
.toThrowError(expect.objectContaining({ code: 'session-not-found' }))
expect(() => test.manager.resize(
1,
created.sessionId,
{ cols: 100, rows: 40 }
)).toThrowError(expect.objectContaining({
code: 'session-not-found'
}))
await expect(test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})).resolves.toBeDefined()
})
it('closes only one owner and disposes all sessions idempotently', async () => {
const test = harness()
const first = await test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})
const second = await test.manager.create(2, {
target: { type: 'local' },
cols: 80,
rows: 24
})
await test.manager.closeOwner(1)
expect(() => test.manager.snapshot(1, first.sessionId))
.toThrowError(expect.objectContaining({ code: 'session-not-found' }))
expect(test.manager.snapshot(2, second.sessionId).state).toBe('running')
await Promise.all([test.manager.dispose(), test.manager.dispose()])
expect(test.localSessions[0]?.close).toHaveBeenCalledTimes(1)
expect(test.localSessions[1]?.close).toHaveBeenCalledTimes(1)
await expect(test.manager.create(2, {
target: { type: 'local' },
cols: 80,
rows: 24
})).rejects.toMatchObject({ code: 'internal-error' })
})
})
@@ -0,0 +1,479 @@
import { randomUUID } from 'node:crypto'
import {
TERMINAL_LIMITS,
type TerminalCreateRequest,
type TerminalErrorCode,
type TerminalEvent,
type TerminalSize,
type TerminalSnapshot
} from '../../shared/terminal-contracts'
import type { AssistantDatabase } from '../assistant/assistant-database'
import {
type ExecutionSpaceDescriptor,
type ExecutionSpaceResolver
} from '../execution-space/execution-space-resolver'
import type { RemoteAgentTargetResolver } from '../remote-agent/remote-agent-connection-manager'
import type {
SshConnectionPool,
SshConnectionPoolTarget
} from '../ssh/ssh-connection-pool'
import {
LocalTerminalSession,
type LocalTerminalSessionOptions
} from './local-terminal-session'
import {
createSshTerminalSession,
type SshTerminalSession,
type SshTerminalSessionOptions
} from './ssh-terminal-session'
export class TerminalSessionManagerError extends Error {
constructor(
readonly code: TerminalErrorCode,
message: string
) {
super(message)
this.name = 'TerminalSessionManagerError'
}
}
export type ManagedTerminalSession = {
snapshot(): TerminalSnapshot
write(data: string): boolean | void
resize(size: TerminalSize): boolean | void
acknowledge(sequence: number): void
close(): void | TerminalSnapshot | Promise<void | TerminalSnapshot>
onEvent?: (listener: (event: TerminalEvent) => void) => () => void
}
export type TerminalSessionManagerDependencies = {
database: Pick<AssistantDatabase, 'getProject'>
executionSpaceResolver: Pick<ExecutionSpaceResolver, 'resolveProject'>
targetResolver: RemoteAgentTargetResolver
sshPool: SshConnectionPool
remoteEnabled: () => boolean | Promise<boolean>
deliverEvent: (ownerWebContentsId: number, event: TerminalEvent) => void
createLocalSession?: (
options: LocalTerminalSessionOptions
) => Promise<ManagedTerminalSession>
createSshSession?: (
pool: SshConnectionPool,
options: SshTerminalSessionOptions
) => Promise<ManagedTerminalSession>
createSessionId?: () => string
}
type SessionRecord = {
ownerId: number
sessionId: string
deliveryEnabled: boolean
delayedEvents: TerminalEvent[]
closing: boolean
session?: ManagedTerminalSession
ready: Promise<ManagedTerminalSession>
closePromise?: Promise<TerminalSnapshot>
removeEventListener?: () => void
}
type ResolvedLaunch =
| {
kind: 'local'
targetLabel: string
projectDirectory?: string
}
| {
kind: 'ssh'
targetLabel: string
workingDirectory: string
poolTarget: SshConnectionPoolTarget
}
/**
* Owns ephemeral terminal sessions. Session identifiers are generated in Main
* and every operation is scoped to the creating WebContents identifier.
*/
export class TerminalSessionManager {
private readonly records = new Map<string, SessionRecord>()
private disposePromise: Promise<void> | undefined
private disposed = false
constructor(
private readonly dependencies: TerminalSessionManagerDependencies
) {}
async create(
ownerWebContentsId: number,
request: TerminalCreateRequest
): Promise<TerminalSnapshot> {
this.assertOwnerId(ownerWebContentsId)
if (this.disposed) {
throw new TerminalSessionManagerError(
'internal-error',
'终端会话管理器已关闭'
)
}
if (this.ownerSessionCount(ownerWebContentsId) >=
TERMINAL_LIMITS.maximumSessionsPerWindow) {
throw new TerminalSessionManagerError(
'session-limit-reached',
`每个窗口最多可同时打开 ${TERMINAL_LIMITS.maximumSessionsPerWindow} 个终端`
)
}
const sessionId =
this.dependencies.createSessionId?.() ?? randomUUID()
const record: SessionRecord = {
ownerId: ownerWebContentsId,
sessionId,
deliveryEnabled: false,
delayedEvents: [],
closing: false,
ready: Promise.resolve(undefined as never)
}
this.records.set(sessionId, record)
record.ready = this.createSession(record, request)
try {
const session = await record.ready
if (record.closing) {
return await this.closeRecord(record)
}
return session.snapshot()
} catch (error) {
if (this.records.get(sessionId) === record) {
this.records.delete(sessionId)
}
throw error
}
}
/**
* Enables event forwarding after the create response has crossed IPC. Events
* produced while a PTY/channel was starting are retained and delivered first.
*/
enableEventDelivery(
ownerWebContentsId: number,
sessionId: string
): void {
const record = this.requireOwned(ownerWebContentsId, sessionId)
if (record.deliveryEnabled) {
return
}
record.deliveryEnabled = true
for (const event of record.delayedEvents.splice(0)) {
this.deliver(record, event)
}
}
write(
ownerWebContentsId: number,
sessionId: string,
data: string
): boolean {
const record = this.requireOwned(ownerWebContentsId, sessionId)
const session = this.requireRunning(record)
return session.write(data) !== false
}
resize(
ownerWebContentsId: number,
sessionId: string,
size: TerminalSize
): boolean {
const record = this.requireOwned(ownerWebContentsId, sessionId)
const session = this.requireRunning(record)
return session.resize(size) !== false
}
snapshot(
ownerWebContentsId: number,
sessionId: string
): TerminalSnapshot {
const record = this.requireOwned(ownerWebContentsId, sessionId)
if (!record.session) {
throw new TerminalSessionManagerError(
'session-not-running',
'终端会话仍在启动'
)
}
return record.session.snapshot()
}
acknowledge(
ownerWebContentsId: number,
sessionId: string,
sequence: number
): void {
this.assertOwnerId(ownerWebContentsId)
const record = this.records.get(sessionId)
if (!record) {
// ACKs already queued in the renderer may arrive after a successful
// close. They are delivery bookkeeping, so treating them as complete is
// both safe and necessary for a race-free close path.
return
}
if (record.ownerId !== ownerWebContentsId) {
throw new TerminalSessionManagerError(
'session-not-found',
'终端会话不存在'
)
}
if (!record.session || record.closing) {
return
}
record.session.acknowledge(sequence)
}
close(
ownerWebContentsId: number,
sessionId: string
): Promise<TerminalSnapshot> {
const record = this.requireOwned(ownerWebContentsId, sessionId)
return this.closeRecord(record)
}
async closeOwner(ownerWebContentsId: number): Promise<void> {
this.assertOwnerId(ownerWebContentsId)
const records = [...this.records.values()].filter(
(record) => record.ownerId === ownerWebContentsId
)
await Promise.allSettled(records.map((record) => this.closeRecord(record)))
for (const record of records) {
record.removeEventListener?.()
if (this.records.get(record.sessionId) === record) {
this.records.delete(record.sessionId)
}
}
}
dispose(): Promise<void> {
this.disposePromise ??= this.disposeInternal()
return this.disposePromise
}
private async disposeInternal(): Promise<void> {
this.disposed = true
const records = [...this.records.values()]
await Promise.allSettled(records.map((record) => this.closeRecord(record)))
for (const record of records) {
record.removeEventListener?.()
}
this.records.clear()
}
private async createSession(
record: SessionRecord,
request: TerminalCreateRequest
): Promise<ManagedTerminalSession> {
const launch = await this.resolveLaunch(request)
const common = {
sessionId: record.sessionId,
target: request.target,
targetLabel: launch.targetLabel,
title: `终端 · ${launch.targetLabel}`,
size: { cols: request.cols, rows: request.rows }
}
let session: ManagedTerminalSession
if (launch.kind === 'local') {
session = await (
this.dependencies.createLocalSession ??
((options) => LocalTerminalSession.create(options))
)({
...common,
target: request.target as Extract<
TerminalCreateRequest['target'],
{ type: 'local' | 'project' }
>,
projectDirectory: launch.projectDirectory
})
record.session = session
record.removeEventListener = session.onEvent?.((event) =>
this.handleEvent(record, event)
)
} else {
session = await (
this.dependencies.createSshSession ??
((pool, options) =>
createSshTerminalSession(pool, options) as Promise<SshTerminalSession>)
)(this.dependencies.sshPool, {
...common,
workingDirectory: launch.workingDirectory,
poolTarget: launch.poolTarget,
onEvent: (event) => this.handleEvent(record, event)
})
record.session = session
}
return session
}
private async resolveLaunch(
request: TerminalCreateRequest
): Promise<ResolvedLaunch> {
if (request.target.type === 'local') {
return { kind: 'local', targetLabel: '本机' }
}
let project: ReturnType<AssistantDatabase['getProject']>
try {
project = this.dependencies.database.getProject(
request.target.projectId
)
} catch (error) {
throw this.resolutionError('target-not-found', error, '项目不存在')
}
let executionSpace: ExecutionSpaceDescriptor
try {
executionSpace =
this.dependencies.executionSpaceResolver.resolveProject(project)
} catch (error) {
throw this.resolutionError(
'target-unavailable',
error,
'项目执行空间不可用'
)
}
if (executionSpace.kind === 'local') {
return {
kind: 'local',
targetLabel: project.name,
projectDirectory: executionSpace.rootPath
}
}
await this.assertRemoteEnabled()
const poolTarget = await this.resolveTarget(executionSpace.hostId)
return {
kind: 'ssh',
targetLabel: poolTarget.host.name,
workingDirectory: executionSpace.remoteRootPath,
poolTarget
}
}
private async resolveTarget(
hostId: string
): Promise<SshConnectionPoolTarget> {
try {
return await this.dependencies.targetResolver.resolve(hostId)
} catch (error) {
throw this.resolutionError(
'target-unavailable',
error,
'SSH 主机不可用'
)
}
}
private async assertRemoteEnabled(): Promise<void> {
if (!(await this.dependencies.remoteEnabled())) {
throw new TerminalSessionManagerError(
'target-unavailable',
'远程终端当前未启用'
)
}
}
private handleEvent(record: SessionRecord, event: TerminalEvent): void {
if (this.records.get(record.sessionId) !== record) {
return
}
if (!record.deliveryEnabled) {
record.delayedEvents.push(event)
return
}
this.deliver(record, event)
}
private deliver(record: SessionRecord, event: TerminalEvent): void {
try {
this.dependencies.deliverEvent(record.ownerId, event)
} catch {
// A destroyed renderer is cleaned up by closeOwner; it must not break
// PTY/SSH event handling while that close is being scheduled.
}
}
private requireRunning(record: SessionRecord): ManagedTerminalSession {
const session = record.session
if (
!session ||
record.closing ||
session.snapshot().state !== 'running'
) {
throw new TerminalSessionManagerError(
'session-not-running',
'终端会话未在运行'
)
}
return session
}
private closeRecord(record: SessionRecord): Promise<TerminalSnapshot> {
record.closing = true
record.closePromise ??= (async () => {
try {
const session = await record.ready
const closed = await session.close()
const snapshot =
closed && typeof closed === 'object' && 'sessionId' in closed
? closed
: session.snapshot()
record.removeEventListener?.()
record.removeEventListener = undefined
record.delayedEvents.length = 0
if (this.records.get(record.sessionId) === record) {
this.records.delete(record.sessionId)
}
return snapshot
} catch (error) {
record.closing = false
record.closePromise = undefined
throw error
}
})()
return record.closePromise
}
private requireOwned(
ownerWebContentsId: number,
sessionId: string
): SessionRecord {
this.assertOwnerId(ownerWebContentsId)
const record = this.records.get(sessionId)
if (!record || record.ownerId !== ownerWebContentsId) {
throw new TerminalSessionManagerError(
'session-not-found',
'终端会话不存在'
)
}
return record
}
private ownerSessionCount(ownerId: number): number {
let count = 0
for (const record of this.records.values()) {
if (record.ownerId === ownerId && !record.closing) {
count += 1
}
}
return count
}
private assertOwnerId(ownerId: number): void {
if (!Number.isSafeInteger(ownerId) || ownerId < 0) {
throw new TypeError('无效的终端窗口标识')
}
}
private resolutionError(
code: TerminalErrorCode,
cause: unknown,
fallback: string
): TerminalSessionManagerError {
return new TerminalSessionManagerError(
code,
cause instanceof Error && cause.message
? cause.message
: fallback
)
}
}