Skip to content

Commit

Permalink
kitten @ run: A new remote control command to run a process on the ma…
Browse files Browse the repository at this point in the history
…chine kitty is running on and get its output

Fixes #7429
  • Loading branch information
kovidgoyal committed May 14, 2024
1 parent 1a394d6 commit 38fed8b
Show file tree
Hide file tree
Showing 4 changed files with 228 additions and 19 deletions.
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Detailed list of changes
0.35.0 [future]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

- kitten @ run: A new remote control command to run a process on the machine kitty is running on and get its output (:iss:`7429`)

- :opt:`notify_on_cmd_finish`: Show the actual command that was finished (:iss:`7420`)

- Shell integration: Make the currently executing cmdline available as a window variable in kitty
Expand Down
42 changes: 23 additions & 19 deletions kitty/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,30 @@ class LaunchSpec(NamedTuple):
args: List[str]


remote_control_password_docs = '''\
type=list
Restrict the actions remote control is allowed to take. This works like
:opt:`remote_control_password`. You can specify a password and list of actions
just as for :opt:`remote_control_password`. For example::
--remote-control-password '"my passphrase" get-* set-colors'
This password will be in effect for this window only.
Note that any passwords you have defined for :opt:`remote_control_password`
in :file:`kitty.conf` are also in effect. You can override them by using the same password here.
You can also disable all :opt:`remote_control_password` global passwords for this window, by using::
--remote-control-password '!'
This option only takes effect if :option:`--allow-remote-control`
is also specified. Can be specified multiple times to create multiple passwords.
This option was added to kitty in version 0.26.0
'''


@run_once
def options_spec() -> str:
return '''
return f'''
--window-title --title
The title to set for the new window. By default, title is controlled by the
child process. The special value :code:`current` will copy the title from the
Expand Down Expand Up @@ -171,24 +192,7 @@ def options_spec() -> str:
--remote-control-password
type=list
Restrict the actions remote control is allowed to take. This works like
:opt:`remote_control_password`. You can specify a password and list of actions
just as for :opt:`remote_control_password`. For example::
--remote-control-password '"my passphrase" get-* set-colors'
This password will be in effect for this window only.
Note that any passwords you have defined for :opt:`remote_control_password`
in :file:`kitty.conf` are also in effect. You can override them by using the same password here.
You can also disable all :opt:`remote_control_password` global passwords for this window, by using::
--remote-control-password '!'
This option only takes effect if :option:`--allow-remote-control`
is also specified. Can be specified multiple times to create multiple passwords.
This option was added to kitty in version 0.26.0
{remote_control_password_docs}
--stdin-source
type=choices
Expand Down
123 changes: 123 additions & 0 deletions kitty/rc/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env python
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>

import sys
from base64 import standard_b64decode, standard_b64encode
from typing import TYPE_CHECKING, Optional

from kitty.launch import remote_control_password_docs
from kitty.types import AsyncResponse

from .base import (
ArgsType,
Boss,
CmdGenerator,
ParsingOfArgsFailed,
PayloadGetType,
PayloadType,
RCOptions,
RemoteCommand,
ResponseType,
Window,
)

if TYPE_CHECKING:
from kitty.cli_stub import RunRCOptions as CLIOptions


class Run(RemoteCommand):
protocol_spec = __doc__ = '''
data+/str: Chunk of STDIN data, base64 encoded no more than 4096 bytes. Must send an empty chunk to indicate end of data.
cmdline+/list.str: The command line to run
allow_remote_control/bool: A boolean indicating whether to allow remote control
remote_control_password/list.str: A list of remote control passwords
'''

short_desc = 'Run a program on the computer in which kitty is running and get the output'
desc = (
'Run the specified program on the computer in which kitty is running. When STDIN is not a TTY it is forwarded'
' to the program as its STDIN. STDOUT and STDERR from the the program are forwarded here. The exit status of this'
' invocation will be the exit status of the executed program. If you wish to just run a program without wiating for a response, '

This comment has been minimized.

Copy link
@amosbird

amosbird May 14, 2024

Contributor

typo :)

' use @ launch --type=background instead.'
)

options_spec = f'''\n
--allow-remote-control
type=bool-set
The executed program will have privileges to run remote control commands in kitty.
--remote-control-password
{remote_control_password_docs}
'''
args = RemoteCommand.Args(
spec='CMD ...', json_field='data', special_parse='+cmdline:!read_run_data(io_data, args, &payload)', minimum_count=1,
completion=RemoteCommand.CompletionSpec.from_string('type:special group:cli.CompleteExecutableFirstArg')
)
reads_streaming_data = True
is_asynchronous = True

def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
if not args:
self.fatal('Must specify command to run')
import secrets
ret = {
'stream_id': secrets.token_urlsafe(),
'cmdline': args,
'allow_remote_control': opts.allow_remote_control,
'remote_control_password': opts.remote_control_password,
'data': '',
}
def pipe() -> CmdGenerator:
if sys.stdin.isatty():
yield ret
else:
limit = 4096
while True:
data = sys.stdin.buffer.read(limit)
if not data:
break
ret['data'] = standard_b64encode(data).decode("ascii")
yield ret
return pipe()

def response_from_kitty(self, boss: Boss, window: Optional[Window], payload_get: PayloadGetType) -> ResponseType:
import os
import tempfile
data = payload_get('data')
q = self.handle_streamed_data(standard_b64decode(data) if data else b'', payload_get)
if isinstance(q, AsyncResponse):
return q
stdin_data = q.getvalue()
from kitty.launch import parse_remote_control_passwords
cmdline = payload_get('cmdline')
allow_remote_control = payload_get('allow_remote_control')
pw = payload_get('remote_control_password')
rcp = parse_remote_control_passwords(allow_remote_control, pw)
if not cmdline:
raise ParsingOfArgsFailed('No cmdline to run specified')
responder = self.create_async_responder(payload_get, window)
stdout, stderr = tempfile.TemporaryFile(), tempfile.TemporaryFile()

def on_death(exit_status: int, err: Optional[Exception]) -> None:
with stdout, stderr:
if err:
responder.send_error(f'Failed to run: {cmdline} with err: {err}')
else:
exit_code = os.waitstatus_to_exitcode(exit_status)
stdout.seek(0)
stderr.seek(0)
responder.send_data({
'stdout': standard_b64encode(stdout.read()).decode('ascii'),
'stderr': standard_b64encode(stderr.read()).decode('ascii'),
'exit_code': exit_code, 'exit_status': exit_status,
})

boss.run_background_process(
cmdline, stdin=stdin_data, stdout=stdout.fileno(), stderr=stderr.fileno(), notify_on_death=on_death,
remote_control_passwords=rcp, allow_remote_control=allow_remote_control
)
return AsyncResponse()


run = Run()
80 changes: 80 additions & 0 deletions tools/cmd/at/run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package at

import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"

"kitty/tools/tty"
)

var _ = fmt.Print

type run_response_data struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Exit_code int `json:"exit_code"`
Exit_status int `json:"exit_status"`
}

func run_handle_response(data []byte) error {
var r run_response_data
if err := json.Unmarshal(data, &r); err != nil {
return err
}
if stdout, err := base64.StdEncoding.DecodeString(r.Stdout); err == nil {
_, _ = os.Stdout.Write(stdout)
} else {
return err
}
if stderr, err := base64.StdEncoding.DecodeString(r.Stderr); err == nil {
_, _ = os.Stderr.Write(stderr)
} else {
return err
}
if r.Exit_code != 0 {
return &exit_error{r.Exit_code}
}
return nil
}

func read_run_data(io_data *rc_io_data, args []string, payload *run_json_type) (func(io_data *rc_io_data) (bool, error), error) {
is_first_call := true
is_tty := tty.IsTerminal(os.Stdin.Fd())
buf := make([]byte, 4096)
cmdline := make([]escaped_string, len(args))
for i, s := range args {
cmdline[i] = escaped_string(s)
}
payload.Cmdline = cmdline
io_data.handle_response = run_handle_response

return func(io_data *rc_io_data) (bool, error) {
if is_first_call {
is_first_call = false
} else {
io_data.rc.Stream = false
}
buf = buf[:cap(buf)]
var n int
var err error
if is_tty {
buf = buf[:0]
err = io.EOF
} else {
n, err = os.Stdin.Read(buf)
if err != nil && err != io.EOF {
return false, err
}
buf = buf[:n]
}
set_payload_data(io_data, base64.StdEncoding.EncodeToString(buf))
if err == io.EOF {
return true, nil
}
return false, nil
}, nil

}

10 comments on commit 38fed8b

@amosbird
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic!

@amosbird
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, after this and dependent commits, kitten @ launch always ends up with Error: i/o timeout

P.S. Would you mind adding --env support for kitten @ run?

@kovidgoyal
Copy link
Owner Author

@kovidgoyal kovidgoyal commented on 38fed8b May 14, 2024 via email

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@amosbird
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works fine for me.

Oh, it's kitten @ send-text aaa which gets stuck.

Should be a trivial PR for you :)

Sure! Will do it now.

@kovidgoyal
Copy link
Owner Author

@kovidgoyal kovidgoyal commented on 38fed8b May 14, 2024 via email

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@amosbird
Copy link
Contributor

@amosbird amosbird commented on 38fed8b May 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems kitten @ run rarely works with unix domain sock. 2/14 work

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
hello

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
Error: Did not receive expected streaming response

 ❯ kitten @ --to unix:/tmp/kitty_sock run echo hello
hello

@kovidgoyal
Copy link
Owner Author

@kovidgoyal kovidgoyal commented on 38fed8b May 17, 2024 via email

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@amosbird
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on X11. I did some debugging. When broken the response is indeed not a stream response.

❯ kitten @ --to unix:/tmp/kitty_sock run echo 1
{"ok": true, "data": {"stdout": "MQo=", "stderr": "", "exit_code": 0, "exit_status": 0}}
Error: Did not receive expected streaming response

❯ kitten @ --to unix:/tmp/kitty_sock run echo 1
{"ok": true, "stream": true}
1

@amosbird
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/kovidgoyal/kitty/blob/master/kitty/rc/run.py#L109

It's likely a race condition here. on_death gets executed before AsyncResponse() is returned to the kitten command.

@amosbird
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if #7443 is a proper fix for this. At least it works for me :)

Please sign in to comment.