ac8c7ba696
End-to-end provisioning parity with the docker backend. After this
chunk a smolmachines bottle has a working trust store, git-gate
gitconfig, and supervise MCP registration — same shape as docker,
dispatched via `smolvm machine cp` / `smolvm machine exec` instead
of `docker cp` / `docker exec`.
Adds three new provision modules:
- ca.py: select egress vs pipelock CA (same logic as
docker), machine cp + update-ca-certificates,
log sha256 fingerprint.
- git.py: copy host .git when --cwd was passed; render
~/.gitconfig with insteadOf URLs. URL prefix is
`git://<bundle_ip>:9418/...` (no DNS in the
TSI-allowlisted guest) vs docker's
`git://git-gate/...`.
- supervise.py: `claude mcp add` via machine_exec; URL is
`http://<bundle_ip>:9100/`. Failure is logged but
non-fatal (matches docker).
Shared render: `render_git_gate_gitconfig` moves out of
backend/docker/provision/git.py into the platform-neutral
claude_bottle/git_gate.py (renamed to git_gate_render_gitconfig
for consistency with the existing git_gate_render_* helpers),
parameterized on a `gate_host` argument so both backends use the
same logic with different addresses.
Path/user fixups for the post-chunk-4c agent image (real
claude-bottle image, USER node, $HOME=/home/node):
- prompt.py default path moves from /root/... to
/home/node/.claude-bottle-prompt.txt; chown + chmod after
machine cp.
- skills.py default skills dir moves from /root/.claude/skills to
/home/node/.claude/skills; chown -R per skill.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
103 lines
4.0 KiB
Python
103 lines
4.0 KiB
Python
"""Git provisioning inside a running smolmachines bottle
|
|
(PRD 0023 chunk 4d).
|
|
|
|
Two concerns, both about git in the agent:
|
|
|
|
1. If --cwd was passed AND the host cwd has a .git, copy that
|
|
.git into /home/node/workspace/.git so the agent operates on
|
|
the user's repo.
|
|
2. If the bottle declares `git` entries (PRD 0008), write a
|
|
~/.gitconfig with insteadOf rules so every git operation
|
|
against a declared upstream transparently hits the per-bottle
|
|
git-gate. The gate mirrors the upstream in both directions,
|
|
so URL rewriting is symmetric.
|
|
|
|
Differs from `backend.docker.provision.git` in one address detail:
|
|
the TSI-allowlisted guest can only reach the bundle's pinned IP
|
|
(no DNS resolver in the /32 allowlist), so the insteadOf URLs
|
|
are `git://<bundle_ip>:<port>/<name>.git` rather than the
|
|
docker backend's `git://git-gate/<name>.git`. The render itself
|
|
is the shared `git_gate_render_gitconfig` on the platform-neutral
|
|
git_gate module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from ....git_gate import git_gate_render_gitconfig
|
|
from ....log import info
|
|
from ...docker.git_gate import GIT_GATE_PORT
|
|
from .. import smolvm as _smolvm
|
|
from ..bottle_plan import SmolmachinesBottlePlan
|
|
|
|
|
|
# `node` is the agent user from the repo Dockerfile. Override via
|
|
# CLAUDE_BOTTLE_GUEST_HOME mirrors the docker backend's
|
|
# CLAUDE_BOTTLE_CONTAINER_HOME knob — same purpose, different
|
|
# transport.
|
|
_DEFAULT_GUEST_HOME = "/home/node"
|
|
|
|
|
|
def _guest_home() -> str:
|
|
return os.environ.get("CLAUDE_BOTTLE_GUEST_HOME", _DEFAULT_GUEST_HOME)
|
|
|
|
|
|
def provision_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|
"""Set up git inside the guest. Runs both subcases; each
|
|
no-ops when its condition isn't met."""
|
|
_provision_cwd_git(plan, target)
|
|
_provision_git_gate_config(plan, target)
|
|
|
|
|
|
def _provision_cwd_git(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|
"""If --cwd was set and the host cwd has a .git directory, copy
|
|
it into <guest_home>/workspace/.git and fix ownership. No-op
|
|
otherwise."""
|
|
if not (plan.spec.copy_cwd and Path(plan.spec.user_cwd, ".git").is_dir()):
|
|
return
|
|
guest_workspace_git = f"{_guest_home()}/workspace/.git"
|
|
info(f"copying {plan.spec.user_cwd}/.git -> {target}:{guest_workspace_git}")
|
|
# mkdir -p the workspace dir so `machine cp` lands the .git
|
|
# directly there even on first-time bottles.
|
|
_smolvm.machine_exec(target, ["mkdir", "-p", f"{_guest_home()}/workspace"])
|
|
_smolvm.machine_cp(
|
|
f"{plan.spec.user_cwd}/.git", f"{target}:{guest_workspace_git}",
|
|
)
|
|
# `machine cp` lands files as root; the agent runs as node so
|
|
# the workspace tree must be chowned over.
|
|
_smolvm.machine_exec(
|
|
target, ["chown", "-R", "node:node", guest_workspace_git],
|
|
)
|
|
|
|
|
|
def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> None:
|
|
"""Write ~/.gitconfig in the guest with the git-gate insteadOf
|
|
rules. No-op when the bottle has no `git` entries."""
|
|
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
|
if not bottle.git:
|
|
return
|
|
|
|
# IP-literal form: the TSI allowlist passes <bundle_ip>/32 and
|
|
# nothing else, so the agent has to dial the gate by IP+port.
|
|
gate_host = f"{plan.bundle_ip}:{GIT_GATE_PORT}"
|
|
content = git_gate_render_gitconfig(bottle.git, gate_host)
|
|
|
|
guest_gitconfig = f"{_guest_home()}/.gitconfig"
|
|
# Stage the file under the plan's stage_dir so `machine cp`
|
|
# has a stable host path. The plan's stage_dir is cleaned up
|
|
# by start.py's session-end teardown.
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", dir=str(plan.stage_dir), prefix="gitconfig.",
|
|
delete=False,
|
|
) as f:
|
|
f.write(content)
|
|
config_file = Path(f.name)
|
|
os.chmod(config_file, 0o600)
|
|
|
|
info(f"writing {guest_gitconfig} with {len(bottle.git)} insteadOf rule(s)")
|
|
_smolvm.machine_cp(str(config_file), f"{target}:{guest_gitconfig}")
|
|
_smolvm.machine_exec(target, ["chown", "node:node", guest_gitconfig])
|
|
_smolvm.machine_exec(target, ["chmod", "644", guest_gitconfig])
|