diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index 42eb3f7..c542052 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -119,12 +119,20 @@ class Bottle(ABC): def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ... @abstractmethod - def exec(self, script: str) -> ExecResult: - """Run `script` as a POSIX shell script inside the bottle and - return the captured stdout/stderr/returncode. The bottle's - environment (including HTTPS_PROXY pointing at the pipelock - sidecar) is inherited by the child. Non-zero exit does not - raise — callers inspect `returncode` themselves.""" + def exec(self, script: str, *, user: str = "node") -> ExecResult: + """Run `script` as a POSIX shell script inside the bottle as + `user` (default `node`, matching the agent image's USER + directive) and return the captured stdout/stderr/returncode. + The bottle's environment (including HTTPS_PROXY pointing at + the pipelock sidecar) is inherited by the child. Non-zero + exit does not raise — callers inspect `returncode` + themselves. + + Pass `user="root"` for shell-outs that need privileged file + writes / package install — provisioning calls that need root + bypass `Bottle.exec` and use the backend-specific raw + machine-exec helper, but the tests have a legitimate use + case for arbitrary-user runs.""" @abstractmethod def cp_in(self, host_path: str, container_path: str) -> None: ... diff --git a/claude_bottle/backend/docker/bottle.py b/claude_bottle/backend/docker/bottle.py index 4670748..e0d421c 100644 --- a/claude_bottle/backend/docker/bottle.py +++ b/claude_bottle/backend/docker/bottle.py @@ -51,12 +51,15 @@ class DockerBottle(Bottle): self.claude_docker_argv(argv, tty=tty), check=False, ).returncode - def exec(self, script: str) -> ExecResult: + def exec(self, script: str, *, user: str = "node") -> ExecResult: # Pipe via stdin to `sh -s` so the caller never has to worry # about quoting; the script source lands inside the container - # without crossing argv. + # without crossing argv. `-u ` overrides the image's + # default USER — defaults to `node` which is already the + # image's USER, so the explicit flag is a no-op there but + # keeps the cross-backend contract uniform. result = subprocess.run( - ["docker", "exec", "-i", self.name, "sh", "-s"], + ["docker", "exec", "-u", user, "-i", self.name, "sh", "-s"], input=script, capture_output=True, text=True, diff --git a/claude_bottle/backend/smolmachines/bottle.py b/claude_bottle/backend/smolmachines/bottle.py index 8a5dbdd..f2bfbb7 100644 --- a/claude_bottle/backend/smolmachines/bottle.py +++ b/claude_bottle/backend/smolmachines/bottle.py @@ -65,15 +65,21 @@ class SmolmachinesBottle(Bottle): result = subprocess.run(flags, check=False) return result.returncode - def exec(self, script: str) -> ExecResult: - """Run a POSIX shell script as the `node` user and capture - the result. Matches the docker backend's `exec`, which - defaults to the image's USER (also node) — so test + def exec(self, script: str, *, user: str = "node") -> ExecResult: + """Run a POSIX shell script as `user` (default `node`) and + capture the result. Matches the docker backend's `exec`, + which defaults to the image's USER (also node) — so test helpers / provision shell-outs run with the same identity - on both backends.""" + on both backends. Pass `user="root"` for tests that need + root. + + `smolvm machine exec` runs commands as root in the VM, so + we always need to switch user (even when the caller asked + for root, switching to root is a cheap no-op via + `runuser -l root`).""" r = _smolvm.machine_exec( self.name, - ["runuser", "-l", "node", "-c", script], + ["runuser", "-l", user, "-c", script], ) return ExecResult( returncode=r.returncode,