fix(firecracker): quote guest argv tokens so codex's multi-word prompt survives ssh

The interactive agent command is sent to the guest by spreading the
remote argv as separate ssh arguments; ssh space-joins everything after
the host into one line that the guest login shell re-parses. That only
works while every token is a "simple word" — which held for claude
(`--append-system-prompt-file <path>`) but not for codex's
`read_prompt_file` mode, whose positional is a whole sentence:
"Read and follow the instructions in <path>.". The guest shell re-split
it on spaces, so codex received `Read` as the prompt and `and`, `follow`,
… as extra args — failing with `unrecognized subcommand 'and'` the moment
an interactive codex session attached.

Pre-quote each remote token with shlex.quote before ssh joins them (the
same ssh→guest-shell discipline infra_vm/cp_in already use). Simple words
are unchanged, so existing behaviour and the parity/structure tests are
untouched; an arg with spaces now survives as a single argument.

Regression test round-trips the joined remote command back through
shlex.split and asserts codex's prompt comes out as exactly one arg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-16 18:01:59 -04:00
parent 7118480d0a
commit c0066d2cd2
2 changed files with 36 additions and 5 deletions
+12 -5
View File
@@ -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 <path>." — 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)
+24
View File
@@ -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 <path>."). 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).