Run every command on the build VM over SSH (M1.3)
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:
dongarandClaude Opus 5.5 committed 2026-10-02 21:39:46 +00:00
1 parent b7f1f3371c
commit 761ebdb857
32 files changed
+1098 -280

No files matched your search

+86 -55
View File
@@ -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)