3fb305f654
`smolvm 0.8.0 machine exec -t` allocates an in-VM PTY but never forwards the host terminal's window size — the PTY starts at `0 0` and host resizes (tmux pane resize, terminal window resize) go unnoticed, so the claude TUI inside a smolmachines bottle renders for whatever tiny box it last saw and ignores operator resizes. `docker exec -it` propagates window-size changes automatically; smolvm doesn't. Workaround: a small Python wrapper (`backend/smolmachines/pty_resize.py`) that interposes between the operator's terminal and `smolvm machine exec`. It spawns smolvm as a child, traps host SIGWINCH, and on every resize (plus once at startup) runs a side-channel `smolvm machine exec --name <M> -- sh -c 'for f in /dev/pts/*; do stty -F $f cols X rows Y; done'`. The kernel delivers SIGWINCH to the in-VM foreground process group when the slave PTY's size changes, so claude picks up the new dimensions without extra signalling. `SmolmachinesBottle.claude_argv` prepends `[sys.executable, -m, claude_bottle.backend.smolmachines. pty_resize, <machine>, --, ...]` to the existing smolvm argv in TTY mode. Non-TTY mode (provisioning shell-outs) skips the wrapper — no PTY to resize. The wrapper survives the dashboard's `_build_resume_argv_with_fallback` shell-wrap because the split-at-`claude` token still finds the right position — the wrapper's prefix wraps the entire smolvm-exec framing. Tests: - `test_smolmachines_pty_resize.py` (new): argv parsing, the side-channel command shape (cols/rows / for-loop over /dev/pts/*), and `_read_winsize`'s fallback across stdin/stdout/stderr including the smolvm-allocated-PTY- reports-`0 0` ironic case. - `test_smolmachines_bottle.py`: updated TTY-mode assertions to unwrap the pty_resize prefix; added `TestClaudeArgvNoTTY` to lock the non-TTY skip. 636 unit tests pass. Removable when smolvm grows native SIGWINCH forwarding. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
158 lines
6.2 KiB
Python
158 lines
6.2 KiB
Python
"""SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d).
|
|
|
|
Routes `exec_claude` / `exec` / `cp_in` through `smolvm machine
|
|
exec` / `smolvm machine cp`. The handle is yielded by `launch`
|
|
and torn down via the surrounding ExitStack on context exit;
|
|
`close` is a no-op idempotent alias so the BottleBackend ABC's
|
|
context-manager contract is satisfied.
|
|
|
|
User context: `smolvm machine exec` runs commands as root in the
|
|
VM, but the agent image's USER is `node` and claude-code refuses
|
|
to run as root with `--dangerously-skip-permissions`. Both
|
|
`exec_claude` and `exec` switch to the requested user (default
|
|
`node`) via `runuser -u <user> --` and set `HOME` / `USER`
|
|
through `smolvm -e` — avoiding `runuser -l`'s login-shell wiring
|
|
(PAM session setup, /etc/profile sourcing) which can hang on a
|
|
minimal Debian VM with no PAM session config."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
from typing import Mapping
|
|
|
|
from .. import Bottle, ExecResult
|
|
from . import smolvm as _smolvm
|
|
|
|
|
|
# Per-user env the agent image's USER (node) expects. claude
|
|
# reads ~/.claude.json + writes session state under ~/.claude/;
|
|
# bare `runuser -u` inherits root's HOME=/root, which claude
|
|
# can't write to. Set HOME / USER explicitly through smolvm -e
|
|
# so the child process sees them.
|
|
_HOME_FOR = {
|
|
"node": "/home/node",
|
|
"root": "/root",
|
|
}
|
|
|
|
|
|
def _env_flags_for(user: str) -> list[str]:
|
|
home = _HOME_FOR.get(user, f"/home/{user}")
|
|
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,
|
|
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 claude_argv(
|
|
self, argv: list[str], *, tty: bool = True,
|
|
) -> list[str]:
|
|
flags = ["smolvm", "machine", "exec", "--name", self.name]
|
|
if tty:
|
|
flags += ["-i", "-t"]
|
|
flags += _env_flags_for("node")
|
|
flags += _guest_env_flags(self._guest_env)
|
|
claude_tail = ["claude"]
|
|
if self._prompt_path:
|
|
claude_tail += ["--append-system-prompt-file", self._prompt_path]
|
|
claude_tail += argv
|
|
flags += ["--", "runuser", "-u", "node", "--", *claude_tail]
|
|
if not tty:
|
|
# No PTY allocated — no SIGWINCH to forward, no resize
|
|
# bridge needed. Skip the wrapper so non-interactive
|
|
# exec paths (e.g., provisioning shell-outs that
|
|
# happen to go through this method) stay light.
|
|
return flags
|
|
return [
|
|
sys.executable, "-m",
|
|
"claude_bottle.backend.smolmachines.pty_resize",
|
|
self.name, "--", *flags,
|
|
]
|
|
|
|
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
|
|
"""Run `claude` interactively inside the VM as the `node`
|
|
user. Inherits the operator's terminal (stdin / stdout /
|
|
stderr) so the session feels native. Blocks until claude
|
|
exits; returns the in-VM exit code.
|
|
|
|
We bypass the captured-output `machine_exec` helper here
|
|
because that one wraps stdout/stderr in pipes — fine for
|
|
scripted exec, wrong for an interactive shell. Drop down
|
|
to `subprocess.run` with the TTY inherited.
|
|
|
|
UID switches via `runuser -u node --` (not `-l`) so we
|
|
avoid login-shell wiring. HOME / USER come from `smolvm
|
|
-e` instead, which sets them on the process env."""
|
|
return subprocess.run(
|
|
self.claude_argv(argv, tty=tty), check=False,
|
|
).returncode
|
|
|
|
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. Pass `user="root"` for tests that need
|
|
root.
|
|
|
|
`runuser -u <user> -- /bin/sh -c <script>` switches UID
|
|
without invoking a login shell; HOME / USER are set via
|
|
`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 `--`);
|
|
# the -e flags go before, so call smolvm directly.
|
|
r = subprocess.run(
|
|
["smolvm", "machine", "exec", "--name", self.name] + argv,
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
return ExecResult(
|
|
returncode=r.returncode,
|
|
stdout=r.stdout or "",
|
|
stderr=r.stderr or "",
|
|
)
|
|
|
|
def cp_in(self, host_path: str, container_path: str) -> None:
|
|
"""Copy a host path into the guest at `container_path`."""
|
|
_smolvm.machine_cp(host_path, f"{self.name}:{container_path}")
|
|
|
|
def close(self) -> None:
|
|
# Real teardown lives on the launch ExitStack; this is just
|
|
# the idempotent alias the BottleBackend ABC expects.
|
|
pass
|