From f71558aff68a7ad04b3f7b9504d1b98b670acd83 Mon Sep 17 00:00:00 2001 From: didericis Date: Sat, 11 Jul 2026 18:57:33 -0400 Subject: [PATCH] fix(firecracker): run the agent from /home/node, not the /root SSH cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control SSH logs in as root, so the agent (dropped to node via runuser) inherited cwd=/root. That was harmless while the rootless rootfs left /root node-owned, but the dropbear fix (chown /root → root:root 0700) made /root unreadable to node — so the agent's cwd was inaccessible, breaking Node's process.cwd(), Claude Code's shell-snapshot machinery, and `/doctor` (which reported /root/.claude EACCES and failed every Bash command). Run the agent from its workdir (default /home/node) via `env --chdir`. Not a `sh -c 'cd … && exec "$@"'` wrapper: ssh space-joins everything after the host into one string for the guest shell, so the quoted script + $@ get re-split and mangled (it exec'd the $0 placeholder → exit 127). `env --chdir=DIR …` is all simple words, so it survives the join. Verified end-to-end: a real headless claude bottle now runs its Bash tool and exits 0 (was EACCES/127 before). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/backend/firecracker/bottle.py | 25 +++++++++++++++++++----- tests/unit/test_firecracker_backend.py | 14 +++++++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/bot_bottle/backend/firecracker/bottle.py b/bot_bottle/backend/firecracker/bottle.py index c8bc62b..eef9132 100644 --- a/bot_bottle/backend/firecracker/bottle.py +++ b/bot_bottle/backend/firecracker/bottle.py @@ -93,12 +93,27 @@ class FirecrackerBottle(Bottle): argv=full_argv, ) ) + # ALWAYS cd into the workdir before exec'ing the agent. The + # control SSH logs in as root, so the inherited cwd is /root — + # which the agent (running as node) can't even read now that + # /root is root-owned. Run the agent from the node workdir + # (default /home/node) so its cwd is node-accessible; otherwise + # Node's process.cwd(), the shell-snapshot machinery, and + # `/doctor` all fail on the unreadable /root. + # Run the agent from the node workdir (default /home/node), NOT + # the /root cwd inherited from the root SSH login — /root is now + # 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. + workdir = self.agent_workdir or _HOME_FOR["node"] remote = ["runuser", "-u", "node", "--", - "env", *_env_assignments_for("node", self._guest_env)] - if self.agent_workdir and self.agent_workdir != _HOME_FOR["node"]: - remote += ["sh", "-lc", f"cd {self.agent_workdir} && exec \"$@\"", - "bot-bottle-agent"] - remote += [self.agent_command, *full_argv] + "env", f"--chdir={workdir}", + *_env_assignments_for("node", self._guest_env), + self.agent_command, *full_argv] return remote def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]: diff --git a/tests/unit/test_firecracker_backend.py b/tests/unit/test_firecracker_backend.py index 3adb065..4e3d5a9 100644 --- a/tests/unit/test_firecracker_backend.py +++ b/tests/unit/test_firecracker_backend.py @@ -235,11 +235,17 @@ class TestBottleAgentArgv(unittest.TestCase): argv[idx:], ) - def test_workdir_wraps_command(self): + 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). argv = _bottle(agent_workdir="/home/node/workspace").agent_argv([], tty=False) - self.assertIn("sh", argv) - joined = " ".join(argv) - self.assertIn("cd /home/node/workspace", joined) + self.assertIn("--chdir=/home/node/workspace", argv) + + def test_default_workdir_still_chdirs_off_root(self): + # Even with the default workdir the agent must leave /root (the + # root-SSH cwd it can't read); it cd's to /home/node. + argv = _bottle().agent_argv([], tty=False) + self.assertIn("--chdir=/home/node", argv) def test_guest_env_injected(self): argv = _bottle(guest_env={"HTTPS_PROXY": "http://100.64.0.0:9099"}).agent_argv(