142 lines
5.6 KiB
Python
142 lines
5.6 KiB
Python
"""Git provisioning inside a running smolmachines bottle
|
|
(PRD 0023 chunk 4d).
|
|
|
|
Three concerns, all 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.
|
|
3. If the bottle declares `git.user` (issue #86), set
|
|
`git config --global user.{name,email}` inside the guest so
|
|
the agent's commits are attributed to that identity.
|
|
|
|
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 .. 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 all three subcases; each
|
|
no-ops when its condition isn't met."""
|
|
_provision_cwd_git(plan, target)
|
|
_provision_git_gate_config(plan, target)
|
|
_provision_git_user(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
|
|
|
|
# `127.0.0.1:<host port>` form: the bundle's git-gate port
|
|
# is published on host loopback at launch time so the
|
|
# smolvm guest (which can only reach macOS networking via
|
|
# TSI, not the docker bridge IP) can dial it. launch.py
|
|
# populates `plan.agent_git_gate_host` after bundle bringup.
|
|
content = git_gate_render_gitconfig(bottle.git, plan.agent_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])
|
|
|
|
|
|
def _provision_git_user(
|
|
plan: SmolmachinesBottlePlan, target: str,
|
|
) -> None:
|
|
"""Apply `git config --global user.{name,email}` inside the
|
|
guest as the node user so --global lands in the same
|
|
`/home/node/.gitconfig` that `_provision_git_gate_config`
|
|
writes to. No-op when the bottle didn't declare `git.user`.
|
|
|
|
Runs via `runuser -u node --`; HOME is forced via smolvm's
|
|
`-e` flag because runuser (without -l) inherits root's
|
|
HOME=/root, which would put --global in the wrong file."""
|
|
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
|
gu = bottle.git_user
|
|
if gu.is_empty():
|
|
return
|
|
env = {"HOME": _guest_home(), "USER": "node"}
|
|
if gu.name:
|
|
info(f"git config --global user.name = {gu.name!r}")
|
|
_smolvm.machine_exec(
|
|
target,
|
|
["runuser", "-u", "node", "--",
|
|
"git", "config", "--global", "user.name", gu.name],
|
|
env=env,
|
|
)
|
|
if gu.email:
|
|
info(f"git config --global user.email = {gu.email!r}")
|
|
_smolvm.machine_exec(
|
|
target,
|
|
["runuser", "-u", "node", "--",
|
|
"git", "config", "--global", "user.email", gu.email],
|
|
env=env,
|
|
)
|