diff --git a/claude_bottle/backend/smolmachines/bottle.py b/claude_bottle/backend/smolmachines/bottle.py index f4e4235..89c836f 100644 --- a/claude_bottle/backend/smolmachines/bottle.py +++ b/claude_bottle/backend/smolmachines/bottle.py @@ -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 `--`); diff --git a/claude_bottle/backend/smolmachines/launch.py b/claude_bottle/backend/smolmachines/launch.py index 1f55792..6f1839c 100644 --- a/claude_bottle/backend/smolmachines/launch.py +++ b/claude_bottle/backend/smolmachines/launch.py @@ -117,10 +117,26 @@ def launch( _smolvm.machine_start(plan.machine_name) stack.callback(_smolvm.machine_stop, plan.machine_name) - # 5. Provision (CA / prompt / skills / git / supervise). + # 5. Reclaim /home/node for the node user. smolvm's pack + # process remaps OCI-layer ownership to the host invoker's + # uid (501 on macOS) rather than preserving the image's + # uid 1000 — so without this chown, node can't write its + # own dotfiles (claude appendFileSync on + # ~/.claude.json bails with ENOENT/EPERM and the TUI hangs + # without surfacing the error). + _smolvm.machine_exec( + plan.machine_name, + ["chown", "-R", "node:node", "/home/node"], + ) + + # 6. Provision (CA / prompt / skills / git / supervise). prompt_path = provision(plan, plan.machine_name) - yield SmolmachinesBottle(plan.machine_name, prompt_path=prompt_path) + yield SmolmachinesBottle( + plan.machine_name, + prompt_path=prompt_path, + guest_env=plan.guest_env, + ) finally: stack.close() diff --git a/claude_bottle/backend/smolmachines/prepare.py b/claude_bottle/backend/smolmachines/prepare.py index da70685..5718bf8 100644 --- a/claude_bottle/backend/smolmachines/prepare.py +++ b/claude_bottle/backend/smolmachines/prepare.py @@ -91,11 +91,18 @@ def resolve_plan( # Agent's env. IP literals; no DNS resolution inside the guest # (TSI allowlist contains only `/32` — no resolver). + # TLS trust env trio (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / + # REQUESTS_CA_BUNDLE) points at Debian's + # update-ca-certificates output bundle — provision_ca writes + # the per-bottle MITM CA there at launch time. guest_env: dict[str, str] = { **bottle.env, "HTTPS_PROXY": f"http://{bundle_ip}:{_BUNDLE_PIPELOCK_PORT}", "HTTP_PROXY": f"http://{bundle_ip}:{_BUNDLE_PIPELOCK_PORT}", "NO_PROXY": "localhost,127.0.0.1", + "NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt", + "SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt", + "REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt", } if bottle.git: guest_env["GIT_GATE_URL"] = ( @@ -124,6 +131,19 @@ def resolve_plan( egress_dir.mkdir(parents=True, exist_ok=True) egress_plan = Egress().prepare(bottle, slug, egress_dir) + # Claude-code refuses to start without *something* it + # recognises as a credential. When the bottle has an egress + # route carrying the `claude_code_oauth` role marker, egress + # strips + re-injects the real Authorization header on the + # outbound leg using a token held in egress's own environ — so + # the agent gets a non-secret placeholder here (matches the + # docker backend's forwarded_env logic in + # claude_bottle/backend/docker/prepare.py). + if any("claude_code_oauth" in r.roles for r in egress_plan.routes): + guest_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" + guest_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") + guest_env.setdefault("DISABLE_ERROR_REPORTING", "1") + supervise_plan = None if bottle.supervise: supervise_dir = supervise_state_dir(slug)