diff --git a/bot_bottle/backend/firecracker/bottle.py b/bot_bottle/backend/firecracker/bottle.py index b8bee3f..f522939 100644 --- a/bot_bottle/backend/firecracker/bottle.py +++ b/bot_bottle/backend/firecracker/bottle.py @@ -105,10 +105,9 @@ class FirecrackerBottle(Bottle): # root-owned and unreadable by node, which breaks Node's # process.cwd(), the shell-snapshot machinery, and `/doctor`. # Use `env --chdir` rather than a `sh -c 'cd … && exec "$@"'` - # wrapper: ssh space-joins everything after the host into one - # string for the guest shell, so a quoted script + $@ would be - # re-split and mangled (exec'ing the $0 placeholder). All-simple - # words survive that join. + # wrapper: it keeps the guest command a flat argv that `agent_argv` + # can quote token-by-token for the ssh→guest-shell round trip, + # avoiding a fragile nested-quoting `"$@"` script. workdir = self.agent_workdir or _HOME_FOR["node"] remote = ["runuser", "-u", "node", "--", "env", f"--chdir={workdir}", @@ -117,7 +116,15 @@ class FirecrackerBottle(Bottle): return remote def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]: - return [*self._ssh(tty=tty), "--", *self._agent_remote_argv(argv)] + # ssh space-joins everything after the host into one line the guest + # shell re-parses, so pre-quote each remote token for that shell. + # Simple words are unchanged (existing behaviour); an arg containing + # spaces — e.g. codex's `read_prompt_file` positional "Read and follow + # the instructions in ." — is quoted so it survives as ONE + # argument instead of being re-split (which made codex parse "and" as + # a subcommand). + remote = self._agent_remote_argv(argv) + return [*self._ssh(tty=tty), "--", *(shlex.quote(t) for t in remote)] def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: agent_argv = self.agent_argv(argv, tty=tty) diff --git a/tests/unit/test_firecracker_backend.py b/tests/unit/test_firecracker_backend.py index 32603d4..f5b91b4 100644 --- a/tests/unit/test_firecracker_backend.py +++ b/tests/unit/test_firecracker_backend.py @@ -255,6 +255,30 @@ class TestBottleAgentArgv(unittest.TestCase): argv[idx:], ) + def test_codex_multiword_prompt_survives_ssh_reparse(self): + # codex's read_prompt_file mode passes a single positional with + # spaces ("Read and follow the instructions in ."). ssh + # space-joins the remote argv and the guest shell re-splits it, so + # the token MUST be quoted or codex sees "and" as a subcommand + # (regression). Each remote token is shlex.quote'd; round-tripping + # the joined remote command back through shlex.split must recover + # the prompt as ONE argument. + import shlex + + argv = _bottle( + agent_command="codex", + agent_prompt_mode="read_prompt_file", + agent_provider_template="codex", + prompt_path_in_guest="/home/node/.bot-bottle-prompt.txt", + ).agent_argv([], tty=False) + idx = argv.index("--") + remote_line = " ".join(argv[idx + 1:]) # what ssh sends to the guest + reparsed = shlex.split(remote_line) # what the guest shell sees + prompt = "Read and follow the instructions in /home/node/.bot-bottle-prompt.txt." + self.assertIn(prompt, reparsed) + # codex is the last simple token before the (single) prompt arg. + self.assertEqual([*reparsed[reparsed.index("codex"):]], ["codex", prompt]) + def test_workdir_sets_chdir(self): # The agent runs from its workdir via `env --chdir` (ssh-safe; # not a `sh -c 'cd …'` wrapper, which the ssh arg-join mangles).