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.
Files changed (76) hide show
  1. {msdev-0.10.1 → msdev-0.11.0}/PKG-INFO +28 -2
  2. {msdev-0.10.1 → msdev-0.11.0}/README.md +27 -1
  3. {msdev-0.10.1 → msdev-0.11.0}/pyproject.toml +1 -1
  4. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/__init__.py +1 -1
  5. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/cli.py +65 -13
  6. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/compat.py +40 -0
  7. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/env_transfer.py +42 -8
  8. msdev-0.11.0/src/msdev/core/exec_argv.py +82 -0
  9. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/execution.py +8 -0
  10. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/node.py +2 -0
  11. msdev-0.11.0/src/msdev/core/ssh_auth.py +395 -0
  12. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/ssh_tunnel.py +7 -1
  13. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/stream_transport.py +13 -3
  14. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/transport.py +90 -9
  15. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/daemon.py +4 -1
  16. {msdev-0.10.1 → msdev-0.11.0}/src/msdev.egg-info/PKG-INFO +28 -2
  17. {msdev-0.10.1 → msdev-0.11.0}/src/msdev.egg-info/SOURCES.txt +4 -0
  18. {msdev-0.10.1 → msdev-0.11.0}/tests/test_cli.py +25 -0
  19. {msdev-0.10.1 → msdev-0.11.0}/tests/test_compat.py +19 -0
  20. {msdev-0.10.1 → msdev-0.11.0}/tests/test_daemon.py +24 -0
  21. {msdev-0.10.1 → msdev-0.11.0}/tests/test_env_transfer.py +27 -0
  22. msdev-0.11.0/tests/test_exec_argv.py +55 -0
  23. msdev-0.11.0/tests/test_ssh_auth.py +515 -0
  24. {msdev-0.10.1 → msdev-0.11.0}/tests/test_target_exec_services.py +73 -1
  25. {msdev-0.10.1 → msdev-0.11.0}/tests/test_transport.py +2 -0
  26. {msdev-0.10.1 → msdev-0.11.0}/tests/test_windows_local_scenario.py +37 -0
  27. {msdev-0.10.1 → msdev-0.11.0}/LICENSE +0 -0
  28. {msdev-0.10.1 → msdev-0.11.0}/setup.cfg +0 -0
  29. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/__init__.py +0 -0
  30. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/config.py +0 -0
  31. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/container_helper.py +0 -0
  32. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/guides.py +0 -0
  33. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/helper_deploy.py +0 -0
  34. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/host_files.py +0 -0
  35. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/host_mutations.py +0 -0
  36. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/inventory.py +0 -0
  37. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/limits.py +0 -0
  38. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/local_rpc.py +0 -0
  39. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/multiplex.py +0 -0
  40. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/request_client.py +0 -0
  41. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/resources.py +0 -0
  42. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/__init__.py +0 -0
  43. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/capabilities.py +0 -0
  44. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/context.py +0 -0
  45. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/environment.py +0 -0
  46. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/model.py +0 -0
  47. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/npu.py +0 -0
  48. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/services/results.py +0 -0
  49. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/stream_limits.py +0 -0
  50. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/stream_server.py +0 -0
  51. {msdev-0.10.1 → msdev-0.11.0}/src/msdev/core/streamproto.py +0 -0
  52. {msdev-0.10.1 → msdev-0.11.0}/src/msdev.egg-info/dependency_links.txt +0 -0
  53. {msdev-0.10.1 → msdev-0.11.0}/src/msdev.egg-info/entry_points.txt +0 -0
  54. {msdev-0.10.1 → msdev-0.11.0}/src/msdev.egg-info/top_level.txt +0 -0
  55. {msdev-0.10.1 → msdev-0.11.0}/tests/test_cli_only_architecture.py +0 -0
  56. {msdev-0.10.1 → msdev-0.11.0}/tests/test_config.py +0 -0
  57. {msdev-0.10.1 → msdev-0.11.0}/tests/test_container_helper.py +0 -0
  58. {msdev-0.10.1 → msdev-0.11.0}/tests/test_daemon_container_stream.py +0 -0
  59. {msdev-0.10.1 → msdev-0.11.0}/tests/test_docker_stream_dispatch.py +0 -0
  60. {msdev-0.10.1 → msdev-0.11.0}/tests/test_env_put_get_scenario.py +0 -0
  61. {msdev-0.10.1 → msdev-0.11.0}/tests/test_env_read_write_scenario.py +0 -0
  62. {msdev-0.10.1 → msdev-0.11.0}/tests/test_exec_default_timeout_scenario.py +0 -0
  63. {msdev-0.10.1 → msdev-0.11.0}/tests/test_helper_deploy.py +0 -0
  64. {msdev-0.10.1 → msdev-0.11.0}/tests/test_inventory.py +0 -0
  65. {msdev-0.10.1 → msdev-0.11.0}/tests/test_model_cli.py +0 -0
  66. {msdev-0.10.1 → msdev-0.11.0}/tests/test_model_services.py +0 -0
  67. {msdev-0.10.1 → msdev-0.11.0}/tests/test_msdev_cli_skill.py +0 -0
  68. {msdev-0.10.1 → msdev-0.11.0}/tests/test_multiplex.py +0 -0
  69. {msdev-0.10.1 → msdev-0.11.0}/tests/test_node_env_config.py +0 -0
  70. {msdev-0.10.1 → msdev-0.11.0}/tests/test_node_env_services.py +0 -0
  71. {msdev-0.10.1 → msdev-0.11.0}/tests/test_request_client.py +0 -0
  72. {msdev-0.10.1 → msdev-0.11.0}/tests/test_ssh_tunnel.py +0 -0
  73. {msdev-0.10.1 → msdev-0.11.0}/tests/test_stream_server.py +0 -0
  74. {msdev-0.10.1 → msdev-0.11.0}/tests/test_stream_transport.py +0 -0
  75. {msdev-0.10.1 → msdev-0.11.0}/tests/test_streamproto.py +0 -0
  76. {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.10.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "msdev"
7
- version = "0.10.1"
7
+ version = "0.11.0"
8
8
  description = "Portable nodes and execution environments for msModelSlim development"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """Portable node and execution-environment tooling for msModelSlim."""
2
2
 
3
- __version__ = "0.10.1"
3
+ __version__ = "0.11.0"
@@ -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
- destination = (
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 and prompt once for authentication",
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(argv: list[str], *, label: str, timeout: float | None) -> None:
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
- **popen_kwargs(hidden=True, new_group=False),
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
- **popen_kwargs(hidden=True, new_group=False),
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
- **popen_kwargs(hidden=True, new_group=False),
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([*ssh_argv, script], label="remote docker cp env get", timeout=timeout)
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
- **popen_kwargs(hidden=True, new_group=False),
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
- **popen_kwargs(hidden=True, new_group=False),
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: