diff --git a/src/wrenn/commands.py b/src/wrenn/commands.py index dece7f7..9ba2727 100644 --- a/src/wrenn/commands.py +++ b/src/wrenn/commands.py @@ -120,8 +120,7 @@ def _build_exec_payload( tag: str | None, ) -> dict: payload: dict = { - "cmd": "/bin/sh", - "args": ["-c", cmd], + "cmd": cmd, "background": background, } if timeout is not None and not background: @@ -156,7 +155,7 @@ def _decode_exec_run( def _build_stream_start(cmd: str, args: builtins.list[str] | None) -> dict: if args: return {"type": "start", "cmd": cmd, "args": args} - return {"type": "start", "cmd": "/bin/sh", "args": ["-c", cmd]} + return {"type": "start", "cmd": cmd} def _decode_exec_response(data: dict) -> CommandResult: @@ -218,7 +217,9 @@ class Commands: """Execute a shell command inside the capsule. Args: - cmd (str): Shell command string to execute. + cmd (str): Command string to execute. Sent verbatim to the + server — no shell wrapping is applied. Provide a full + shell-quoted command if you need shell features. background (bool): If ``True``, launch the process in the background and return a :class:`CommandHandle` immediately. Defaults to ``False``. @@ -308,10 +309,11 @@ class Commands: """Execute a command via WebSocket, streaming output as events. 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``. + cmd (str): Command to execute. Sent verbatim to the server as + the full command string. + args (list[str] | None): Optional explicit argument vector. When + provided, *cmd* is treated as the program and *args* as its + argv tail. When omitted, *cmd* is forwarded as-is. Yields: StreamEvent: Successive events including :class:`StreamStartEvent`, @@ -378,7 +380,9 @@ class AsyncCommands: """Execute a shell command inside the capsule. Args: - cmd (str): Shell command string to execute. + cmd (str): Command string to execute. Sent verbatim to the + server — no shell wrapping is applied. Provide a full + shell-quoted command if you need shell features. background (bool): If ``True``, launch the process in the background and return a :class:`CommandHandle` immediately. Defaults to ``False``. @@ -470,10 +474,11 @@ class AsyncCommands: """Execute a command via WebSocket, streaming output as events. 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``. + cmd (str): Command to execute. Sent verbatim to the server as + the full command string. + args (list[str] | None): Optional explicit argument vector. When + provided, *cmd* is treated as the program and *args* as its + argv tail. When omitted, *cmd* is forwarded as-is. Yields: StreamEvent: Successive events including :class:`StreamStartEvent`, diff --git a/tests/test_commands.py b/tests/test_commands.py index d2d304d..45ff340 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -145,8 +145,8 @@ class TestRunPayload: route = respx.post(EXEC_URL).respond(200, json={"stdout": "hi", "exit_code": 0}) result = _make_commands().run("echo hi") body = json.loads(route.calls[0].request.content) - assert body["cmd"] == "/bin/sh" - assert body["args"] == ["-c", "echo hi"] + assert body["cmd"] == "echo hi" + assert "args" not in body assert body["background"] is False assert body["timeout_sec"] == 30 assert result.stdout == "hi" @@ -361,12 +361,12 @@ def _patch_async_ws(monkeypatch, ws: _AsyncFakeWS) -> None: class TestStream: - def test_stream_sends_shell_wrapped_start(self, monkeypatch): + def test_stream_sends_cmd_verbatim(self, monkeypatch): ws = _FakeWS([{"type": "exit", "exit_code": 0}]) _patch_sync_ws(monkeypatch, ws) list(_make_commands().stream("echo hi")) start = json.loads(ws.sent[0]) - assert start == {"type": "start", "cmd": "/bin/sh", "args": ["-c", "echo hi"]} + assert start == {"type": "start", "cmd": "echo hi"} def test_stream_with_explicit_args(self, monkeypatch): ws = _FakeWS([{"type": "exit", "exit_code": 0}]) @@ -480,7 +480,8 @@ class TestAsyncCommands: events = [e async for e in _make_async_commands().stream("echo out")] assert [e.type for e in events] == ["start", "stdout", "exit"] start = json.loads(ws.sent[0]) - assert start["cmd"] == "/bin/sh" + assert start["cmd"] == "echo out" + assert "args" not in start @pytest.mark.asyncio async def test_async_connect(self, monkeypatch): diff --git a/tests/test_git.py b/tests/test_git.py index 2d1dcce..f54a253 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -988,14 +988,14 @@ class TestAsyncGit: assert len(branches) == 2 -# ════════════════════════════════��═════════════════════════════════ -# Command payload tests — verify /bin/sh -c wrapping -# ════════════════════════════���══════════════════════���══════════════ +# ══════════════════════════════════════════════════════════════════ +# Command payload tests — verify cmd passed verbatim, no sh -c wrap +# ══════════════════════════════════════════════════════════════════ class TestCommandPayloadWrapping: - """Verify that Commands.run sends cmd=/bin/sh args=['-c', cmd_string] - so the server-side wrapper expands "${@}" into proper argv.""" + """Verify that Commands.run sends the full command string as ``cmd`` + with no auto ``/bin/sh -c`` wrapping or args splitting.""" @respx.mock def test_simple_command(self): @@ -1005,15 +1005,13 @@ class TestCommandPayloadWrapping: 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] + assert "args" not in body + assert "git" in body["cmd"] + assert "init" in body["cmd"] @respx.mock def test_command_with_pipes(self): - """Pipes and redirects work because /bin/sh interprets them.""" + """Shell metacharacters are forwarded verbatim — caller owns shell semantics.""" from wrenn.client import WrennClient from wrenn.commands import Commands @@ -1023,8 +1021,8 @@ class TestCommandPayloadWrapping: 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"] + assert body["cmd"] == "cat /etc/passwd | wc -l" + assert "args" not in body @respx.mock def test_command_with_semicolons(self): @@ -1037,8 +1035,8 @@ class TestCommandPayloadWrapping: 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"] + assert body["cmd"] == "cd /tmp; ls -la && echo done" + assert "args" not in body @respx.mock def test_command_with_env_vars(self): @@ -1051,8 +1049,8 @@ class TestCommandPayloadWrapping: 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"] + assert body["cmd"] == "FOO=bar echo $FOO" + assert "args" not in body @respx.mock def test_command_with_subshell(self): @@ -1065,8 +1063,8 @@ class TestCommandPayloadWrapping: 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)"] + assert body["cmd"] == "echo $(date +%s)" + assert "args" not in body @respx.mock def test_command_with_quotes_and_spaces(self): @@ -1079,10 +1077,8 @@ class TestCommandPayloadWrapping: 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] + assert "args" not in body + assert "hello 'world'" in body["cmd"] @respx.mock def test_heredoc_style_command(self): @@ -1095,20 +1091,18 @@ class TestCommandPayloadWrapping: 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)'"] + assert body["cmd"] == "python3 -c 'import sys; print(sys.version)'" + assert "args" not in body @respx.mock def test_git_shlex_joined_command(self): - """Git module uses shlex.join — verify it passes through correctly.""" + """Git module uses shlex.join — verify it passes through as cmd.""" 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 "args" not in body + shell_cmd = body["cmd"] assert "git" in shell_cmd assert "clone" in shell_cmd assert "--depth" in shell_cmd @@ -1125,13 +1119,12 @@ class TestCommandPayloadWrapping: 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["cmd"] == "tail -f /var/log/syslog" + assert "args" not in body 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] + """Extract the command string from the payload.""" + assert "args" not in body + return body["cmd"]