From 02a68e7541c31b77492c4d8951acca3c57684ae3 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2022 22:06:15 +0530 Subject: [PATCH] Pass the value of shell_integration in the tarfile Allows per host overrides and also avoids needing to read kitty.conf in the ssh kitten --- kittens/ssh/main.py | 169 +++++------------------------ kitty/shell_integration.py | 13 ++- kitty_tests/ssh.py | 7 +- shell-integration/ssh/bootstrap.sh | 24 ++-- 4 files changed, 58 insertions(+), 155 deletions(-) diff --git a/kittens/ssh/main.py b/kittens/ssh/main.py index e152feda6..d84a2c600 100644 --- a/kittens/ssh/main.py +++ b/kittens/ssh/main.py @@ -6,14 +6,15 @@ import io import os import re import shlex -import subprocess import sys import tarfile import tempfile +import time from contextlib import suppress -from typing import Dict, Iterator, List, NoReturn, Optional, Set, Tuple +from typing import Dict, Iterator, List, NoReturn, Optional, Set, Tuple, Union from kitty.constants import cache_dir, shell_integration_dir, terminfo_dir +from kitty.shell_integration import get_effective_ksi_env_var from kitty.short_uuid import uuid4 from kitty.utils import SSHConnectionData @@ -24,17 +25,32 @@ DEFAULT_SHELL_INTEGRATION_DEST = '.local/share/kitty-ssh-kitten/shell-integratio def make_tarfile(hostname: str = '', shell_integration_dest: str = DEFAULT_SHELL_INTEGRATION_DEST) -> bytes: - def filter_files(tarinfo: tarfile.TarInfo) -> Optional[tarfile.TarInfo]: - if tarinfo.name.endswith('ssh/bootstrap.sh') or tarinfo.name.endswith('ssh/bootstrap.py'): - return None + def normalize_tarinfo(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo: tarinfo.uname = tarinfo.gname = 'kitty' tarinfo.uid = tarinfo.gid = 0 return tarinfo + def add_data_as_file(tf: tarfile.TarFile, arcname: str, data: Union[str, bytes]) -> tarfile.TarInfo: + ans = tarfile.TarInfo(arcname) + ans.mtime = int(time.time()) + ans.type = tarfile.REGTYPE + if isinstance(data, str): + data = data.encode('utf-8') + ans.size = len(data) + normalize_tarinfo(ans) + tf.addfile(ans, io.BytesIO(data)) + return ans + + def filter_files(tarinfo: tarfile.TarInfo) -> Optional[tarfile.TarInfo]: + if tarinfo.name.endswith('ssh/bootstrap.sh') or tarinfo.name.endswith('ssh/bootstrap.py'): + return None + return normalize_tarinfo(tarinfo) + buf = io.BytesIO() with tarfile.open(mode='w:bz2', fileobj=buf, encoding='utf-8') as tf: tf.add(shell_integration_dir, arcname=shell_integration_dest, filter=filter_files) tf.add(terminfo_dir, arcname='.terminfo', filter=filter_files) + add_data_as_file(tf, shell_integration_dest.rstrip('/') + '/settings/ksi_env_var', get_effective_ksi_env_var()) return buf.getvalue() @@ -77,7 +93,6 @@ def prepare_script(ans: str, replacements: Dict[str, str]) -> str: for k in ('EXEC_CMD', 'OVERRIDE_LOGIN_SHELL'): replacements[k] = replacements.get(k, '') replacements['SHELL_INTEGRATION_DIR'] = replacements.get('SHELL_INTEGRATION_DIR', DEFAULT_SHELL_INTEGRATION_DEST) - replacements['SHELL_INTEGRATION_VALUE'] = replacements.get('SHELL_INTEGRATION_VALUE', 'enabled') def sub(m: 're.Match[str]') -> str: return replacements[m.group()] @@ -91,129 +106,8 @@ def bootstrap_script(script_type: str = 'sh', **replacements: str) -> str: return prepare_script(ans, replacements) -SHELL_SCRIPT = '''\ -#!/bin/sh -# macOS ships with an ancient version of tic that cannot read from stdin, so we -# create a temp file for it -tmp=$(mktemp) -cat >$tmp << 'TERMEOF' -TERMINFO -TERMEOF - -tname=.terminfo -if [ -e "/usr/share/misc/terminfo.cdb" ]; then - # NetBSD requires this see https://github.com/kovidgoyal/kitty/issues/4622 - tname=".terminfo.cdb" -fi -tic_out=$(tic -x -o $HOME/$tname $tmp 2>&1) -rc=$? -rm $tmp -if [ "$rc" != "0" ]; then echo "$tic_out"; exit 1; fi -if [ -z "$USER" ]; then export USER=$(whoami); fi -export TERMINFO="$HOME/$tname" -login_shell="" -python="" - -login_shell_is_ok() { - if [ -z "$login_shell" ] || [ ! -x "$login_shell" ]; then return 1; fi - case "$login_shell" in - *sh) return 0; - esac - return 1; -} - -detect_python() { - python=$(command -v python3) - if [ -z "$python" ]; then python=$(command -v python2); fi - if [ -z "$python" ]; then python=python; fi -} - -using_getent() { - cmd=$(command -v getent) - if [ -z "$cmd" ]; then return; fi - output=$($cmd passwd $USER 2>/dev/null) - if [ $? = 0 ]; then login_shell=$(echo $output | cut -d: -f7); fi -} - -using_id() { - cmd=$(command -v id) - if [ -z "$cmd" ]; then return; fi - output=$($cmd -P $USER 2>/dev/null) - if [ $? = 0 ]; then login_shell=$(echo $output | cut -d: -f7); fi -} - -using_passwd() { - cmd=$(command -v grep) - if [ -z "$cmd" ]; then return; fi - output=$($cmd "^$USER:" /etc/passwd 2>/dev/null) - if [ $? = 0 ]; then login_shell=$(echo $output | cut -d: -f7); fi -} - -using_python() { - detect_python - if [ ! -x "$python" ]; then return; fi - output=$($python -c "import pwd, os; print(pwd.getpwuid(os.geteuid()).pw_shell)") - if [ $? = 0 ]; then login_shell=$output; fi -} - -execute_with_python() { - detect_python - exec $python -c "import os; os.execl('$login_shell', '-' '$shell_name')" -} - -die() { echo "$*" 1>&2 ; exit 1; } - -using_getent -if ! login_shell_is_ok; then using_id; fi -if ! login_shell_is_ok; then using_python; fi -if ! login_shell_is_ok; then using_passwd; fi -if ! login_shell_is_ok; then die "Could not detect login shell"; fi - - -# If a command was passed to SSH execute it here -EXEC_CMD - -# We need to pass the first argument to the executed program with a leading - -# to make sure the shell executes as a login shell. Note that not all shells -# support exec -a so we use the below to try to detect such shells -shell_name=$(basename $login_shell) -if [ -z "$PIPESTATUS" ]; then - # the dash shell does not support exec -a and also does not define PIPESTATUS - execute_with_python -fi -exec -a "-$shell_name" $login_shell -''' - - -PYTHON_SCRIPT = '''\ -#!/usr/bin/env python -from __future__ import print_function -from tempfile import NamedTemporaryFile -import subprocess, os, sys, pwd, binascii, json - -# macOS ships with an ancient version of tic that cannot read from stdin, so we -# create a temp file for it -with NamedTemporaryFile() as tmp: - tname = '.terminfo' - if os.path.exists('/usr/share/misc/terminfo.cdb'): - tname += '.cdb' - tmp.write(binascii.unhexlify('{terminfo}')) - tmp.flush() - p = subprocess.Popen(['tic', '-x', '-o', os.path.expanduser('~/' + tname), tmp.name], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - stdout, stderr = p.communicate() - if p.wait() != 0: - getattr(sys.stderr, 'buffer', sys.stderr).write(stdout + stderr) - raise SystemExit('Failed to compile terminfo using tic') -command_to_execute = json.loads(binascii.unhexlify('{command_to_execute}')) -try: - shell_path = pwd.getpwuid(os.geteuid()).pw_shell or '/bin/sh' -except KeyError: - shell_path = '/bin/sh' -shell_name = '-' + os.path.basename(shell_path) -if command_to_execute: - os.execlp(shell_path, shell_path, '-c', command_to_execute) -os.execlp(shell_path, shell_name) -''' +def load_script(script_type: str = 'sh', exec_cmd: str = '') -> str: + return bootstrap_script(script_type, EXEC_CMD=exec_cmd) def get_ssh_cli() -> Tuple[Set[str], Set[str]]: @@ -345,8 +239,7 @@ def parse_ssh_args(args: List[str]) -> Tuple[List[str], List[str], bool]: return ssh_args, server_args, passthrough -def get_posix_cmd(terminfo: str, remote_args: List[str]) -> List[str]: - sh_script = SHELL_SCRIPT.replace('TERMINFO', terminfo, 1) +def get_posix_cmd(remote_args: List[str]) -> List[str]: command_to_execute = '' if remote_args: # ssh simply concatenates multiple commands using a space see @@ -354,17 +247,12 @@ def get_posix_cmd(terminfo: str, remote_args: List[str]) -> List[str]: # concatenated command as shell -c cmd args = [c.replace("'", """'"'"'""") for c in remote_args] command_to_execute = "exec $login_shell -c '{}'".format(' '.join(args)) - sh_script = sh_script.replace('EXEC_CMD', command_to_execute) + sh_script = load_script(exec_cmd=command_to_execute) return [f'sh -c {shlex.quote(sh_script)}'] -def get_python_cmd(terminfo: str, command_to_execute: List[str]) -> List[str]: - import json - script = PYTHON_SCRIPT.format( - terminfo=terminfo.encode('utf-8').hex(), - command_to_execute=json.dumps(' '.join(command_to_execute)).encode('utf-8').hex() - ) - return [f'python -c "{script}"'] +def get_python_cmd(remote_args: List[str]) -> List[str]: + raise NotImplementedError('TODO: Implement me') def main(args: List[str]) -> NoReturn: @@ -386,9 +274,8 @@ def main(args: List[str]) -> NoReturn: cmd.append('-t') cmd.append('--') cmd.append(hostname) - terminfo = subprocess.check_output(['infocmp', '-a']).decode('utf-8') f = get_posix_cmd if use_posix else get_python_cmd - cmd += f(terminfo, remote_args) + cmd += f(remote_args) os.execvp('ssh', cmd) diff --git a/kitty/shell_integration.py b/kitty/shell_integration.py index d044e883c..d5fa95a62 100644 --- a/kitty/shell_integration.py +++ b/kitty/shell_integration.py @@ -10,6 +10,7 @@ from typing import Dict, List, Optional from .constants import shell_integration_dir from .options.types import Options from .utils import log_error, which +from .fast_data_types import get_options def setup_fish_env(env: Dict[str, str], argv: List[str]) -> None: @@ -120,11 +121,19 @@ def shell_integration_allows_rc_modification(opts: Options) -> bool: return not (opts.shell_integration & {'disabled', 'no-rc'}) +def get_effective_ksi_env_var(opts: Optional[Options] = None) -> str: + opts = opts or get_options() + if 'disabled' in opts.shell_integration: + return '' + return ' '.join(opts.shell_integration) + + def modify_shell_environ(opts: Options, env: Dict[str, str], argv: List[str]) -> None: shell = get_supported_shell_name(argv[0]) - if shell is None or 'disabled' in opts.shell_integration: + ksi = get_effective_ksi_env_var(opts) + if shell is None or not ksi: return - env['KITTY_SHELL_INTEGRATION'] = ' '.join(opts.shell_integration) + env['KITTY_SHELL_INTEGRATION'] = ksi if not shell_integration_allows_rc_modification(opts): return f = ENV_MODIFIERS.get(shell) diff --git a/kitty_tests/ssh.py b/kitty_tests/ssh.py index 66e6caaeb..9c5f22882 100644 --- a/kitty_tests/ssh.py +++ b/kitty_tests/ssh.py @@ -10,6 +10,7 @@ import tempfile from kittens.ssh.main import bootstrap_script, get_connection_data from kitty.constants import is_macos from kitty.fast_data_types import CURSOR_BEAM +from kitty.options.utils import shell_integration from kitty.utils import SSHConnectionData from . import BaseTest @@ -83,7 +84,7 @@ print(' '.join(map(str, buf)))'''), lines=13, cols=77) self.check_bootstrap(sh, tdir, login_shell) # check that turning off shell integration works if ok_login_shell in ('bash', 'zsh'): - for val in ('', 'no-rc'): + for val in ('', 'no-rc', 'enabled no-rc'): with tempfile.TemporaryDirectory() as tdir: self.check_bootstrap('sh', tdir, ok_login_shell, val) @@ -91,14 +92,14 @@ print(' '.join(map(str, buf)))'''), lines=13, cols=77) script = bootstrap_script( EXEC_CMD=f'echo "UNTAR_DONE"; {extra_exec}', OVERRIDE_LOGIN_SHELL=login_shell, - SHELL_INTEGRATION_VALUE=SHELL_INTEGRATION_VALUE, ) env = basic_shell_env(home_dir) # Avoid generating unneeded completion scripts os.makedirs(os.path.join(home_dir, '.local', 'share', 'fish', 'generated_completions'), exist_ok=True) # prevent newuser-install from running open(os.path.join(home_dir, '.zshrc'), 'w').close() - pty = self.create_pty(f'{sh} -c {shlex.quote(script)}', cwd=home_dir, env=env) + options = {'shell_integration': shell_integration(SHELL_INTEGRATION_VALUE or 'disabled')} + pty = self.create_pty(f'{sh} -c {shlex.quote(script)}', cwd=home_dir, env=env, options=options) if pre_data: pty.write_buf = pre_data.encode('utf-8') del script diff --git a/shell-integration/ssh/bootstrap.sh b/shell-integration/ssh/bootstrap.sh index 4573c5033..c10d732e8 100644 --- a/shell-integration/ssh/bootstrap.sh +++ b/shell-integration/ssh/bootstrap.sh @@ -77,7 +77,9 @@ get_data command stty "$saved_tty_settings" saved_tty_settings="" if [ "$rc" != "0" ]; then die "Failed to extract data transmitted by ssh kitten over the TTY device"; fi -if [ ! -f "$HOME/.terminfo/kitty.terminfo" ]; then die "Extracted data transmitted by ssh kitten is incomplete"; fi +shell_integration_dir="$HOME/SHELL_INTEGRATION_DIR" +shell_integration_settings_file="$shell_integration_dir/settings/ksi_env_var" +if [ ! -f "$shell_integration_settings_file" ]; then die "Extracted data transmitted by ssh kitten is incomplete"; fi if [ -n "$leading_data" ]; then # clear current line as it might have things echoed on it from leading_data # because we only turn off echo in this script whereas the leading bytes could @@ -100,7 +102,6 @@ if [ -x "$(command -v tic)" ]; then if [ "$rc" != "0" ]; then die "$tic_out"; fi fi -shell_integration_dir="$HOME/SHELL_INTEGRATION_DIR" login_shell_is_ok() { if [ -z "$login_shell" -o ! -x "$login_shell" ]; then return 1; fi @@ -187,7 +188,8 @@ else fi shell_name=$(basename $login_shell) -export KITTY_SHELL_INTEGRATION="SHELL_INTEGRATION_VALUE" +# read the variable and remove all leading and trailing spaces and collapse multiple spaces using xargs +export KITTY_SHELL_INTEGRATION="$(cat $shell_integration_settings_file | xargs echo)" exec_bash_with_integration() { export ENV="$shell_integration_dir/bash/kitty.bash" @@ -235,14 +237,18 @@ exec_with_shell_integration() { } case "$KITTY_SHELL_INTEGRATION" in - "") + ("") + # only blanks or unset unset KITTY_SHELL_INTEGRATION ;; - *"no-rc"*) - ;; - *) - exec_with_shell_integration - unset KITTY_SHELL_INTEGRATION + (*) + # not blank + q=$(printf "%s" "$KITTY_SHELL_INTEGRATION" | grep '\bno-rc\b') + if [ -z "$q" ]; then + exec_with_shell_integration + # exec failed, unset + unset KITTY_SHELL_INTEGRATION + fi ;; esac