fix(smolmachines): forward guest env on every exec + chown /home/node
test / unit (pull_request) Successful in 26s
test / integration (pull_request) Successful in 40s

Two issues kept claude's TUI from drawing after launch:

1. smolvm pack remaps OCI-layer ownership to the host invoker's
   uid (501 on macOS) instead of preserving the image's
   USER node (uid 1000). /home/node ends up owned by some uid
   that doesn't exist in the VM, so when claude runs as node it
   can't appendFileSync to ~/.claude.json on startup — fails
   with ENOENT and the TUI hangs. Fix: chown -R node:node
   /home/node after machine_start, before provision.

2. smolvm machine_create -e sets env on PID 1 but it doesn't
   propagate to fresh exec process trees (verified empirically:
   `smolvm machine exec -- printenv` shows none of the
   machine_create env vars). Claude was running with no
   HTTPS_PROXY / CLAUDE_CODE_OAUTH_TOKEN / NODE_EXTRA_CA_CERTS,
   so even the auth-validation step bailed silently. Fix:
   thread `guest_env` through to the SmolmachinesBottle handle
   and re-pass every entry via `-e K=V` on every machine_exec
   call (interactive claude and shell exec both).

Also fills in the same `CLAUDE_CODE_OAUTH_TOKEN=egress-
placeholder` + telemetry-off env the docker backend's
forwarded_env carries, plus the NODE_EXTRA_CA_CERTS /
SSL_CERT_FILE / REQUESTS_CA_BUNDLE trust trio.

Verified end-to-end on Docker Desktop / macOS: claude's TUI
renders cleanly with the bypass-permissions banner.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 15:18:21 -04:00
parent 35edf50f21
commit 91955ec59f
3 changed files with 64 additions and 3 deletions
+26 -1
View File
@@ -18,6 +18,7 @@ minimal Debian VM with no PAM session config."""
from __future__ import annotations
import subprocess
from typing import Mapping
from .. import Bottle, ExecResult
from . import smolvm as _smolvm
@@ -39,18 +40,40 @@ def _env_flags_for(user: str) -> list[str]:
return ["-e", f"HOME={home}", "-e", f"USER={user}"]
def _guest_env_flags(env: Mapping[str, str]) -> list[str]:
"""Render `{K: V}` into a flat `-e K=V` argv slice for
`smolvm machine exec`. `smolvm machine create -e` set env
on PID 1 but it doesn't propagate to fresh exec process
trees, so we have to re-pass them every call."""
out: list[str] = []
for k, v in env.items():
out += ["-e", f"{k}={v}"]
return out
class SmolmachinesBottle(Bottle):
"""Handle returned by `SmolmachinesBottleBackend.launch`. The
underlying VM lifecycle (create / start / stop / delete) lives
on the launch ExitStack — this class only routes runtime
operations to the right `smolvm machine ...` subcommand."""
def __init__(self, machine_name: str, *, prompt_path: str | None = None) -> None:
def __init__(
self,
machine_name: str,
*,
prompt_path: str | None = None,
guest_env: Mapping[str, str] | None = None,
) -> None:
self.name = machine_name
# In-VM path to the agent's prompt file. None when the
# agent declared no prompt (file still exists; we just
# don't pass --append-system-prompt-file).
self._prompt_path = prompt_path
# Env vars the agent process needs (HTTPS_PROXY,
# CLAUDE_CODE_OAUTH_TOKEN, manifest-declared bottle env, …).
# Forwarded on every `smolvm machine exec` via `-e K=V`
# because exec doesn't inherit from machine_create's env.
self._guest_env = dict(guest_env or {})
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
"""Run `claude` interactively inside the VM as the `node`
@@ -70,6 +93,7 @@ class SmolmachinesBottle(Bottle):
if tty:
flags += ["-i", "-t"]
flags += _env_flags_for("node")
flags += _guest_env_flags(self._guest_env)
claude_argv = ["claude"]
if self._prompt_path:
claude_argv += ["--append-system-prompt-file", self._prompt_path]
@@ -91,6 +115,7 @@ class SmolmachinesBottle(Bottle):
`smolvm -e` (see `_env_flags_for`)."""
argv = (
_env_flags_for(user)
+ _guest_env_flags(self._guest_env)
+ ["--", "runuser", "-u", user, "--", "/bin/sh", "-c", script]
)
# _smolvm.machine_exec expects argv (the bit after `--`);