Allow per-profile custom SSH TERM (#11480)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: x06579 <x06579@ai-dashboard>
Co-authored-by: Eugene <inbox@null.page>
This commit is contained in:
H-TTTTT
2026-09-11 22:03:05 +02:00
committed by GitHub
co-authored by Sisyphus x06579 Eugene
parent 7a44e6bb27
commit a8abcc0be6
12 changed files with 147 additions and 10 deletions
+3
View File
@@ -25,6 +25,9 @@ jobs:
rm app/node_modules/.yarn-integrity
yarn
- name: Test SSH TERM
run: yarn run test:ssh-term
- name: Build typings
run: yarn run build:typings
+1
View File
@@ -95,6 +95,7 @@
"start:prod": "electron app --debug",
"prod": "cross-env TABBY_DEV=1 electron app",
"docs": "node scripts/build-docs.mjs",
"test:ssh-term": "node --experimental-strip-types --test tabby-ssh/test/*.test.ts",
"lint": "eslint --ext ts */src */lib",
"postinstall": "patch-package && node ./scripts/install-deps.mjs && node ./scripts/build-native.mjs",
"i18n:pull": "crowdin pull --skip-untranslated-strings",
+1
View File
@@ -37,6 +37,7 @@ export interface SSHProfileOptions extends LoginScriptsOptions {
httpProxyPort: number | null
reuseSession: boolean
input: InputProcessingOptions,
term?: string
}
export enum PortForwardType {
@@ -228,6 +228,16 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
.description Multiplex multiple shells through the same connection
toggle([(ngModel)]='profile.options.reuseSession')
.form-line
.header
.title(translate) Terminal type
.description(translate) Value of $TERM reported to the remote host (e.g. xterm-256color, vt100)
input.form-control(
type='text',
placeholder='xterm-256color',
[(ngModel)]='profile.options.term',
)
.form-line
.header
.title(translate) Keep Alive Interval (Milliseconds)
+1
View File
@@ -44,6 +44,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile>
httpProxyPort: null,
reuseSession: true,
input: { backspace: 'backspace' },
term: 'xterm-256color',
},
clearServiceMessagesOnConnect: true,
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { Injector } from '@angular/core'
import { LogService } from 'tabby-core'
import { BaseSession, UTF8SplitterMiddleware, InputProcessor } from 'tabby-terminal'
import { SSHSession } from './ssh'
import { openShellChannelForProfile } from './shellChannel'
import { SSHProfile } from '../api'
import * as russh from 'russh'
@@ -40,7 +41,7 @@ export class SSHShellSession extends BaseSession {
this.logger.debug('Opening shell')
try {
this.shell = await this.ssh.openShellChannel({ x11: this.profile.options.x11 })
this.shell = await openShellChannelForProfile(this.ssh, this.profile)
} catch (err) {
if (err.toString().includes('Unable to request X11')) {
this.emitServiceMessage(' Make sure `xauth` is installed on the remote side')
+42
View File
@@ -0,0 +1,42 @@
import type { Channel } from 'russh'
export const DEFAULT_SSH_TERMINAL_TYPE = 'xterm-256color'
export interface SSHShellChannelOptions {
x11: boolean
term: string | null | undefined
}
interface SSHShellProfile {
options: {
x11: boolean
term?: string | null
}
}
interface SSHShellChannelOpener<T> {
openShellChannel: (options: SSHShellChannelOptions) => Promise<T>
}
export function resolveSSHTerminalType (term: unknown): string {
if (typeof term !== 'string') {
return DEFAULT_SSH_TERMINAL_TYPE
}
return term.trim() || DEFAULT_SSH_TERMINAL_TYPE
}
export function openShellChannelForProfile<T> (ssh: SSHShellChannelOpener<T>, profile: SSHShellProfile): Promise<T> {
return ssh.openShellChannel({
x11: profile.options.x11,
term: profile.options.term,
})
}
export function requestShellPTY (channel: Pick<Channel, 'requestPTY'>, options: Pick<SSHShellChannelOptions, 'term'>): Promise<void> {
return channel.requestPTY(resolveSSHTerminalType(options.term), {
columns: 80,
rows: 24,
pixHeight: 0,
pixWidth: 0,
})
}
+3 -7
View File
@@ -16,6 +16,7 @@ import { SSHAlgorithmType, SSHProfile, AutoPrivateKeyLocator, PortForwardType }
import { ForwardedPort } from './forwards'
import { X11Socket } from './x11'
import { supportedAlgorithms } from '../algorithms'
import { requestShellPTY, SSHShellChannelOptions } from './shellChannel'
import * as russh from 'russh'
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
@@ -852,17 +853,12 @@ export class SSHSession {
this.ssh.disconnect()
}
async openShellChannel (options: { x11: boolean }): Promise<russh.Channel> {
async openShellChannel (options: SSHShellChannelOptions): Promise<russh.Channel> {
if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
throw new Error('Cannot open shell channel before auth')
}
const ch = await this.ssh.activateChannel(await this.ssh.openSessionChannel())
await ch.requestPTY('xterm-256color', {
columns: 80,
rows: 24,
pixHeight: 0,
pixWidth: 0,
})
await requestShellPTY(ch, options)
if (options.x11) {
await ch.requestX11Forwarding({
singleConnection: false,
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { DEFAULT_SSH_TERMINAL_TYPE, openShellChannelForProfile, requestShellPTY } from '../src/session/shellChannel.ts'
import type { SSHShellChannelOptions } from '../src/session/shellChannel.ts'
class SharedSSHSessionAdapter {
readonly channelOptions: SSHShellChannelOptions[] = []
readonly requestedTerms: string[] = []
async openShellChannel (options: SSHShellChannelOptions): Promise<void> {
this.channelOptions.push(options)
await requestShellPTY({
requestPTY: async term => {
this.requestedTerms.push(term)
},
}, options)
}
}
describe('shared SSH session shell channels', () => {
it('uses each shell profile TERM and applies the blank fallback', async () => {
const sharedSession = new SharedSSHSessionAdapter()
await openShellChannelForProfile(sharedSession, { options: { x11: false, term: ' vt100 ' } })
await openShellChannelForProfile(sharedSession, { options: { x11: true, term: 'xterm' } })
await openShellChannelForProfile(sharedSession, { options: { x11: false, term: ' ' } })
assert.deepEqual(sharedSession.channelOptions, [
{ x11: false, term: ' vt100 ' },
{ x11: true, term: 'xterm' },
{ x11: false, term: ' ' },
])
assert.deepEqual(sharedSession.requestedTerms, [
'vt100',
'xterm',
'xterm-256color',
])
})
it('falls back for a malformed persisted TERM value', async () => {
const sharedSession = new SharedSSHSessionAdapter()
const profile = { options: { x11: false, term: 256 } } as unknown as Parameters<typeof openShellChannelForProfile>[1]
await openShellChannelForProfile(sharedSession, profile)
assert.deepEqual(sharedSession.requestedTerms, [DEFAULT_SSH_TERMINAL_TYPE])
})
})
+34
View File
@@ -0,0 +1,34 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { DEFAULT_SSH_TERMINAL_TYPE, resolveSSHTerminalType } from '../src/session/shellChannel.ts'
describe('resolveSSHTerminalType', () => {
it('returns the custom terminal type when set', () => {
assert.equal(resolveSSHTerminalType('vt100'), 'vt100')
assert.equal(resolveSSHTerminalType('xterm'), 'xterm')
assert.equal(resolveSSHTerminalType('linux'), 'linux')
})
it('falls back to xterm-256color for blank or whitespace-only values', () => {
assert.equal(resolveSSHTerminalType(''), DEFAULT_SSH_TERMINAL_TYPE)
assert.equal(resolveSSHTerminalType(' '), DEFAULT_SSH_TERMINAL_TYPE)
assert.equal(resolveSSHTerminalType('\t\n'), DEFAULT_SSH_TERMINAL_TYPE)
})
it('falls back to xterm-256color when missing', () => {
assert.equal(resolveSSHTerminalType(undefined), DEFAULT_SSH_TERMINAL_TYPE)
assert.equal(resolveSSHTerminalType(null), DEFAULT_SSH_TERMINAL_TYPE)
assert.equal(DEFAULT_SSH_TERMINAL_TYPE, 'xterm-256color')
})
it('falls back to xterm-256color for non-string persisted values', () => {
assert.equal(resolveSSHTerminalType(256), DEFAULT_SSH_TERMINAL_TYPE)
assert.equal(resolveSSHTerminalType(false), DEFAULT_SSH_TERMINAL_TYPE)
assert.equal(resolveSSHTerminalType({ terminal: 'vt100' }), DEFAULT_SSH_TERMINAL_TYPE)
})
it('trims surrounding whitespace from a custom value', () => {
assert.equal(resolveSSHTerminalType(' vt100 '), 'vt100')
assert.equal(resolveSSHTerminalType('\txterm-color\n'), 'xterm-color')
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"extends": "../tsconfig.json",
"exclude": ["node_modules", "dist", "typings"],
"exclude": ["node_modules", "dist", "typings", "test"],
"compilerOptions": {
"baseUrl": "src"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"extends": "../tsconfig.json",
"exclude": ["node_modules", "dist", "typings"],
"exclude": ["node_modules", "dist", "typings", "test"],
"compilerOptions": {
"baseUrl": "src",
"emitDeclarationOnly": true,