msdev 0.10.1__tar.gz → 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {msdev-0.10.1 → msdev-0.11.0}/PKG-INFO +28 -2
- {msdev-0.10.1 → msdev-0.11.0}/README.md +27 -1
- {msdev-0.10.1 → msdev-0.11.0}/pyproject.toml +1 -1
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/__init__.py +1 -1
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/cli.py +65 -13
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/compat.py +40 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/env_transfer.py +42 -8
- msdev-0.11.0/src/msdev/core/exec_argv.py +82 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/execution.py +8 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/node.py +2 -0
- msdev-0.11.0/src/msdev/core/ssh_auth.py +395 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/ssh_tunnel.py +7 -1
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/stream_transport.py +13 -3
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/transport.py +90 -9
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/daemon.py +4 -1
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev.egg-info/PKG-INFO +28 -2
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev.egg-info/SOURCES.txt +4 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_cli.py +25 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_compat.py +19 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_daemon.py +24 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_env_transfer.py +27 -0
- msdev-0.11.0/tests/test_exec_argv.py +55 -0
- msdev-0.11.0/tests/test_ssh_auth.py +515 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_target_exec_services.py +73 -1
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_transport.py +2 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_windows_local_scenario.py +37 -0
- {msdev-0.10.1 → msdev-0.11.0}/LICENSE +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/setup.cfg +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/__init__.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/config.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/container_helper.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/guides.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/helper_deploy.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/host_files.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/host_mutations.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/inventory.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/limits.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/local_rpc.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/multiplex.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/request_client.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/resources.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/__init__.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/capabilities.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/context.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/environment.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/model.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/npu.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/results.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/stream_limits.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/stream_server.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/streamproto.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev.egg-info/dependency_links.txt +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev.egg-info/entry_points.txt +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/src/msdev.egg-info/top_level.txt +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_cli_only_architecture.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_config.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_container_helper.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_daemon_container_stream.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_docker_stream_dispatch.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_env_put_get_scenario.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_env_read_write_scenario.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_exec_default_timeout_scenario.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_helper_deploy.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_inventory.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_model_cli.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_model_services.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_msdev_cli_skill.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_multiplex.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_node_env_config.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_node_env_services.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_request_client.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_ssh_tunnel.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_stream_server.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_stream_transport.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_streamproto.py +0 -0
- {msdev-0.10.1 → msdev-0.11.0}/tests/test_target_guides.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: msdev
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Portable nodes and execution environments for msModelSlim development
|
|
5
5
|
Author: msModelSlim Agent Contributors
|
|
6
6
|
License-Expression: MulanPSL-2.0
|
|
@@ -62,6 +62,12 @@ implement ControlMaster multiplexing, so msdev:
|
|
|
62
62
|
`env put`/`get` stay on scp/ssh streams (file bytes do not go through JSON RPC);
|
|
63
63
|
- hides SSH console windows (`CREATE_NO_WINDOW`) and kills process trees with
|
|
64
64
|
`taskkill /T` on timeout or cancel;
|
|
65
|
+
- SSH is non-interactive: `BatchMode` unless a password is supplied, host keys
|
|
66
|
+
use `accept-new`, and there is no password/passphrase/yes-no prompt. When a
|
|
67
|
+
password is known, injects OpenSSH `SSH_ASKPASS` so `node connect` (and later
|
|
68
|
+
ssh/scp) can authenticate without a TTY; the local public key is then
|
|
69
|
+
installed on the Node so later logins are passwordless. The password is
|
|
70
|
+
never written to the node registry;
|
|
65
71
|
- serves a TCP loopback endpoint for local `msdevd` when this Python build has
|
|
66
72
|
no `AF_UNIX` sockets.
|
|
67
73
|
|
|
@@ -92,10 +98,23 @@ the remote user daemon:
|
|
|
92
98
|
msdev node bootstrap dev-122
|
|
93
99
|
```
|
|
94
100
|
|
|
95
|
-
Persistent OpenSSH masters are optional
|
|
101
|
+
Persistent OpenSSH masters are optional. All SSH from msdev is
|
|
102
|
+
non-interactive: host keys use `accept-new`, and password/passphrase prompts
|
|
103
|
+
are disabled. Supply a password via environment or file (never a prompt);
|
|
104
|
+
connect then installs the local public key (`~/.ssh/id_ed25519`, generated
|
|
105
|
+
if missing) so later connections are passwordless:
|
|
96
106
|
|
|
97
107
|
```bash
|
|
108
|
+
# Environment (also used by exec / env put after connect if persist is off)
|
|
109
|
+
export MSDEV_SSH_PASSWORD='...'
|
|
110
|
+
export MSDEV_SSH_PASSWORD_DEV_122='...' # per-node override
|
|
111
|
+
|
|
98
112
|
msdev node connect dev-122
|
|
113
|
+
msdev node connect dev-122 --password-file FILE
|
|
114
|
+
msdev node connect dev-122 --password-env VAR
|
|
115
|
+
msdev node connect dev-122 --password PASSWORD # non-interactive; visible in ps
|
|
116
|
+
msdev node connect dev-122 --identity FILE # choose which key to install
|
|
117
|
+
msdev node connect dev-122 --no-install-key # password only, do not copy key
|
|
99
118
|
msdev node disconnect dev-122
|
|
100
119
|
```
|
|
101
120
|
|
|
@@ -185,6 +204,13 @@ msdev exec \
|
|
|
185
204
|
-- python3 run.py
|
|
186
205
|
```
|
|
187
206
|
|
|
207
|
+
Quoted pipelines and `|` tokens are executed through a remote POSIX shell
|
|
208
|
+
(`sh -c`), so they run on the Node instead of being treated as a single
|
|
209
|
+
executable name. On a Windows `local` Env they use `cmd.exe /s /c`. Prefer
|
|
210
|
+
`sh -c '…'` for complex scripts. Default streaming writes POSIX newlines as
|
|
211
|
+
CRLF on a Windows console so remote line output does not staircase; piped
|
|
212
|
+
CLI output keeps LF.
|
|
213
|
+
|
|
188
214
|
`--timeout-seconds` defaults to `-1` (no deadline). Any positive finite duration
|
|
189
215
|
is also accepted. A positive timeout kills the complete command
|
|
190
216
|
process group and returns exit code 124. Cancelling the local CLI closes the RPC
|
|
@@ -37,6 +37,12 @@ implement ControlMaster multiplexing, so msdev:
|
|
|
37
37
|
`env put`/`get` stay on scp/ssh streams (file bytes do not go through JSON RPC);
|
|
38
38
|
- hides SSH console windows (`CREATE_NO_WINDOW`) and kills process trees with
|
|
39
39
|
`taskkill /T` on timeout or cancel;
|
|
40
|
+
- SSH is non-interactive: `BatchMode` unless a password is supplied, host keys
|
|
41
|
+
use `accept-new`, and there is no password/passphrase/yes-no prompt. When a
|
|
42
|
+
password is known, injects OpenSSH `SSH_ASKPASS` so `node connect` (and later
|
|
43
|
+
ssh/scp) can authenticate without a TTY; the local public key is then
|
|
44
|
+
installed on the Node so later logins are passwordless. The password is
|
|
45
|
+
never written to the node registry;
|
|
40
46
|
- serves a TCP loopback endpoint for local `msdevd` when this Python build has
|
|
41
47
|
no `AF_UNIX` sockets.
|
|
42
48
|
|
|
@@ -67,10 +73,23 @@ the remote user daemon:
|
|
|
67
73
|
msdev node bootstrap dev-122
|
|
68
74
|
```
|
|
69
75
|
|
|
70
|
-
Persistent OpenSSH masters are optional
|
|
76
|
+
Persistent OpenSSH masters are optional. All SSH from msdev is
|
|
77
|
+
non-interactive: host keys use `accept-new`, and password/passphrase prompts
|
|
78
|
+
are disabled. Supply a password via environment or file (never a prompt);
|
|
79
|
+
connect then installs the local public key (`~/.ssh/id_ed25519`, generated
|
|
80
|
+
if missing) so later connections are passwordless:
|
|
71
81
|
|
|
72
82
|
```bash
|
|
83
|
+
# Environment (also used by exec / env put after connect if persist is off)
|
|
84
|
+
export MSDEV_SSH_PASSWORD='...'
|
|
85
|
+
export MSDEV_SSH_PASSWORD_DEV_122='...' # per-node override
|
|
86
|
+
|
|
73
87
|
msdev node connect dev-122
|
|
88
|
+
msdev node connect dev-122 --password-file FILE
|
|
89
|
+
msdev node connect dev-122 --password-env VAR
|
|
90
|
+
msdev node connect dev-122 --password PASSWORD # non-interactive; visible in ps
|
|
91
|
+
msdev node connect dev-122 --identity FILE # choose which key to install
|
|
92
|
+
msdev node connect dev-122 --no-install-key # password only, do not copy key
|
|
74
93
|
msdev node disconnect dev-122
|
|
75
94
|
```
|
|
76
95
|
|
|
@@ -160,6 +179,13 @@ msdev exec \
|
|
|
160
179
|
-- python3 run.py
|
|
161
180
|
```
|
|
162
181
|
|
|
182
|
+
Quoted pipelines and `|` tokens are executed through a remote POSIX shell
|
|
183
|
+
(`sh -c`), so they run on the Node instead of being treated as a single
|
|
184
|
+
executable name. On a Windows `local` Env they use `cmd.exe /s /c`. Prefer
|
|
185
|
+
`sh -c '…'` for complex scripts. Default streaming writes POSIX newlines as
|
|
186
|
+
CRLF on a Windows console so remote line output does not staircase; piped
|
|
187
|
+
CLI output keeps LF.
|
|
188
|
+
|
|
163
189
|
`--timeout-seconds` defaults to `-1` (no deadline). Any positive finite duration
|
|
164
190
|
is also accepted. A positive timeout kills the complete command
|
|
165
191
|
process group and returns exit code 124. Cancelling the local CLI closes the RPC
|
|
@@ -12,6 +12,7 @@ from dataclasses import asdict
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
15
|
+
from .core.compat import ConsoleChunkWriter
|
|
15
16
|
from .core.guides import (
|
|
16
17
|
EnvironmentGuideStore,
|
|
17
18
|
MAX_GUIDE_BYTES,
|
|
@@ -48,6 +49,7 @@ from .core.services import (
|
|
|
48
49
|
NodeService,
|
|
49
50
|
)
|
|
50
51
|
from .core.resources import EnvironmentStore, NodeStore
|
|
52
|
+
from .core.ssh_auth import apply_password_sources
|
|
51
53
|
from .core.transport import (
|
|
52
54
|
RpcError,
|
|
53
55
|
SshRpcTransport,
|
|
@@ -205,9 +207,12 @@ def command_node(
|
|
|
205
207
|
if item.error is not None:
|
|
206
208
|
print(f"{item.node}: error: {item.error}", file=sys.stderr)
|
|
207
209
|
elif action == "connect":
|
|
210
|
+
suffix = f"(persist={item.persist})"
|
|
211
|
+
key = getattr(item, "key", None)
|
|
212
|
+
if key:
|
|
213
|
+
suffix = f"(persist={item.persist}, key={key})"
|
|
208
214
|
print(
|
|
209
|
-
f"{item.node}: {item.state} to {item.ssh_host} "
|
|
210
|
-
f"(persist={item.persist})"
|
|
215
|
+
f"{item.node}: {item.state} to {item.ssh_host} {suffix}"
|
|
211
216
|
)
|
|
212
217
|
else:
|
|
213
218
|
print(f"{item.node}: {item.state}")
|
|
@@ -672,17 +677,13 @@ def command_exec(
|
|
|
672
677
|
result = service.run(request)
|
|
673
678
|
_print_json(result.to_dict())
|
|
674
679
|
else:
|
|
680
|
+
writers = {
|
|
681
|
+
"stdout": ConsoleChunkWriter(sys.stdout),
|
|
682
|
+
"stderr": ConsoleChunkWriter(sys.stderr),
|
|
683
|
+
}
|
|
684
|
+
|
|
675
685
|
def on_output(stream_name: str, chunk: bytes) -> None:
|
|
676
|
-
|
|
677
|
-
sys.stdout if stream_name == "stdout" else sys.stderr
|
|
678
|
-
)
|
|
679
|
-
binary = getattr(destination, "buffer", None)
|
|
680
|
-
if binary is not None:
|
|
681
|
-
binary.write(chunk)
|
|
682
|
-
binary.flush()
|
|
683
|
-
else:
|
|
684
|
-
destination.write(chunk.decode("utf-8", errors="replace"))
|
|
685
|
-
destination.flush()
|
|
686
|
+
writers.get(stream_name, writers["stderr"]).write(chunk)
|
|
686
687
|
|
|
687
688
|
result = service.stream(request, on_output)
|
|
688
689
|
return result.returncode
|
|
@@ -748,10 +749,47 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
748
749
|
node_bootstrap.add_argument("--package-path", type=Path)
|
|
749
750
|
node_connect = node_sub.add_parser(
|
|
750
751
|
"connect",
|
|
751
|
-
help="open a persistent SSH master
|
|
752
|
+
help="open a persistent SSH master without prompting",
|
|
752
753
|
)
|
|
753
754
|
node_connect.add_argument("name", nargs="?")
|
|
754
755
|
node_connect.add_argument("--all", action="store_true")
|
|
756
|
+
node_connect.add_argument(
|
|
757
|
+
"--password",
|
|
758
|
+
default=None,
|
|
759
|
+
metavar="PASSWORD",
|
|
760
|
+
help=(
|
|
761
|
+
"SSH password for this process only (non-interactive). "
|
|
762
|
+
"Prefer --password-file or MSDEV_SSH_PASSWORD. "
|
|
763
|
+
"Not stored in the node registry"
|
|
764
|
+
),
|
|
765
|
+
)
|
|
766
|
+
node_connect.add_argument(
|
|
767
|
+
"--password-file",
|
|
768
|
+
"--ssh-password-file",
|
|
769
|
+
type=Path,
|
|
770
|
+
dest="ssh_password_file",
|
|
771
|
+
metavar="FILE",
|
|
772
|
+
help="read SSH password from FILE (first line); not stored in the registry",
|
|
773
|
+
)
|
|
774
|
+
node_connect.add_argument(
|
|
775
|
+
"--password-env",
|
|
776
|
+
"--ssh-password-env",
|
|
777
|
+
dest="ssh_password_env",
|
|
778
|
+
metavar="VAR",
|
|
779
|
+
help="read SSH password from environment variable VAR",
|
|
780
|
+
)
|
|
781
|
+
node_connect.add_argument(
|
|
782
|
+
"--identity",
|
|
783
|
+
type=Path,
|
|
784
|
+
dest="ssh_identity",
|
|
785
|
+
metavar="FILE",
|
|
786
|
+
help="private key to install and use (default: ~/.ssh/id_ed25519)",
|
|
787
|
+
)
|
|
788
|
+
node_connect.add_argument(
|
|
789
|
+
"--no-install-key",
|
|
790
|
+
action="store_true",
|
|
791
|
+
help="do not install the local public key during password connect",
|
|
792
|
+
)
|
|
755
793
|
node_disconnect = node_sub.add_parser(
|
|
756
794
|
"disconnect",
|
|
757
795
|
help="close a persistent SSH master",
|
|
@@ -1040,6 +1078,20 @@ def _watch_parent_exit(
|
|
|
1040
1078
|
def main() -> None:
|
|
1041
1079
|
parser = build_parser()
|
|
1042
1080
|
args = parser.parse_args()
|
|
1081
|
+
try:
|
|
1082
|
+
apply_password_sources(
|
|
1083
|
+
password=getattr(args, "password", None),
|
|
1084
|
+
password_file=getattr(args, "ssh_password_file", None),
|
|
1085
|
+
password_env=getattr(args, "ssh_password_env", None),
|
|
1086
|
+
)
|
|
1087
|
+
except ValueError as exc:
|
|
1088
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1089
|
+
raise SystemExit(2) from exc
|
|
1090
|
+
identity = getattr(args, "ssh_identity", None)
|
|
1091
|
+
if identity is not None:
|
|
1092
|
+
os.environ["MSDEV_SSH_IDENTITY"] = str(identity)
|
|
1093
|
+
if getattr(args, "no_install_key", False):
|
|
1094
|
+
os.environ["MSDEV_SSH_INSTALL_KEY"] = "0"
|
|
1043
1095
|
node_store = NodeStore(args.config)
|
|
1044
1096
|
environment_store = EnvironmentStore(args.config)
|
|
1045
1097
|
previous_sigterm = signal.getsignal(signal.SIGTERM)
|
|
@@ -49,6 +49,14 @@ def scp_executable() -> str:
|
|
|
49
49
|
return fallback or "scp"
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
def ssh_keygen_executable() -> str:
|
|
53
|
+
"""Resolve ssh-keygen without requiring OpenSSH to be on PATH."""
|
|
54
|
+
if shutil.which("ssh-keygen"):
|
|
55
|
+
return "ssh-keygen"
|
|
56
|
+
fallback = _windows_openssh("ssh-keygen.exe") if IS_WINDOWS else None
|
|
57
|
+
return fallback or "ssh-keygen"
|
|
58
|
+
|
|
59
|
+
|
|
52
60
|
def ssh_control_master_enabled() -> bool:
|
|
53
61
|
"""OpenSSH ControlMaster is unsupported by native Windows OpenSSH."""
|
|
54
62
|
override = os.environ.get("MSDEV_SSH_CONTROL_MASTER", "").strip().lower()
|
|
@@ -87,6 +95,38 @@ def normalize_captured_text(value: str) -> str:
|
|
|
87
95
|
return value.replace("\r\n", "\n").replace("\r", "\n")
|
|
88
96
|
|
|
89
97
|
|
|
98
|
+
def translate_lf_to_crlf(chunk: bytes, carry_cr: bool) -> tuple[bytes, bool]:
|
|
99
|
+
"""Convert POSIX newlines to Windows console CRLF without doubling CR."""
|
|
100
|
+
if carry_cr:
|
|
101
|
+
chunk = b"\r" + chunk
|
|
102
|
+
if chunk.endswith(b"\r"):
|
|
103
|
+
return (
|
|
104
|
+
chunk[:-1].replace(b"\r\n", b"\n").replace(b"\n", b"\r\n"),
|
|
105
|
+
True,
|
|
106
|
+
)
|
|
107
|
+
return chunk.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n"), False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ConsoleChunkWriter:
|
|
111
|
+
"""Write exec chunks to a text stream, fixing Windows TTY staircase output."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, stream: Any) -> None:
|
|
114
|
+
self.stream = stream
|
|
115
|
+
self.binary = getattr(stream, "buffer", None)
|
|
116
|
+
self._translate = IS_WINDOWS and bool(getattr(stream, "isatty", lambda: False)())
|
|
117
|
+
self._carry_cr = False
|
|
118
|
+
|
|
119
|
+
def write(self, chunk: bytes) -> None:
|
|
120
|
+
if self._translate:
|
|
121
|
+
chunk, self._carry_cr = translate_lf_to_crlf(chunk, self._carry_cr)
|
|
122
|
+
if self.binary is not None:
|
|
123
|
+
self.binary.write(chunk)
|
|
124
|
+
self.binary.flush()
|
|
125
|
+
return
|
|
126
|
+
self.stream.write(chunk.decode("utf-8", errors="replace"))
|
|
127
|
+
self.stream.flush()
|
|
128
|
+
|
|
129
|
+
|
|
90
130
|
def is_unix_socket(path: Path) -> bool:
|
|
91
131
|
try:
|
|
92
132
|
return stat.S_ISSOCK(path.lstat().st_mode)
|
|
@@ -14,6 +14,13 @@ from typing import Any, BinaryIO
|
|
|
14
14
|
from .compat import popen_kwargs
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _run_kw(env: dict[str, str] | None = None) -> dict[str, Any]:
|
|
18
|
+
kwargs = popen_kwargs(hidden=True, new_group=False)
|
|
19
|
+
if env is not None:
|
|
20
|
+
kwargs["env"] = env
|
|
21
|
+
return kwargs
|
|
22
|
+
|
|
23
|
+
|
|
17
24
|
def validate_absolute_local(path: str, *, label: str) -> str:
|
|
18
25
|
"""Accept a host-local absolute path (POSIX or Windows)."""
|
|
19
26
|
if not isinstance(path, str) or not path or "\0" in path:
|
|
@@ -100,14 +107,21 @@ def local_get_file(
|
|
|
100
107
|
return result
|
|
101
108
|
|
|
102
109
|
|
|
103
|
-
def _run(
|
|
110
|
+
def _run(
|
|
111
|
+
argv: list[str],
|
|
112
|
+
*,
|
|
113
|
+
label: str,
|
|
114
|
+
timeout: float | None,
|
|
115
|
+
env: dict[str, str] | None = None,
|
|
116
|
+
) -> None:
|
|
104
117
|
try:
|
|
105
118
|
completed = subprocess.run(
|
|
106
119
|
argv,
|
|
107
120
|
capture_output=True,
|
|
108
121
|
check=False,
|
|
109
122
|
timeout=timeout,
|
|
110
|
-
|
|
123
|
+
stdin=subprocess.DEVNULL,
|
|
124
|
+
**_run_kw(env),
|
|
111
125
|
)
|
|
112
126
|
except subprocess.TimeoutExpired as exc:
|
|
113
127
|
raise RuntimeError(f"{label} timed out") from exc
|
|
@@ -134,6 +148,7 @@ def ssh_host_put_file(
|
|
|
134
148
|
local_path: Path,
|
|
135
149
|
remote_path: str,
|
|
136
150
|
timeout: float | None,
|
|
151
|
+
env: dict[str, str] | None = None,
|
|
137
152
|
) -> dict[str, Any]:
|
|
138
153
|
destination = validate_absolute_remote(remote_path, label="remote path")
|
|
139
154
|
parent = str(PurePosixPath(destination).parent)
|
|
@@ -142,11 +157,13 @@ def ssh_host_put_file(
|
|
|
142
157
|
[*ssh_argv, f"mkdir -p -- {shlex.quote(parent)}"],
|
|
143
158
|
label="remote mkdir for env put",
|
|
144
159
|
timeout=timeout,
|
|
160
|
+
env=env,
|
|
145
161
|
)
|
|
146
162
|
_run(
|
|
147
163
|
[*scp_argv, str(local_path), _remote_spec(ssh_host, staging)],
|
|
148
164
|
label="scp env put",
|
|
149
165
|
timeout=timeout,
|
|
166
|
+
env=env,
|
|
150
167
|
)
|
|
151
168
|
_run(
|
|
152
169
|
[
|
|
@@ -155,6 +172,7 @@ def ssh_host_put_file(
|
|
|
155
172
|
],
|
|
156
173
|
label="remote finalize env put",
|
|
157
174
|
timeout=timeout,
|
|
175
|
+
env=env,
|
|
158
176
|
)
|
|
159
177
|
return {
|
|
160
178
|
"path": destination,
|
|
@@ -174,6 +192,7 @@ def ssh_host_get_file(
|
|
|
174
192
|
output: Path | None,
|
|
175
193
|
sink: BinaryIO | None,
|
|
176
194
|
timeout: float | None,
|
|
195
|
+
env: dict[str, str] | None = None,
|
|
177
196
|
) -> dict[str, Any]:
|
|
178
197
|
source = validate_absolute_remote(remote_path, label="remote path")
|
|
179
198
|
if output is not None:
|
|
@@ -183,6 +202,7 @@ def ssh_host_get_file(
|
|
|
183
202
|
[*scp_argv, _remote_spec(ssh_host, source), str(output)],
|
|
184
203
|
label="scp env get",
|
|
185
204
|
timeout=timeout,
|
|
205
|
+
env=env,
|
|
186
206
|
)
|
|
187
207
|
return file_result(output, runtime="host")
|
|
188
208
|
if sink is not None:
|
|
@@ -190,7 +210,8 @@ def ssh_host_get_file(
|
|
|
190
210
|
[*ssh_argv, f"cat -- {shlex.quote(source)}"],
|
|
191
211
|
stdout=subprocess.PIPE,
|
|
192
212
|
stderr=subprocess.PIPE,
|
|
193
|
-
|
|
213
|
+
stdin=subprocess.DEVNULL,
|
|
214
|
+
**_run_kw(env),
|
|
194
215
|
)
|
|
195
216
|
assert process.stdout is not None
|
|
196
217
|
try:
|
|
@@ -210,7 +231,8 @@ def ssh_host_get_file(
|
|
|
210
231
|
capture_output=True,
|
|
211
232
|
check=False,
|
|
212
233
|
timeout=timeout,
|
|
213
|
-
|
|
234
|
+
stdin=subprocess.DEVNULL,
|
|
235
|
+
**_run_kw(env),
|
|
214
236
|
)
|
|
215
237
|
if completed.returncode != 0:
|
|
216
238
|
detail = (completed.stderr or b"").decode("utf-8", errors="replace").strip()
|
|
@@ -237,6 +259,7 @@ def ssh_docker_put_file(
|
|
|
237
259
|
local_path: Path,
|
|
238
260
|
remote_path: str,
|
|
239
261
|
timeout: float | None,
|
|
262
|
+
env: dict[str, str] | None = None,
|
|
240
263
|
) -> dict[str, Any]:
|
|
241
264
|
destination = validate_absolute_remote(remote_path, label="remote path")
|
|
242
265
|
parent = str(PurePosixPath(destination).parent)
|
|
@@ -245,6 +268,7 @@ def ssh_docker_put_file(
|
|
|
245
268
|
[*scp_argv, str(local_path), _remote_spec(ssh_host, staging)],
|
|
246
269
|
label="scp env docker put",
|
|
247
270
|
timeout=timeout,
|
|
271
|
+
env=env,
|
|
248
272
|
)
|
|
249
273
|
script = (
|
|
250
274
|
f"docker exec {shlex.quote(container)} "
|
|
@@ -253,7 +277,7 @@ def ssh_docker_put_file(
|
|
|
253
277
|
f"{shlex.quote(container)}:{shlex.quote(destination)} && "
|
|
254
278
|
f"rm -f -- {shlex.quote(staging)}"
|
|
255
279
|
)
|
|
256
|
-
_run([*ssh_argv, script], label="remote docker cp env put", timeout=timeout)
|
|
280
|
+
_run([*ssh_argv, script], label="remote docker cp env put", timeout=timeout, env=env)
|
|
257
281
|
return {
|
|
258
282
|
"path": destination,
|
|
259
283
|
"type": "file",
|
|
@@ -273,6 +297,7 @@ def ssh_docker_get_file(
|
|
|
273
297
|
output: Path | None,
|
|
274
298
|
sink: BinaryIO | None,
|
|
275
299
|
timeout: float | None,
|
|
300
|
+
env: dict[str, str] | None = None,
|
|
276
301
|
) -> dict[str, Any]:
|
|
277
302
|
source = validate_absolute_remote(remote_path, label="remote path")
|
|
278
303
|
if output is not None:
|
|
@@ -281,7 +306,12 @@ def ssh_docker_get_file(
|
|
|
281
306
|
f"docker cp -- {shlex.quote(container)}:{shlex.quote(source)} "
|
|
282
307
|
f"{shlex.quote(staging)}"
|
|
283
308
|
)
|
|
284
|
-
_run(
|
|
309
|
+
_run(
|
|
310
|
+
[*ssh_argv, script],
|
|
311
|
+
label="remote docker cp env get",
|
|
312
|
+
timeout=timeout,
|
|
313
|
+
env=env,
|
|
314
|
+
)
|
|
285
315
|
try:
|
|
286
316
|
output = output.expanduser()
|
|
287
317
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -289,6 +319,7 @@ def ssh_docker_get_file(
|
|
|
289
319
|
[*scp_argv, _remote_spec(ssh_host, staging), str(output)],
|
|
290
320
|
label="scp env docker get",
|
|
291
321
|
timeout=timeout,
|
|
322
|
+
env=env,
|
|
292
323
|
)
|
|
293
324
|
return file_result(output, runtime="docker")
|
|
294
325
|
finally:
|
|
@@ -296,6 +327,7 @@ def ssh_docker_get_file(
|
|
|
296
327
|
[*ssh_argv, f"rm -f -- {shlex.quote(staging)}"],
|
|
297
328
|
label="remote cleanup env docker get",
|
|
298
329
|
timeout=timeout,
|
|
330
|
+
env=env,
|
|
299
331
|
)
|
|
300
332
|
if sink is not None:
|
|
301
333
|
process = subprocess.Popen(
|
|
@@ -310,7 +342,8 @@ def ssh_docker_get_file(
|
|
|
310
342
|
],
|
|
311
343
|
stdout=subprocess.PIPE,
|
|
312
344
|
stderr=subprocess.PIPE,
|
|
313
|
-
|
|
345
|
+
stdin=subprocess.DEVNULL,
|
|
346
|
+
**_run_kw(env),
|
|
314
347
|
)
|
|
315
348
|
assert process.stdout is not None
|
|
316
349
|
try:
|
|
@@ -336,7 +369,8 @@ def ssh_docker_get_file(
|
|
|
336
369
|
capture_output=True,
|
|
337
370
|
check=False,
|
|
338
371
|
timeout=timeout,
|
|
339
|
-
|
|
372
|
+
stdin=subprocess.DEVNULL,
|
|
373
|
+
**_run_kw(env),
|
|
340
374
|
)
|
|
341
375
|
if completed.returncode != 0:
|
|
342
376
|
detail = (completed.stderr or b"").decode("utf-8", errors="replace").strip()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Wrap user exec argv that uses shell pipelines or operators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
OPERATOR_TOKENS = frozenset({"|", "||", "&&", ";", "&", ">", "<", ">>"})
|
|
11
|
+
_SHELL_SCRIPT_RE = re.compile(r"[|;&<>()`]|\$\(|\$\{|&&|\|\|")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _command_name(command: list[str]) -> str:
|
|
15
|
+
return Path(command[0]).name.lower()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def already_shelled(command: list[str]) -> bool:
|
|
19
|
+
if len(command) < 3:
|
|
20
|
+
return False
|
|
21
|
+
name = _command_name(command)
|
|
22
|
+
if name in {"sh", "bash", "zsh", "dash"}:
|
|
23
|
+
return command[1] in {"-c", "-lc", "-ic"}
|
|
24
|
+
if name in {"cmd", "cmd.exe"}:
|
|
25
|
+
return any(part.lower() in {"/c", "/k"} for part in command[1:3])
|
|
26
|
+
if name in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}:
|
|
27
|
+
return any(
|
|
28
|
+
part.lower() in {"-c", "-command", "-encodedcommand"}
|
|
29
|
+
for part in command[1:4]
|
|
30
|
+
)
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def needs_shell(command: list[str]) -> bool:
|
|
35
|
+
if not command or already_shelled(command):
|
|
36
|
+
return False
|
|
37
|
+
if len(command) == 1:
|
|
38
|
+
return bool(_SHELL_SCRIPT_RE.search(command[0]))
|
|
39
|
+
return any(item in OPERATOR_TOKENS for item in command)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def join_shell_argv(command: list[str], *, windows: bool) -> str:
|
|
43
|
+
if len(command) == 1:
|
|
44
|
+
return command[0]
|
|
45
|
+
parts: list[str] = []
|
|
46
|
+
for item in command:
|
|
47
|
+
if item in OPERATOR_TOKENS:
|
|
48
|
+
parts.append(item)
|
|
49
|
+
elif windows:
|
|
50
|
+
parts.append(subprocess.list2cmdline([item]))
|
|
51
|
+
else:
|
|
52
|
+
parts.append(shlex.quote(item))
|
|
53
|
+
return " ".join(parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def wrap_exec_argv(command: list[str], *, windows: bool) -> list[str]:
|
|
57
|
+
"""Run pipelines through a shell; leave plain argv unchanged."""
|
|
58
|
+
wrapped = list(command)
|
|
59
|
+
if not needs_shell(wrapped):
|
|
60
|
+
return wrapped
|
|
61
|
+
script = join_shell_argv(wrapped, windows=windows)
|
|
62
|
+
if windows:
|
|
63
|
+
return ["cmd.exe", "/s", "/c", script]
|
|
64
|
+
return ["sh", "-c", script]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def popen_exec_command(command: list[str]) -> str | list[str]:
|
|
68
|
+
"""Avoid a second list2cmdline pass around ``cmd /c`` scripts.
|
|
69
|
+
|
|
70
|
+
``subprocess.Popen(list)`` on Windows quotes each argument again, which
|
|
71
|
+
breaks nested quotes and ``|`` inside the script. Build the CreateProcess
|
|
72
|
+
string as ``cmd.exe /s /c "script"`` so cmd strips only the outer quotes.
|
|
73
|
+
"""
|
|
74
|
+
if len(command) < 4:
|
|
75
|
+
return command
|
|
76
|
+
if _command_name(command) not in {"cmd", "cmd.exe"}:
|
|
77
|
+
return command
|
|
78
|
+
if not any(part.lower() in {"/c", "/k"} for part in command[1:3]):
|
|
79
|
+
return command
|
|
80
|
+
switches = command[1:-1]
|
|
81
|
+
script = command[-1]
|
|
82
|
+
return subprocess.list2cmdline([command[0], *switches]) + ' "' + script + '"'
|
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
6
|
from typing import Any, Callable, Mapping
|
|
7
7
|
|
|
8
|
+
from ..compat import IS_WINDOWS
|
|
9
|
+
from ..exec_argv import wrap_exec_argv
|
|
8
10
|
from ..limits import (
|
|
9
11
|
DEFAULT_EXEC_TIMEOUT_SECONDS,
|
|
10
12
|
validate_exec_timeout_seconds,
|
|
@@ -57,6 +59,12 @@ class ExecutionService:
|
|
|
57
59
|
argv = tuple(request.argv)
|
|
58
60
|
if not argv or not all(isinstance(item, str) and item for item in argv):
|
|
59
61
|
raise ValueError("exec argv must be a non-empty string array")
|
|
62
|
+
argv = tuple(
|
|
63
|
+
wrap_exec_argv(
|
|
64
|
+
list(argv),
|
|
65
|
+
windows=environment == "local" and IS_WINDOWS,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
60
68
|
env: dict[str, str] = {}
|
|
61
69
|
for key, value in request.env.items():
|
|
62
70
|
if not isinstance(key, str) or not key:
|
|
@@ -35,6 +35,7 @@ class NodeConnectionResult:
|
|
|
35
35
|
state: str | None = None
|
|
36
36
|
persist: str | None = None
|
|
37
37
|
error: str | None = None
|
|
38
|
+
key: str | None = None
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
@dataclass(frozen=True)
|
|
@@ -156,6 +157,7 @@ class NodeService:
|
|
|
156
157
|
node.ssh_host,
|
|
157
158
|
state,
|
|
158
159
|
persist,
|
|
160
|
+
key=result.get("key") if action == "connect" else None,
|
|
159
161
|
)
|
|
160
162
|
)
|
|
161
163
|
except (RpcError, OSError) as exc:
|