Skip to content

Commit af83d85

Browse files
committed
Add a framework for easily and securely using remote control from the main function of a custom kitten
1 parent 4bb0d3d commit af83d85

File tree

10 files changed

+219
-29
lines changed

10 files changed

+219
-29
lines changed

docs/changelog.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ consumption to do the same tasks.
7474
Detailed list of changes
7575
-------------------------------------
7676

77+
0.37.0 [future]
78+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
79+
80+
- Custom kittens: Add :ref:`a framework <kitten_main_rc>` for easily and securely using remote control from within a kitten's :code:`main()` function
81+
7782
0.36.4 [2024-09-27]
7883
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7984

docs/kittens/custom.rst

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,56 @@ The function will only send the event if the program is receiving events of
231231
that type, and will return ``True`` if it sent the event, and ``False`` if not.
232232

233233

234+
.. _kitten_main_rc:
235+
236+
Using remote control inside the main() kitten function
237+
------------------------------------------------------------
238+
239+
You can use kitty's remote control features inside the main() function of a
240+
kitten, even without enabling remote control. This is useful if you want to
241+
probe kitty for more information before presenting some UI to the user or if
242+
you want the user to be able to control kitty from within your kitten's UI
243+
rather than after it has finished running. To enable it, simply tell kitty your kitten
244+
requires remote control, as shown in the example below::
245+
246+
import json
247+
import sys
248+
from pprint import pprint
249+
250+
from kittens.tui.handler import kitten_ui
251+
252+
@kitten_ui(allow_remote_control=True)
253+
def main(args: list[str]) -> str:
254+
# get the result of running kitten @ ls
255+
cp = main.remote_control(['ls'], capture_output=True)
256+
if cp.returncode != 0:
257+
sys.stderr.buffer.write(cp.stderr)
258+
raise SystemExit(cp.returncode)
259+
output = json.loads(cp.stdout)
260+
pprint(output)
261+
# open a new tab with a title specified by the user
262+
title = input('Enter the name of tab: ')
263+
window_id = main.remote_control(['launch', '--type=tab', '--tab-title', title], check=True, capture_output=True).stdout.decode()
264+
return window_id
265+
266+
:code:`allow_remote_control=True` tells kitty to run this kitten with remote
267+
control enabled, regardless of whether it is enabled globally or not.
268+
To run a remote control command use the :code:`main.remote_control()` function
269+
which is a thin wrapper around Python's :code:`subprocess.run` function. Note
270+
that by default, for security, child processes launched by your kitten cannot use remote
271+
control, thus it is necessary to use :code:`main.remote_control()`. If you wish
272+
to enable child processes to use remote control, call
273+
:code:`main.allow_indiscriminate_remote_control()`.
274+
275+
Remote control access can be further secured by using
276+
:code:`kitten_ui(allow_remote_control=True, remote_control_password='ls set-colors')`.
277+
This will use a secure generated password to restrict remote control.
278+
You can specify a space separated list of remote control commands to allow, see
279+
:opt:`remote_control_password` for details. The password value is accessible
280+
as :code:`main.password` and is used by :code:`main.remote_control()`
281+
automatically.
282+
283+
234284
Debugging kittens
235285
--------------------
236286

kittens/runner.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,18 +80,22 @@ class KittenMetadata(NamedTuple):
8080
no_ui: bool = False
8181
has_ready_notification: bool = False
8282
open_url_handler: Optional[Callable[[BossType, WindowType, str, int, str], bool]] = None
83-
83+
allow_remote_control: bool = False
84+
remote_control_password: str | bool = False
8485

8586

8687
def create_kitten_handler(kitten: str, orig_args: List[str]) -> KittenMetadata:
8788
from kitty.constants import config_dir
8889
kitten = resolved_kitten(kitten)
8990
m = import_kitten_main_module(config_dir, kitten)
91+
main = m['start']
9092
handle_result = m['end']
9193
return KittenMetadata(
9294
handle_result=partial(handle_result, [kitten] + orig_args),
9395
type_of_input=getattr(handle_result, 'type_of_input', None),
9496
no_ui=getattr(handle_result, 'no_ui', False),
97+
allow_remote_control=getattr(main, 'allow_remote_control', False),
98+
remote_control_password=getattr(main, 'remote_control_password', True),
9599
has_ready_notification=getattr(handle_result, 'has_ready_notification', False),
96100
open_url_handler=getattr(handle_result, 'open_url_handler', None))
97101

kittens/tui/handler.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
33

44

5+
import os
56
from collections import deque
67
from contextlib import suppress
78
from types import TracebackType
89
from typing import TYPE_CHECKING, Any, Callable, ContextManager, Deque, Dict, NamedTuple, Optional, Sequence, Type, Union, cast
910

10-
from kitty.fast_data_types import monotonic
11+
from kitty.constants import kitten_exe, running_in_kitty
12+
from kitty.fast_data_types import monotonic, safe_pipe
1113
from kitty.types import DecoratedFunc, ParsedShortcut
1214
from kitty.typing import (
1315
AbstractEventLoop,
@@ -47,6 +49,98 @@ def is_click(a: ButtonEvent, b: ButtonEvent) -> bool:
4749
return x*x + y*y <= 4
4850

4951

52+
class KittenUI:
53+
allow_remote_control: bool = False
54+
remote_control_password: bool | str = False
55+
56+
def __init__(self, func: Callable[[list[str]], str], allow_remote_control: bool, remote_control_password: bool | str):
57+
self.func = func
58+
self.allow_remote_control = allow_remote_control
59+
self.remote_control_password = remote_control_password
60+
self.password = self.to = ''
61+
self.rc_fd = -1
62+
self.initialized = False
63+
64+
def initialize(self) -> None:
65+
if self.initialized:
66+
return
67+
self.initialized = True
68+
if running_in_kitty():
69+
return
70+
if self.allow_remote_control:
71+
self.to = os.environ.get('KITTY_LISTEN_ON', '')
72+
self.rc_fd = int(self.to.partition(':')[-1])
73+
os.set_inheritable(self.rc_fd, False)
74+
if (self.remote_control_password or self.remote_control_password == '') and not self.password:
75+
import socket
76+
with socket.fromfd(self.rc_fd, socket.AF_UNIX, socket.SOCK_STREAM) as s:
77+
data = s.recv(256)
78+
if not data.endswith(b'\n'):
79+
raise Exception(f'The remote control password was invalid: {data!r}')
80+
self.password = data.strip().decode()
81+
82+
def __call__(self, args: list[str]) -> str:
83+
self.initialize()
84+
return self.func(args)
85+
86+
def allow_indiscriminate_remote_control(self, enable: bool = True) -> None:
87+
if self.rc_fd > -1:
88+
if enable:
89+
os.set_inheritable(self.rc_fd, True)
90+
if self.password:
91+
os.environ['KITTY_RC_PASSWORD'] = self.password
92+
else:
93+
os.set_inheritable(self.rc_fd, False)
94+
if self.password:
95+
os.environ.pop('KITTY_RC_PASSWORD', None)
96+
97+
def remote_control(self, cmd: str | Sequence[str], **kw: Any) -> Any:
98+
if not self.allow_remote_control:
99+
raise ValueError('Remote control is not enabled, remember to use allow_remote_control=True')
100+
prefix = [kitten_exe(), '@']
101+
r = -1
102+
pass_fds = list(kw.get('pass_fds') or ())
103+
try:
104+
if self.rc_fd > -1:
105+
pass_fds.append(self.rc_fd)
106+
if self.password and self.rc_fd > -1:
107+
r, w = safe_pipe(False)
108+
os.write(w, self.password.encode())
109+
os.close(w)
110+
prefix += ['--password-file', f'fd:{r}', '--use-password', 'always']
111+
pass_fds.append(r)
112+
if pass_fds:
113+
kw['pass_fds'] = tuple(pass_fds)
114+
if isinstance(cmd, str):
115+
cmd = ' '.join(prefix)
116+
else:
117+
cmd = prefix + list(cmd)
118+
import subprocess
119+
if self.rc_fd > -1:
120+
is_inheritable = os.get_inheritable(self.rc_fd)
121+
if not is_inheritable:
122+
os.set_inheritable(self.rc_fd, True)
123+
try:
124+
return subprocess.run(cmd, **kw)
125+
finally:
126+
if self.rc_fd > -1 and not is_inheritable:
127+
os.set_inheritable(self.rc_fd, False)
128+
finally:
129+
if r > -1:
130+
os.close(r)
131+
132+
133+
def kitten_ui(
134+
allow_remote_control: bool = KittenUI.allow_remote_control,
135+
remote_control_password: bool | str = KittenUI.allow_remote_control,
136+
) -> Callable[[Callable[[list[str]], str]], KittenUI]:
137+
138+
def wrapper(impl: Callable[..., Any]) -> KittenUI:
139+
return KittenUI(impl, allow_remote_control, remote_control_password)
140+
141+
return wrapper
142+
143+
50144
class Handler:
51145

52146
image_manager_class: Optional[Type[ImageManagerType]] = None

kitty/boss.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,17 +1948,32 @@ def run_kitten_with_metadata(
19481948
else:
19491949
cmd = [kitty_exe(), '+runpy', 'from kittens.runner import main; main()']
19501950
env['PYTHONWARNINGS'] = 'ignore'
1951-
overlay_window = tab.new_special_window(
1952-
SpecialWindow(
1953-
cmd + final_args,
1954-
stdin=data,
1955-
env=env,
1956-
cwd=w.cwd_of_child,
1957-
overlay_for=w.id,
1958-
overlay_behind=end_kitten.has_ready_notification,
1959-
),
1960-
copy_colors_from=w
1961-
)
1951+
remote_control_fd = -1
1952+
if end_kitten.allow_remote_control:
1953+
remote_control_passwords: Optional[dict[str, Sequence[str]]] = None
1954+
initial_data = b''
1955+
if end_kitten.remote_control_password:
1956+
from secrets import token_hex
1957+
p = token_hex(16)
1958+
remote_control_passwords = {p: end_kitten.remote_control_password if isinstance(end_kitten.remote_control_password, str) else ''}
1959+
initial_data = p.encode() + b'\n'
1960+
remote = self.add_fd_based_remote_control(remote_control_passwords, initial_data)
1961+
remote_control_fd = remote.fileno()
1962+
try:
1963+
overlay_window = tab.new_special_window(
1964+
SpecialWindow(
1965+
cmd + final_args,
1966+
stdin=data,
1967+
env=env,
1968+
cwd=w.cwd_of_child,
1969+
overlay_for=w.id,
1970+
overlay_behind=end_kitten.has_ready_notification,
1971+
),
1972+
copy_colors_from=w, remote_control_fd=remote_control_fd,
1973+
)
1974+
finally:
1975+
if end_kitten.allow_remote_control:
1976+
remote.close()
19621977
wid = w.id
19631978
overlay_window.actions_on_close.append(partial(self.on_kitten_finish, wid, custom_callback or end_kitten.handle_result, default_data=default_data))
19641979
overlay_window.open_url_handler = end_kitten.open_url_handler
@@ -2351,9 +2366,11 @@ def special_window_for_cmd(
23512366
overlay_for = w.id if w and as_overlay else None
23522367
return SpecialWindow(cmd, input_data, cwd_from=cwd_from, overlay_for=overlay_for, env=env)
23532368

2354-
def add_fd_based_remote_control(self, remote_control_passwords: Optional[dict[str, Sequence[str]]] = None) -> socket.socket:
2369+
def add_fd_based_remote_control(self, remote_control_passwords: Optional[dict[str, Sequence[str]]] = None, initial_data: bytes = b'') -> socket.socket:
23552370
local, remote = socket.socketpair()
23562371
os.set_inheritable(remote.fileno(), True)
2372+
if initial_data:
2373+
local.send(initial_data)
23572374
lfd = os.dup(local.fileno())
23582375
local.close()
23592376
try:

kitty/child.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from collections.abc import Generator, Sequence
88
from contextlib import contextmanager, suppress
99
from itertools import count
10-
from typing import TYPE_CHECKING, DefaultDict, Optional, Protocol, Union
10+
from typing import TYPE_CHECKING, DefaultDict, Optional
1111

1212
import kitty.fast_data_types as fast_data_types
1313

@@ -23,11 +23,6 @@
2323
from .window import CwdRequest
2424

2525

26-
class InheritableFile(Protocol):
27-
28-
def fileno(self) -> int: ...
29-
30-
3126
if is_macos:
3227
from kitty.fast_data_types import cmdline_of_process as cmdline_
3328
from kitty.fast_data_types import cwd_of_process as _cwd
@@ -216,13 +211,15 @@ def __init__(
216211
is_clone_launch: str = '',
217212
add_listen_on_env_var: bool = True,
218213
hold: bool = False,
219-
pass_fds: tuple[Union[int, InheritableFile], ...] = (),
214+
pass_fds: tuple[int, ...] = (),
215+
remote_control_fd: int = -1,
220216
):
221217
self.is_clone_launch = is_clone_launch
222218
self.id = next(child_counter)
223219
self.add_listen_on_env_var = add_listen_on_env_var
224220
self.argv = list(argv)
225221
self.pass_fds = pass_fds
222+
self.remote_control_fd = remote_control_fd
226223
if cwd_from:
227224
try:
228225
cwd = cwd_from.modify_argv_for_launch_with_cwd(self.argv, env) or cwd
@@ -251,7 +248,9 @@ def get_final_env(self) -> dict[str, str]:
251248
env['COLORTERM'] = 'truecolor'
252249
env['KITTY_PID'] = getpid()
253250
env['KITTY_PUBLIC_KEY'] = boss.encryption_public_key
254-
if self.add_listen_on_env_var and boss.listening_on:
251+
if self.remote_control_fd > -1:
252+
env['KITTY_LISTEN_ON'] = f'fd:{self.remote_control_fd}'
253+
elif self.add_listen_on_env_var and boss.listening_on:
255254
env['KITTY_LISTEN_ON'] = boss.listening_on
256255
else:
257256
env.pop('KITTY_LISTEN_ON', None)
@@ -299,6 +298,9 @@ def fork(self) -> Optional[int]:
299298
self.final_env = self.get_final_env()
300299
argv = list(self.argv)
301300
cwd = self.cwd
301+
pass_fds = self.pass_fds
302+
if self.remote_control_fd > -1:
303+
pass_fds += self.remote_control_fd,
302304
if self.should_run_via_run_shell_kitten:
303305
# bash will only source ~/.bash_profile if it detects it is a login
304306
# shell (see the invocation section of the bash man page), which it
@@ -319,7 +321,7 @@ def fork(self) -> Optional[int]:
319321
if ksi == 'invalid':
320322
ksi = 'enabled'
321323
argv = [kitten_exe(), 'run-shell', '--shell', shlex.join(argv), '--shell-integration', ksi]
322-
if is_macos and not self.pass_fds and not opts.forward_stdio:
324+
if is_macos and not pass_fds and not opts.forward_stdio:
323325
# In addition for getlogin() to work we need to run the shell
324326
# via the /usr/bin/login wrapper, sigh.
325327
# And login on macOS looks for .hushlogin in CWD instead of
@@ -339,12 +341,10 @@ def fork(self) -> Optional[int]:
339341
argv = cmdline_for_hold(argv)
340342
final_exe = argv[0]
341343
env = tuple(f'{k}={v}' for k, v in self.final_env.items())
342-
pass_fds = tuple(sorted(x if isinstance(x, int) else x.fileno() for x in self.pass_fds))
343344
pid = fast_data_types.spawn(
344345
final_exe, cwd, tuple(argv), env, master, slave, stdin_read_fd, stdin_write_fd,
345346
ready_read_fd, ready_write_fd, tuple(handled_signals), kitten_exe(), opts.forward_stdio, pass_fds)
346347
os.close(slave)
347-
self.pass_fds = ()
348348
self.pid = pid
349349
self.child_fd = master
350350
if stdin is not None:

kitty/remote_control.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ def handle_cmd(
279279
default=rc-pass
280280
A file from which to read the password. Trailing whitespace is ignored. Relative
281281
paths are resolved from the kitty configuration directory. Use - to read from STDIN.
282+
Use :code:`fd:num` to read from the file descriptor :code:`num`.
282283
Used if no :option:`kitten @ --password` is supplied. Defaults to checking for the
283284
:file:`rc-pass` file in the kitty configuration directory.
284285

0 commit comments

Comments
 (0)