diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eaef042ad..5e49d7293 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/package.json b/package.json index d0ae183c6..dc7ebd2e9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tabby-ssh/src/api/interfaces.ts b/tabby-ssh/src/api/interfaces.ts index ab66c4ea3..3666b0986 100644 --- a/tabby-ssh/src/api/interfaces.ts +++ b/tabby-ssh/src/api/interfaces.ts @@ -37,6 +37,7 @@ export interface SSHProfileOptions extends LoginScriptsOptions { httpProxyPort: number | null reuseSession: boolean input: InputProcessingOptions, + term?: string } export enum PortForwardType { diff --git a/tabby-ssh/src/components/sshProfileSettings.component.pug b/tabby-ssh/src/components/sshProfileSettings.component.pug index 60a2bb7d9..85e429dd8 100644 --- a/tabby-ssh/src/components/sshProfileSettings.component.pug +++ b/tabby-ssh/src/components/sshProfileSettings.component.pug @@ -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) diff --git a/tabby-ssh/src/profiles.ts b/tabby-ssh/src/profiles.ts index 4512f8d61..3bf151d83 100644 --- a/tabby-ssh/src/profiles.ts +++ b/tabby-ssh/src/profiles.ts @@ -44,6 +44,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider httpProxyPort: null, reuseSession: true, input: { backspace: 'backspace' }, + term: 'xterm-256color', }, clearServiceMessagesOnConnect: true, } diff --git a/tabby-ssh/src/session/shell.ts b/tabby-ssh/src/session/shell.ts index aa98008c1..6cfb6948f 100644 --- a/tabby-ssh/src/session/shell.ts +++ b/tabby-ssh/src/session/shell.ts @@ -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') diff --git a/tabby-ssh/src/session/shellChannel.ts b/tabby-ssh/src/session/shellChannel.ts new file mode 100644 index 000000000..3e1b542b9 --- /dev/null +++ b/tabby-ssh/src/session/shellChannel.ts @@ -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 { + openShellChannel: (options: SSHShellChannelOptions) => Promise +} + +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 (ssh: SSHShellChannelOpener, profile: SSHShellProfile): Promise { + return ssh.openShellChannel({ + x11: profile.options.x11, + term: profile.options.term, + }) +} + +export function requestShellPTY (channel: Pick, options: Pick): Promise { + return channel.requestPTY(resolveSSHTerminalType(options.term), { + columns: 80, + rows: 24, + pixHeight: 0, + pixWidth: 0, + }) +} diff --git a/tabby-ssh/src/session/ssh.ts b/tabby-ssh/src/session/ssh.ts index 4cdcc97c5..3511a8f26 100644 --- a/tabby-ssh/src/session/ssh.ts +++ b/tabby-ssh/src/session/ssh.ts @@ -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 { + async openShellChannel (options: SSHShellChannelOptions): Promise { 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, diff --git a/tabby-ssh/test/shellChannel.test.ts b/tabby-ssh/test/shellChannel.test.ts new file mode 100644 index 000000000..7f42c0864 --- /dev/null +++ b/tabby-ssh/test/shellChannel.test.ts @@ -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 { + 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[1] + + await openShellChannelForProfile(sharedSession, profile) + + assert.deepEqual(sharedSession.requestedTerms, [DEFAULT_SSH_TERMINAL_TYPE]) + }) +}) diff --git a/tabby-ssh/test/terminalType.test.ts b/tabby-ssh/test/terminalType.test.ts new file mode 100644 index 000000000..9162b98fd --- /dev/null +++ b/tabby-ssh/test/terminalType.test.ts @@ -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') + }) +}) diff --git a/tabby-ssh/tsconfig.json b/tabby-ssh/tsconfig.json index a863b98a8..a683cd528 100644 --- a/tabby-ssh/tsconfig.json +++ b/tabby-ssh/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../tsconfig.json", - "exclude": ["node_modules", "dist", "typings"], + "exclude": ["node_modules", "dist", "typings", "test"], "compilerOptions": { "baseUrl": "src" } diff --git a/tabby-ssh/tsconfig.typings.json b/tabby-ssh/tsconfig.typings.json index 9188bdf79..4be784978 100644 --- a/tabby-ssh/tsconfig.typings.json +++ b/tabby-ssh/tsconfig.typings.json @@ -1,6 +1,6 @@ { "extends": "../tsconfig.json", - "exclude": ["node_modules", "dist", "typings"], + "exclude": ["node_modules", "dist", "typings", "test"], "compilerOptions": { "baseUrl": "src", "emitDeclarationOnly": true,