diff --git a/packages/coding-agent/.changes/ssh-control-stdin-piping.md b/packages/coding-agent/.changes/ssh-control-stdin-piping.md new file mode 100644 index 000000000..2a948c045 --- /dev/null +++ b/packages/coding-agent/.changes/ssh-control-stdin-piping.md @@ -0,0 +1,2 @@ +- Added stdin piping to ssh-control with `run(..., input=...)` accepting `str`, `bytes`, `BashHandle`, and `BashStream`. +- Changed ssh-control guidance to re-ensure the master on `Connection closed by UNKNOWN port 65535` instead of falling back to plain ssh. diff --git a/packages/coding-agent/skills/ssh-control/SKILL.md b/packages/coding-agent/skills/ssh-control/SKILL.md index 8a8b0b6be..432dee8eb 100644 --- a/packages/coding-agent/skills/ssh-control/SKILL.md +++ b/packages/coding-agent/skills/ssh-control/SKILL.md @@ -15,6 +15,9 @@ h.pid h.tail(20) result = await h # BashResult(exit_code, output, duration) +piped = conn.run("cat > /tmp/patch", input=patch_text) +await piped + upload = conn.copy_to("./bundle.tar", "/tmp/bundle.tar") await upload @@ -38,5 +41,6 @@ Rules: - Keep the master open across related calls. Close it during final cleanup. - `run`, `copy_to`, `copy_from`, and `close` use the ordinary `BashHandle` lifecycle: `.pid`, `.running`, `.tail()`, `.output()`, `.poll()`, `.kill()`, and `await`. - Remote command strings are interpreted by the remote login shell. Do not interpolate untrusted text; quote values deliberately. -- Command methods never authenticate, reconnect, or retry behind the handle. If a master expires, explicitly call `await ssh_control.ensure(...)` again. Never blindly retry a command after an ambiguous disconnect because it may already have executed remotely. +- Command methods never authenticate, reconnect, or retry behind the handle. If a master expires — `Connection closed by UNKNOWN port 65535` from a run or copy is the tell — explicitly call `await ssh_control.ensure(...)` again. Never fall back to plain `ssh` mid-task, and never blindly retry a command after an ambiguous disconnect because it may already have executed remotely. +- `run(..., input=...)` pipes stdin through the native handle; it accepts whatever `bash()` accepts (`str`, `bytes`, `BashHandle`, `BashStream`) and is binary-safe. - Module-level cleanup is `h = ssh_control.close(...)`; await or inspect that handle normally. diff --git a/packages/coding-agent/skills/ssh-control/src/ssh_control/__init__.py b/packages/coding-agent/skills/ssh-control/src/ssh_control/__init__.py index 7ccd385be..016a161fb 100644 --- a/packages/coding-agent/skills/ssh-control/src/ssh_control/__init__.py +++ b/packages/coding-agent/skills/ssh-control/src/ssh_control/__init__.py @@ -13,13 +13,17 @@ from pathlib import Path from typing import Any try: - from rlm.bash import BashHandle, bash as _bash + from rlm.bash import BashHandle, BashStream, bash as _bash except ModuleNotFoundError as error: if error.name not in {"rlm", "rlm.bash"}: raise BashHandle = Any + BashStream = Any - def _bash(command: str) -> Any: + def _bash( + command: str, + input: str | bytes | BashHandle | BashStream | None = None, + ) -> Any: raise RuntimeError("ssh_control command methods require the Prime Agent runtime") _OPTION_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") @@ -72,7 +76,12 @@ class Connection: persist: str = "30m" reused: bool = False - def run(self, command: str) -> BashHandle: + def run( + self, + command: str, + *, + input: str | bytes | BashHandle | BashStream | None = None, + ) -> BashHandle: """Start a remote command and return the native `bash()` handle.""" if not command or "\x00" in command: raise ValueError("remote command must be non-empty and contain no NUL") @@ -84,7 +93,7 @@ class Connection: self.destination, command, ] - return _bash(shlex.join(args)) + return _bash(shlex.join(args), input=input) def copy_to( self, @@ -425,6 +434,7 @@ def run( destination: str, command: str, *, + input: str | bytes | BashHandle | BashStream | None = None, port: int | None = None, identity_file: str | Path | None = None, proxy_jump: str | None = None, @@ -444,7 +454,7 @@ def run( options=options, persist=persist, ) - return connection.run(command) + return connection.run(command, input=input) def close( diff --git a/packages/coding-agent/skills/ssh-control/tests/test_api.py b/packages/coding-agent/skills/ssh-control/tests/test_api.py index d87935db0..cf71a604e 100644 --- a/packages/coding-agent/skills/ssh-control/tests/test_api.py +++ b/packages/coding-agent/skills/ssh-control/tests/test_api.py @@ -119,6 +119,21 @@ class SSHControlTests(unittest.TestCase): self.assertIn("user@example.test", command) self.assertIn("printf", command) + def test_run_forwards_stdin_to_the_native_handle(self): + connection = ssh_control._connection( + "user@example.test", + port=None, + identity_file=None, + proxy_jump=None, + options=None, + persist="30m", + ) + with patch.object(ssh_control, "_bash", return_value=sentinel.handle) as start: + connection.run("cat > out", input="hello") + ssh_control.run("user@example.test", "cat > out", input=b"\x00 binary") + self.assertEqual(start.call_args_list[0].kwargs["input"], "hello") + self.assertEqual(start.call_args_list[1].kwargs["input"], b"\x00 binary") + def test_copy_and_close_return_native_bash_handles(self): connection = ssh_control._connection( "user@example.test",