mirror of
https://github.com/eugeny/tabby
synced 2026-09-25 06:26:10 +00:00
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:
co-authored by
Sisyphus
x06579
Eugene
parent
7a44e6bb27
commit
a8abcc0be6
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -44,6 +44,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile>
|
||||
httpProxyPort: null,
|
||||
reuseSession: true,
|
||||
input: { backspace: 'backspace' },
|
||||
term: 'xterm-256color',
|
||||
},
|
||||
clearServiceMessagesOnConnect: true,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "typings"],
|
||||
"exclude": ["node_modules", "dist", "typings", "test"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "typings"],
|
||||
"exclude": ["node_modules", "dist", "typings", "test"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"emitDeclarationOnly": true,
|
||||
|
||||
Reference in New Issue
Block a user