Run every command on the build VM over SSH (M1.3)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 55s
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 55s
execution.SSHHost.execute is now the only way the agent runs anything (L13). Commands travel as a script on SSH's stdin, so the secrets handed to a call are never on a command line, and every value handed out is redacted from the output. Timeouts kill the remote process group, and an unreachable host is reported as such, not as a failed command. Workspace and GitRepo run on the build VM. The clone uses the repository's SSH URL, so no credential is stored in it (#89); fetches and pushes get the push key over stdin, in a short-lived ssh-agent, and PRs are still opened from the agent's host with its API token. The bench runs on the build host too. Tests reach "the build VM" through a throwaway local sshd (nisse_dev .testing); CI installs openssh-server. The live push test now pushes with the sandbox's deploy key (NISSE_PUSH_KEY). Fixes #89. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
b7f1f3371c
commit
761ebdb857
32 files changed
+1098
-280
No files matched your search
@@ -1,94 +1,135 @@
|
||||
"""Git operations for the agent's workspace (architecture D4).
|
||||
"""Git operations in the work item's workspace, on its build VM (D4, L13).
|
||||
|
||||
Push authentication uses the Gitea service-account token embedded in the
|
||||
remote URL. The token is never written to disk in the repo config and is
|
||||
scrubbed from anything this module raises or returns, so a failed push cannot
|
||||
leak the credential into a transcript, a log line, or a PR comment.
|
||||
Every command runs on the build VM over SSH. Remotes are plain URLs with no
|
||||
credential in them, so nothing secret is ever written to the clone's
|
||||
configuration (#89). Operations that reach the remote (clone, fetch, push,
|
||||
ls-remote) are handed the push key for that one operation: it is fed over
|
||||
stdin to a short-lived ``ssh-agent`` on the VM and gone when the command
|
||||
ends. The model's commands never receive it.
|
||||
|
||||
Pull requests are opened from the agent's own host with its Gitea API token
|
||||
(publish.py), which never reaches the VM.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from nisse_dev.execution import HostUnavailable, SSHHost
|
||||
|
||||
TIMEOUT_S = 120
|
||||
|
||||
# ssh for git on the VM: no prompts, and trust Gitea's host key on first use.
|
||||
_GIT_SSH = "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
|
||||
|
||||
|
||||
class GitError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _with_key(command: str, known_hosts: str | None) -> str:
|
||||
"""Run ``command`` with the key on stdin loaded into a throwaway agent."""
|
||||
git_ssh = _GIT_SSH
|
||||
if known_hosts is not None:
|
||||
git_ssh += f" -o UserKnownHostsFile={shlex.quote(known_hosts)}"
|
||||
return (
|
||||
'eval "$(ssh-agent -s)" > /dev/null && '
|
||||
"ssh-add -q - 2> /dev/null && "
|
||||
f"GIT_SSH_COMMAND={shlex.quote(git_ssh)} {command}; "
|
||||
"rc=$?; ssh-agent -k > /dev/null 2>&1; exit $rc"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitRepo:
|
||||
host: SSHHost
|
||||
root: Path
|
||||
# Where git's ssh on the VM records the forge's host key; the default is
|
||||
# the VM user's own known_hosts.
|
||||
known_hosts: str | None = None
|
||||
|
||||
def _run(self, *args: str, secret: str | None = None) -> str:
|
||||
def _git(self, *args: str, key: str | None = None, cwd: str | None = None) -> str:
|
||||
command = "git " + " ".join(shlex.quote(a) for a in args)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=self.root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=TIMEOUT_S,
|
||||
check=False,
|
||||
result = self.host.execute(
|
||||
_with_key(command, self.known_hosts) if key else command,
|
||||
cwd=cwd or str(self.root),
|
||||
stdin=key,
|
||||
timeout_s=TIMEOUT_S,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise GitError(f"git {args[0]} failed: {_scrub(str(exc), secret)}") from exc
|
||||
if completed.returncode != 0:
|
||||
except HostUnavailable as exc:
|
||||
raise GitError(f"git {args[0]} failed: {exc}") from exc
|
||||
if result.timed_out:
|
||||
raise GitError(f"git {args[0]} timed out after {TIMEOUT_S}s")
|
||||
if result.exit_code != 0:
|
||||
raise GitError(
|
||||
f"git {args[0]} failed: "
|
||||
f"{_scrub(completed.stderr.strip() or completed.stdout.strip(), secret)}"
|
||||
f"{result.stderr.strip() or result.stdout.strip()}"
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
return result.stdout.strip()
|
||||
|
||||
# -- inspection -------------------------------------------------------
|
||||
|
||||
def current_branch(self) -> str:
|
||||
# --show-current rather than rev-parse: it answers correctly on a
|
||||
# freshly initialised repo, where HEAD points at an unborn branch.
|
||||
return self._run("branch", "--show-current")
|
||||
return self._git("branch", "--show-current")
|
||||
|
||||
def head_sha(self) -> str:
|
||||
return self._run("rev-parse", "HEAD")
|
||||
return self._git("rev-parse", "HEAD")
|
||||
|
||||
def has_changes(self) -> bool:
|
||||
return bool(self._run("status", "--porcelain"))
|
||||
return bool(self._git("status", "--porcelain"))
|
||||
|
||||
def commits_since(self, ref: str) -> int:
|
||||
"""How many commits HEAD has that ref lacks. The executor may commit
|
||||
its own work, so a clean tree does not mean nothing to publish."""
|
||||
return int(self._run("rev-list", "--count", f"{ref}..HEAD"))
|
||||
return int(self._git("rev-list", "--count", f"{ref}..HEAD"))
|
||||
|
||||
def remote_sha(
|
||||
self, remote_url: str, branch: str, *, token: str | None = None
|
||||
self, remote_url: str, branch: str, *, key: str | None = None
|
||||
) -> str | None:
|
||||
"""What the remote's branch points at, or None if it has no such
|
||||
branch. Pushes go by URL, so there is no tracking ref to read."""
|
||||
out = self._run("ls-remote", remote_url, f"refs/heads/{branch}", secret=token)
|
||||
out = self._git("ls-remote", remote_url, f"refs/heads/{branch}", key=key)
|
||||
return out.split()[0] if out else None
|
||||
|
||||
def exists(self) -> bool:
|
||||
"""Whether the workspace is already a git repository."""
|
||||
try:
|
||||
result = self.host.execute(
|
||||
f"test -d {shlex.quote(str(self.root / '.git'))}", timeout_s=TIMEOUT_S
|
||||
)
|
||||
except HostUnavailable as exc:
|
||||
raise GitError(f"could not check the workspace: {exc}") from exc
|
||||
return result.exit_code == 0
|
||||
|
||||
# -- mutation ---------------------------------------------------------
|
||||
|
||||
def init(self, *, default_branch: str = "main") -> None:
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self._run("init", "-q", "-b", default_branch)
|
||||
self.host.execute(f"mkdir -p -- {shlex.quote(str(self.root))}")
|
||||
self._git("init", "-q", "-b", default_branch)
|
||||
|
||||
def clone(self, remote_url: str, dest: Path, *, token: str | None = None) -> None:
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self._run("clone", "-q", remote_url, str(dest), secret=token)
|
||||
def clone(self, remote_url: str, *, key: str | None = None) -> None:
|
||||
"""Clone into the workspace root. The remote URL carries no
|
||||
credential, so none is stored in the clone's configuration."""
|
||||
parent = str(self.root.parent)
|
||||
self.host.execute(f"mkdir -p -- {shlex.quote(parent)}")
|
||||
self._git("clone", "-q", remote_url, str(self.root), key=key, cwd=parent)
|
||||
|
||||
def configure_identity(self, name: str, email: str) -> None:
|
||||
self._run("config", "user.name", name)
|
||||
self._run("config", "user.email", email)
|
||||
self._git("config", "user.name", name)
|
||||
self._git("config", "user.email", email)
|
||||
|
||||
def checkout_new_branch(self, name: str) -> None:
|
||||
self._run("checkout", "-q", "-b", name)
|
||||
self._git("checkout", "-q", "-b", name)
|
||||
|
||||
def checkout_existing_branch(self, name: str) -> None:
|
||||
self._run("checkout", "-q", name)
|
||||
self._git("checkout", "-q", name)
|
||||
|
||||
def branch_exists(self, name: str) -> bool:
|
||||
try:
|
||||
self._run("rev-parse", "--verify", "--quiet", f"refs/heads/{name}")
|
||||
self._git("rev-parse", "--verify", "--quiet", f"refs/heads/{name}")
|
||||
except GitError:
|
||||
return False
|
||||
return True
|
||||
@@ -98,14 +139,14 @@ class GitRepo:
|
||||
commit — an empty milestone is a fact to report, not an error."""
|
||||
if not self.has_changes():
|
||||
return None
|
||||
self._run("add", "-A")
|
||||
self._run("commit", "-q", "-m", message)
|
||||
self._git("add", "-A")
|
||||
self._git("commit", "-q", "-m", message)
|
||||
return self.head_sha()
|
||||
|
||||
def push(self, remote_url: str, branch: str, *, token: str | None = None) -> None:
|
||||
# The URL is passed per-invocation rather than stored as a remote, so
|
||||
# the token never lands in .git/config.
|
||||
self._run("push", "-q", remote_url, f"HEAD:refs/heads/{branch}", secret=token)
|
||||
def push(self, remote_url: str, branch: str, *, key: str | None = None) -> None:
|
||||
# By URL, per invocation, rather than a configured remote with a
|
||||
# credential in it.
|
||||
self._git("push", "-q", remote_url, f"HEAD:refs/heads/{branch}", key=key)
|
||||
|
||||
def discard_branch(self, branch: str, *, back_to: str) -> None:
|
||||
"""Throw away a branch and everything on it.
|
||||
@@ -114,20 +155,10 @@ class GitRepo:
|
||||
atomicity that reconciliation gives an interrupted transition, and
|
||||
half-finished work must not survive to be built upon.
|
||||
"""
|
||||
self._run("checkout", "-q", "--force", back_to)
|
||||
self._run("branch", "-q", "-D", branch)
|
||||
self._git("checkout", "-q", "--force", back_to)
|
||||
self._git("branch", "-q", "-D", branch)
|
||||
|
||||
def delete_remote_branch(
|
||||
self, remote_url: str, branch: str, *, token: str | None = None
|
||||
self, remote_url: str, branch: str, *, key: str | None = None
|
||||
) -> None:
|
||||
self._run("push", "-q", remote_url, f":refs/heads/{branch}", secret=token)
|
||||
|
||||
|
||||
def authenticated_url(base_url: str, repo: str, user: str, token: str) -> str:
|
||||
host = base_url.split("://", 1)[-1].rstrip("/")
|
||||
scheme = base_url.split("://", 1)[0] if "://" in base_url else "https"
|
||||
return f"{scheme}://{user}:{token}@{host}/{repo}.git"
|
||||
|
||||
|
||||
def _scrub(text: str, secret: str | None) -> str:
|
||||
return text.replace(secret, "***") if secret else text
|
||||
self._git("push", "-q", remote_url, f":refs/heads/{branch}", key=key)
|
||||
Reference in new issue
Block a user