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
@@ -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
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user