From ee1f55635f193a7de8ada3802adfe13cf1b17608 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 23 Apr 2026 05:16:08 +0600 Subject: [PATCH] fix: wrap commands in /bin/sh -c for proper server-side argv expansion The server-side agent runs commands through a nice wrapper that uses "${@}" expansion. Sending the full command string as a single cmd field caused nice to treat it as one executable name. Now Commands.run sends cmd=/bin/sh args=["-c", cmd_string] so "${@}" expands into proper argv. --- src/wrenn/commands.py | 26 +++++-- tests/test_git.py | 155 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 6 deletions(-) diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py index c42f136..4cb005d 100644 --- a/src/wrenn/commands.py +++ b/src/wrenn/commands.py @@ -183,7 +183,11 @@ class Commands: CommandHandle: PID and tag for background commands (``background=True``). """ - payload: dict = {"cmd": cmd, "background": background} + payload: dict = { + "cmd": "/bin/sh", + "args": ["-c", cmd], + "background": background, + } if timeout is not None and not background: payload["timeout_sec"] = timeout if envs is not None: @@ -271,6 +275,8 @@ class Commands: Args: cmd (str): Command to execute. args (list[str] | None): Additional arguments for the command. + When omitted, *cmd* is interpreted as a shell command + string and executed via ``/bin/sh -c``. Yields: StreamEvent: Successive events including :class:`StreamStartEvent`, @@ -281,9 +287,10 @@ class Commands: f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, ) as ws: - start_msg: dict = {"type": "start", "cmd": cmd} if args: - start_msg["args"] = args + start_msg: dict = {"type": "start", "cmd": cmd, "args": args} + else: + start_msg = {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]} ws.send_text(json.dumps(start_msg)) while True: try: @@ -359,7 +366,11 @@ class AsyncCommands: CommandHandle: PID and tag for background commands (``background=True``). """ - payload: dict = {"cmd": cmd, "background": background} + payload: dict = { + "cmd": "/bin/sh", + "args": ["-c", cmd], + "background": background, + } if timeout is not None and not background: payload["timeout_sec"] = timeout if envs is not None: @@ -449,6 +460,8 @@ class AsyncCommands: Args: cmd (str): Command to execute. args (list[str] | None): Additional arguments for the command. + When omitted, *cmd* is interpreted as a shell command + string and executed via ``/bin/sh -c``. Yields: StreamEvent: Successive events including :class:`StreamStartEvent`, @@ -459,9 +472,10 @@ class AsyncCommands: f"/v1/capsules/{self._capsule_id}/exec/stream", self._http, ) as ws: - start_msg: dict = {"type": "start", "cmd": cmd} if args: - start_msg["args"] = args + start_msg: dict = {"type": "start", "cmd": cmd, "args": args} + else: + start_msg = {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]} await ws.send_text(json.dumps(start_msg)) try: while True: diff --git a/tests/test_git.py b/tests/test_git.py index 1fc7463..29c9e12 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + import pytest import respx from httpx import Response @@ -942,3 +944,156 @@ class TestAsyncGit: git = _make_async_git() branches = await git.branches(cwd="/repo") assert len(branches) == 2 + + +# ════════════════════════════════��═════════════════════════════════ +# Command payload tests — verify /bin/sh -c wrapping +# ════════════════════════════���══════════════════════���══════════════ + + +class TestCommandPayloadWrapping: + """Verify that Commands.run sends cmd=/bin/sh args=['-c', cmd_string] + so the server-side wrapper expands "${@}" into proper argv.""" + + @respx.mock + def test_simple_command(self): + route = respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="hello world\n" + )) + git = _make_git() + git.init("/repo") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", git_cmd_from_body(body)] + # args[1] should contain the actual git command + assert body["args"][0] == "-c" + assert "git" in body["args"][1] + + @respx.mock + def test_command_with_pipes(self): + """Pipes and redirects work because /bin/sh interprets them.""" + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response( + stdout="3\n" + )) + commands.run("cat /etc/passwd | wc -l") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "cat /etc/passwd | wc -l"] + + @respx.mock + def test_command_with_semicolons(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + commands.run("cd /tmp; ls -la && echo done") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "cd /tmp; ls -la && echo done"] + + @respx.mock + def test_command_with_env_vars(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + commands.run("FOO=bar echo $FOO") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "FOO=bar echo $FOO"] + + @respx.mock + def test_command_with_subshell(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + commands.run("echo $(date +%s)") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "echo $(date +%s)"] + + @respx.mock + def test_command_with_quotes_and_spaces(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + commands.run("""echo "hello 'world'" | grep -o "'[^']*'" """) + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"][0] == "-c" + # The command string is passed verbatim — shell interprets it + assert "hello 'world'" in body["args"][1] + + @respx.mock + def test_heredoc_style_command(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + commands.run("python3 -c 'import sys; print(sys.version)'") + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "python3 -c 'import sys; print(sys.version)'"] + + @respx.mock + def test_git_shlex_joined_command(self): + """Git module uses shlex.join — verify it passes through correctly.""" + route = respx.post(EXEC_URL).respond(200, json=_exec_response()) + git = _make_git() + git.clone("https://github.com/user/repo.git", "/tmp/repo", depth=1) + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"][0] == "-c" + # shlex.join produces: git clone --depth 1 https://... /tmp/repo + shell_cmd = body["args"][1] + assert "git" in shell_cmd + assert "clone" in shell_cmd + assert "--depth" in shell_cmd + assert "https://github.com/user/repo.git" in shell_cmd + + @respx.mock + def test_background_command_also_wrapped(self): + from wrenn.client import WrennClient + from wrenn.commands import Commands + + client = WrennClient(api_key="wrn_test1234567890abcdef12345678") + commands = Commands(CAPSULE_ID, client.http) + + route = respx.post(EXEC_URL).respond(200, json={ + "pid": 42, "tag": "bg-1" + }) + commands.run("tail -f /var/log/syslog", background=True) + body = json.loads(route.calls[0].request.content) + assert body["cmd"] == "/bin/sh" + assert body["args"] == ["-c", "tail -f /var/log/syslog"] + assert body["background"] is True + + +def git_cmd_from_body(body: dict) -> str: + """Extract the shell command string from a wrapped payload.""" + assert body["cmd"] == "/bin/sh" + assert body["args"][0] == "-c" + return body["args"][1]