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

+25 -11
View File
@@ -5,13 +5,15 @@ Cleans up after itself (closes the PR, deletes the branch) so repeated runs
do not silt up the sandbox.
"""
import os
import uuid
from pathlib import Path
import pytest
from nisse_dev.config import AgentConfig
from nisse_dev.git import GitError, GitRepo, authenticated_url
from nisse_dev.execution import SSHHost
from nisse_dev.git import GitError, GitRepo
from nisse_dev.gitea import GiteaClient, pull_request_reconciler
from nisse_dev.state import Intent
@@ -29,11 +31,20 @@ def intent_for(branch: str) -> Intent:
)
def push_key() -> str:
"""The sandbox's deploy key with write access: what the agent pushes with
(D4). NISSE_PUSH_KEY holds the private key itself (CI) or, since a .env
line can't hold one, the path to it (local runs)."""
value = os.environ.get("NISSE_PUSH_KEY", "")
assert value.strip(), "NISSE_PUSH_KEY is not set: the sandbox's deploy key"
if "PRIVATE KEY" not in value and Path(value).expanduser().is_file():
value = Path(value).expanduser().read_text()
return value if value.endswith("\n") else value + "\n"
def test_push_open_pr_and_reconcile(
tmp_path: Path, live_config: AgentConfig, gitea: GiteaClient
tmp_path: Path, live_config: AgentConfig, gitea: GiteaClient, ssh_host: SSHHost
) -> None:
base_url = live_config.gitea_url
token = live_config.gitea_token
repo_path = live_config.gitea_repo # the sandbox
client = gitea
@@ -48,16 +59,19 @@ def test_push_open_pr_and_reconcile(
# Before the PR exists, external truth says the effect has not happened.
assert reconcile(intent_for(branch)) is False
repo = GitRepo(tmp_path / "work")
# Pushed the way the agent does: from the build VM (here the throwaway
# sshd), over SSH, with the deploy key and never the API token.
key = push_key()
url = client.ssh_url(repo_path)
repo = GitRepo(ssh_host, tmp_path / "work", known_hosts=str(tmp_path / "kh"))
repo.init()
repo.configure_identity(account, f"{account}@example.test")
(repo.root / f"{branch}.txt").write_text("live test artifact\n")
assert repo.commit_all("Live test commit") is not None
url = authenticated_url(base_url, repo_path, account, token)
pr = None
try:
repo.push(url, branch, token=token)
repo.push(url, branch, key=key)
pr = client.open_pull_request(
repo_path,
head=branch,
@@ -75,8 +89,8 @@ def test_push_open_pr_and_reconcile(
assert reconcile(intent_for(branch)) is True
# D4 is only real if the server enforces it. The agent's own token
# must not be able to merge the PR it just opened, and must not be
# able to push to the protected default branch at all.
# must not be able to merge the PR it just opened, and its push key
# must not be able to push to the protected default branch at all.
merged = client._http.post(
f"/repos/{repo_path}/pulls/{pr.number}/merge", json={"Do": "merge"}
)
@@ -85,9 +99,9 @@ def test_push_open_pr_and_reconcile(
"the human gate"
)
with pytest.raises(GitError):
repo.push(url, "main", token=token)
repo.push(url, "main", key=key)
finally:
if pr is not None:
client.close_pull_request(repo_path, pr.number)
repo.delete_remote_branch(url, branch, token=token)
repo.delete_remote_branch(url, branch, key=key)
client.close()