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

+6 -2
View File
@@ -7,11 +7,15 @@
# the live tests that need it, rather than skipping them, so a broken
# credential cannot hide as a skip.
# Unused by the live tests, which work in pytest's temporary directories;
# present because every agent config has them.
# Unused by the live tests, which work in pytest's temporary directories on a
# throwaway local sshd (the `ssh_host` fixture); present because every agent
# config has them.
state_db: /tmp/nisse-test/state.db
workspace: /tmp/nisse-test/work
project: nisse-dev-testing
build_host:
address: 127.0.0.1
key: /tmp/nisse-test/unused-key
llm:
base_url: https://litellm.dongar.net
+5 -2
View File
@@ -10,19 +10,22 @@ from nisse_dev.bench.runner import run_task
from nisse_dev.bench.tasks import load_tasks
from nisse_dev.catalog import ModelCatalog
from nisse_dev.config import AgentConfig
from nisse_dev.execution import SSHHost
pytestmark = pytest.mark.integration
def test_the_simplest_task_passes_live(
live_config: AgentConfig, tmp_path: Path
live_config: AgentConfig, tmp_path: Path, ssh_host: SSHHost
) -> None:
catalog = ModelCatalog.discover(live_config.llm_base_url, live_config.llm_token)
normalized, _ = make_llm_clients(live_config, catalog)
facts = catalog.facts(live_config.roles.executor)
[task] = load_tasks(["write-function"])
record = run_task(task, facts, normalized, live_config.tuning, workdir=tmp_path)
record = run_task(
task, facts, normalized, live_config.tuning, host=ssh_host, workdir=tmp_path
)
assert record.fallbacks == []
assert record.success, (
+19 -8
View File
@@ -7,6 +7,7 @@ from pathlib import Path
import pytest
from nisse_dev.delegation import Caps, Delegation
from nisse_dev.execution import SSHHost
from nisse_dev.git import GitRepo
from nisse_dev.llm import AnthropicPassthroughClient
from nisse_dev.oplog import OpLog
@@ -23,9 +24,13 @@ it, and run `python -m pytest test_slugify.py -q` until it passes."""
def build(
tmp_path: Path, caps: Caps, client: AnthropicPassthroughClient, model: str
ssh_host: SSHHost,
tmp_path: Path,
caps: Caps,
client: AnthropicPassthroughClient,
model: str,
) -> tuple[Delegation, GitRepo, StateStore]:
repo = GitRepo(tmp_path / "work")
repo = GitRepo(ssh_host, tmp_path / "work")
repo.init()
repo.configure_identity("nisse-dev-testing", "n@t.test")
(repo.root / "README.md").write_text("sandbox\n")
@@ -34,7 +39,7 @@ def build(
delegation = Delegation(
client,
store,
Workspace(repo.root, timeout_s=60),
Workspace(ssh_host, repo.root, timeout_s=60),
repo,
model=model,
caps=caps,
@@ -43,10 +48,13 @@ def build(
def test_delegate_completes_a_scoped_subtask(
tmp_path: Path, passthrough: AnthropicPassthroughClient, live_model: str
tmp_path: Path,
passthrough: AnthropicPassthroughClient,
live_model: str,
ssh_host: SSHHost,
) -> None:
delegation, repo, store = build(
tmp_path, Caps(turns=12, wall_clock_s=300.0), passthrough, live_model
ssh_host, tmp_path, Caps(turns=12, wall_clock_s=300.0), passthrough, live_model
)
result = delegation.run(
@@ -66,19 +74,22 @@ def test_delegate_completes_a_scoped_subtask(
# The delegate's own work is on its branch, and it really runs.
assert repo.current_branch() == "live/delegation-1"
check = Workspace(repo.root, timeout_s=60).run_command(
check = Workspace(ssh_host, repo.root, timeout_s=60).run_command(
"python -m pytest test_slugify.py -q"
)
assert check.success, f"delegate's own tests do not pass: {check.result[:400]}"
def test_a_cap_breach_discards_the_work(
tmp_path: Path, passthrough: AnthropicPassthroughClient, live_model: str
tmp_path: Path,
passthrough: AnthropicPassthroughClient,
live_model: str,
ssh_host: SSHHost,
) -> None:
# One turn cannot finish the brief, so this exercises the abort path
# against a real model rather than a scripted one.
delegation, repo, store = build(
tmp_path, Caps(turns=1, wall_clock_s=120.0), passthrough, live_model
ssh_host, tmp_path, Caps(turns=1, wall_clock_s=120.0), passthrough, live_model
)
result = delegation.run(
+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()
+3 -2
View File
@@ -19,6 +19,7 @@ from nisse_dev.control import (
PR_OPEN,
Orchestrator,
)
from nisse_dev.execution import SSHHost
from nisse_dev.executor import LLMExecutor
from nisse_dev.llm import LiteLLMClient, RoleMap
from nisse_dev.oplog import OpLog
@@ -59,7 +60,7 @@ def _drive_to_execution(
def test_plan_approve_execute_produces_a_real_file(
tmp_path: Path, litellm: LiteLLMClient, live_model: str
tmp_path: Path, litellm: LiteLLMClient, live_model: str, ssh_host: SSHHost
) -> None:
# Both roles use the capable model here: the point is the whole gated
# loop over a real proxy, not local-model competence (measured separately).
@@ -67,7 +68,7 @@ def test_plan_approve_execute_produces_a_real_file(
roles = RoleMap(executor=model, planner=model, helper=model, expert=model)
client = litellm
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
workspace = Workspace(tmp_path / "work", timeout_s=60)
workspace = Workspace(ssh_host, tmp_path / "work", timeout_s=60)
transport = InMemoryTransport()
orchestrator = Orchestrator(
store,