Merge pull request 'Run every command on the build VM over SSH (M1.3)' (#93) from ssh-execution into main
CI / checks (push) Successful in 1m49s
CI / checks (push) Successful in 1m49s
Reviewed-on: dongar/nisse#93 Reviewed-by: Don Garrett <dgarrett@acm.org>
This commit was merged in pull request #93.
This commit is contained in:
commit
3cc3eed8f3
36 files changed
+1492
-316
No files matched your search
@@ -11,6 +11,8 @@ NISSE_LLM_TOKEN=replace-me
|
||||
NISSE_MATRIX_TOKEN=replace-me
|
||||
# gitea.token: the Gitea service account's scoped token (B2)
|
||||
NISSE_GITEA_TOKEN=replace-me
|
||||
# The live push test reads the sandbox's deploy key from the path in
|
||||
# tests/integration/nisse-test.yaml (gitea.push_key), not from here.
|
||||
|
||||
# Where the config file is, if not ./nisse-dev.yaml. Where this file is, if
|
||||
# not ./.env, is NISSE_ENV, which has to be set in the real environment.
|
||||
|
||||
@@ -24,6 +24,15 @@ jobs:
|
||||
- name: Sync workspace
|
||||
run: uv sync --all-packages
|
||||
|
||||
- name: Install sshd
|
||||
# The tests reach "the build VM" over real SSH, through a throwaway
|
||||
# sshd the test fixture starts (nisse_dev/testing.py).
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq openssh-server openssh-client > /dev/null
|
||||
$SUDO mkdir -p /run/sshd
|
||||
|
||||
- name: Lint
|
||||
run: uv run ruff check .
|
||||
|
||||
@@ -33,6 +42,15 @@ jobs:
|
||||
- name: Unit tests
|
||||
run: uv run pytest -m "not integration"
|
||||
|
||||
- name: Push key
|
||||
# The sandbox's deploy key, where nisse-test.yaml expects it.
|
||||
env:
|
||||
NISSE_PUSH_KEY: ${{ secrets.NISSE_PUSH_KEY }}
|
||||
run: |
|
||||
mkdir -p /tmp/nisse-test
|
||||
printf '%s\n' "$NISSE_PUSH_KEY" > /tmp/nisse-test/push_key
|
||||
chmod 600 /tmp/nisse-test/push_key
|
||||
|
||||
- name: Integration tests
|
||||
# Everything but the secrets is in tests/integration/nisse-test.yaml,
|
||||
# which refers to these by name. A missing secret fails the live
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"""Fixtures shared by every test tree."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.testing import local_sshd
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ssh_host(tmp_path_factory: pytest.TempPathFactory) -> Iterator[SSHHost]:
|
||||
"""A throwaway sshd on this machine, standing in for the build VM (L13).
|
||||
It runs as the current user, so remote paths are this machine's paths and
|
||||
tests can inspect what a command did."""
|
||||
with local_sshd(Path(tmp_path_factory.mktemp("sshd"))) as host:
|
||||
yield host
|
||||
@@ -543,8 +543,8 @@ event, work item, cause); every verdict, escalation and external effect is logge
|
||||
- **MCP tools are the one deliberate exception:** fixed code against an API, with
|
||||
the model choosing only arguments. They still pass through P3, P4 and L4, and their
|
||||
output is redacted (L12).
|
||||
- **Tests use SSH too,** against a throwaway `sshd` container, so the tested path is
|
||||
the production path.
|
||||
- **Tests use SSH too,** against a throwaway `sshd`, so the tested path is the
|
||||
production path.
|
||||
|
||||
---
|
||||
|
||||
@@ -674,9 +674,10 @@ Installing on any other host is dangerous.
|
||||
fresh and this one resumes from its branch.
|
||||
- **Publish only the work:** what the agent changed, counted from where the
|
||||
milestone started, never build artifacts; anything left out is reported.
|
||||
- **Pushes run on the build VM** with a key the model's commands never receive
|
||||
(L12); PRs are opened from the agent's container with its API token, which never
|
||||
reaches a build VM. Gitea is the only copy that has to survive.
|
||||
- **Pushes run on the build VM** with a push credential (a deploy key, or a
|
||||
push-only account's token) the model's commands never receive (L12); PRs are
|
||||
opened from the agent's container with its API token, which never reaches a
|
||||
build VM. Gitea is the only copy that has to survive.
|
||||
- **Publishing is idempotent** (L8). A milestone with no changes is done if it wasn't
|
||||
expected to change code; otherwise it's raised with the operator.
|
||||
- **The agent never owns or administers a repository:** whoever does can turn
|
||||
@@ -727,7 +728,7 @@ execution:
|
||||
|
||||
**Secrets are namespaced by project.** A build VM's commands receive only their
|
||||
project's, and each secret's configuration says whether the model's commands may
|
||||
see it or only harness operations use it (the push key). Anything the model's
|
||||
see it or only harness operations use it (the push credential). Anything the model's
|
||||
commands can see must be scoped for testing, never production.
|
||||
|
||||
**Provisioning:** build VMs come from a template via Terraform; eventually at
|
||||
|
||||
@@ -282,8 +282,10 @@ if the implementation differs.
|
||||
- **D3 preflight:** a project's needs are read from its files (`Cargo.toml`,
|
||||
`pyproject.toml`, `package.json`) or a declaration in the repository.
|
||||
- **D4 publishing:** standard excludes are configured in each clone, and only what
|
||||
the work produced is committed. A push hands the key over stdin to a
|
||||
short-lived `ssh-agent` on the build VM, for that one push.
|
||||
the work produced is committed. The push credential is handed to each push
|
||||
only: a deploy key over stdin to a short-lived `ssh-agent` on the build VM, or
|
||||
a push-only account's HTTPS token as that command's secrets, read by a
|
||||
credential helper that names environment variables, never values.
|
||||
- **D7:** test services use ports 18000–18099, so opening access later is a
|
||||
firewall change. The inference host is behind LiteLLM, so it is not a second
|
||||
egress exception.
|
||||
|
||||
@@ -9,9 +9,15 @@
|
||||
# silently doing nothing.
|
||||
|
||||
state_db: ~/nisse-demo-workspace/state.db
|
||||
workspace: ~/nisse-demo-workspace/project # the clone
|
||||
workspace: /root/work/demo # the clone, on the build host
|
||||
project: demo
|
||||
|
||||
build_host: # L13: every command runs here, over SSH
|
||||
address: build-demo.example
|
||||
key: ~/.ssh/nisse-dev # the agent's key for the build host
|
||||
# user: root
|
||||
# port: 22
|
||||
|
||||
llm: # L9: LiteLLM is the only LLM destination
|
||||
base_url: http://litellm.example:4000
|
||||
token: ${NISSE_LLM_TOKEN}
|
||||
@@ -41,6 +47,7 @@ gitea: # D4, D5
|
||||
repo: owner/demo-repo
|
||||
operators: [your-gitea-login]
|
||||
base_branch: main
|
||||
push_key: ~/.ssh/push-demo-repo # a deploy key with write access (D4)
|
||||
|
||||
# What each knob does, and how to choose a value: docs/Tuning-Guide.md.
|
||||
# These are the defaults; the section can be left out entirely. The file is
|
||||
|
||||
@@ -18,9 +18,10 @@ from nisse_dev.config import AgentConfig
|
||||
from nisse_dev.context import BudgetedClient
|
||||
from nisse_dev.control import Orchestrator
|
||||
from nisse_dev.delegation import Caps, Delegation
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.executor import LLMExecutor
|
||||
from nisse_dev.expert import ExpertDesk
|
||||
from nisse_dev.git import GitRepo, authenticated_url
|
||||
from nisse_dev.git import GitRepo, HTTPSToken, PushCredential, SSHKey
|
||||
from nisse_dev.gitea import Comment, GiteaClient
|
||||
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
|
||||
from nisse_dev.matrix import MatrixTransport
|
||||
@@ -164,20 +165,59 @@ def make_llm_clients(
|
||||
return normalized, passthrough
|
||||
|
||||
|
||||
def prepare_workspace(config: AgentConfig, account: str) -> GitRepo:
|
||||
"""Ensure the workspace is a clone of the project repo.
|
||||
|
||||
Cloning here rather than on `use <project>` is the single-project
|
||||
shortcut: resolving a project name to a repo is issue #27's work.
|
||||
"""
|
||||
repo = GitRepo(config.workspace)
|
||||
remote = authenticated_url(
|
||||
config.gitea_url, config.gitea_repo, account, config.gitea_token
|
||||
def build_host(config: AgentConfig) -> SSHHost:
|
||||
"""The tool VM the workspace lives on (L13). Its known-hosts file and
|
||||
connection socket live beside the state store."""
|
||||
return SSHHost(
|
||||
config.build_host.address,
|
||||
key=config.build_host.key,
|
||||
user=config.build_host.user,
|
||||
port=config.build_host.port,
|
||||
state_dir=config.state_db.parent / "ssh",
|
||||
)
|
||||
if not (config.workspace / ".git").exists():
|
||||
config.workspace.parent.mkdir(parents=True, exist_ok=True)
|
||||
GitRepo(config.workspace.parent).clone(remote, config.workspace)
|
||||
|
||||
|
||||
def push_credential(config: AgentConfig) -> PushCredential | None:
|
||||
"""What pushes go by: a push-only account's token over HTTPS, or a
|
||||
deploy key over SSH (D4)."""
|
||||
if config.push_user and config.push_token:
|
||||
return HTTPSToken(config.push_user, config.push_token)
|
||||
if config.push_key is None:
|
||||
return None
|
||||
try:
|
||||
return SSHKey(config.push_key.read_text())
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"cannot read the push key {config.push_key}: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def remote_url(gitea: GiteaClient, repo: str, credential: PushCredential | None) -> str:
|
||||
"""The credential-free URL the workspace clones from and pushes to."""
|
||||
if isinstance(credential, SSHKey):
|
||||
return gitea.ssh_url(repo)
|
||||
return gitea.clone_url(repo)
|
||||
|
||||
|
||||
def prepare_workspace(
|
||||
host: SSHHost,
|
||||
config: AgentConfig,
|
||||
account: str,
|
||||
remote: str,
|
||||
credential: PushCredential | None,
|
||||
) -> GitRepo:
|
||||
"""Ensure the workspace on the build VM is a clone of the project repo.
|
||||
|
||||
The clone uses the repository's SSH URL, so no credential is ever stored
|
||||
in it (#89); the push credential is handed to the clone and nothing else.
|
||||
Cloning here rather than when a work item starts is the single-project
|
||||
shortcut that M1.4 and D3's fresh workspace replace.
|
||||
"""
|
||||
repo = GitRepo(host, config.workspace)
|
||||
if not repo.exists():
|
||||
repo.clone(remote, credential=credential)
|
||||
repo.configure_identity(account, f"{account}@users.noreply.local")
|
||||
repo.install_excludes() # build artifacts are never the work (#80)
|
||||
return repo
|
||||
|
||||
|
||||
@@ -204,9 +244,13 @@ def build_agent(
|
||||
# anything depends on it.
|
||||
account = gitea.whoami()
|
||||
|
||||
git_repo = prepare_workspace(config, account)
|
||||
host = build_host(config)
|
||||
credential = push_credential(config)
|
||||
remote = remote_url(gitea, config.gitea_repo, credential)
|
||||
git_repo = prepare_workspace(host, config, account, remote, credential)
|
||||
tuning = config.tuning
|
||||
workspace = Workspace(
|
||||
host,
|
||||
config.workspace,
|
||||
timeout_s=tuning.tools.command_timeout_s,
|
||||
max_output_chars=tuning.tools.max_output_chars,
|
||||
@@ -249,9 +293,8 @@ def build_agent(
|
||||
gitea,
|
||||
config.gitea_repo,
|
||||
git_repo,
|
||||
base_url=config.gitea_url,
|
||||
account=account,
|
||||
token=config.gitea_token,
|
||||
remote_url=remote,
|
||||
push_credential=credential,
|
||||
base_branch=config.base_branch,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Running one task once with one executor model, and recording it."""
|
||||
|
||||
import io
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -20,6 +22,7 @@ from nisse_dev.canonical import (
|
||||
)
|
||||
from nisse_dev.catalog import ModelFacts
|
||||
from nisse_dev.config import Tuning
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.executor import LLMExecutor
|
||||
from nisse_dev.llm import RoleMap
|
||||
from nisse_dev.oplog import OpLog
|
||||
@@ -150,6 +153,7 @@ def run_task(
|
||||
client: Any,
|
||||
tuning: Tuning,
|
||||
*,
|
||||
host: SSHHost,
|
||||
note: str = "",
|
||||
commit: str = "",
|
||||
workdir: Path | None = None,
|
||||
@@ -161,12 +165,14 @@ def run_task(
|
||||
model under test, not a frontier model's success."""
|
||||
with tempfile.TemporaryDirectory(dir=workdir) as tmp:
|
||||
root = Path(tmp)
|
||||
# The workspace is on the build VM, like the agent's own (L13).
|
||||
workspace = Workspace(
|
||||
root / "work",
|
||||
host,
|
||||
f"/tmp/nisse-bench/{uuid.uuid4().hex}",
|
||||
timeout_s=tuning.tools.command_timeout_s,
|
||||
max_output_chars=tuning.tools.max_output_chars,
|
||||
)
|
||||
task.seed_into(workspace.root)
|
||||
task.seed_workspace(workspace)
|
||||
store = StateStore(root / "state.db", oplog=OpLog("nisse-bench", io.StringIO()))
|
||||
# One work item per run, as the agent's own tasks are (L1).
|
||||
store.open_work_item(
|
||||
@@ -198,7 +204,8 @@ def run_task(
|
||||
status, detail = "error", repr(exc)
|
||||
wall = time.monotonic() - started
|
||||
|
||||
success, check_output = task.run_check(workspace.root)
|
||||
success, check_output = task.check_workspace(workspace)
|
||||
host.execute(f"rm -rf -- {shlex.quote(str(workspace.root))}", timeout_s=60)
|
||||
failed, invented = _count(store.transcript("bench", "executor").read())
|
||||
store.close()
|
||||
|
||||
|
||||
@@ -13,13 +13,18 @@ Each task is a directory under ``tasks/``:
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nisse_dev.tools import Workspace
|
||||
|
||||
TASKS_DIR = Path(__file__).parent / "tasks"
|
||||
CHECK_DIR = ".bench_check"
|
||||
|
||||
@@ -75,6 +80,26 @@ class Task:
|
||||
output = (result.stdout + result.stderr)[-2000:]
|
||||
return result.returncode == 0, output
|
||||
|
||||
# -- on the build VM, where the agent's runs happen (L13) ---------------
|
||||
|
||||
def seed_workspace(self, workspace: "Workspace") -> None:
|
||||
workspace.upload_tree(self.path / "seed")
|
||||
|
||||
def check_workspace(
|
||||
self, workspace: "Workspace", timeout_s: int = 300
|
||||
) -> tuple[bool, str]:
|
||||
"""run_check, on the build VM: copy the hidden files in fresh (never
|
||||
trusting a directory the model made) and run the check there."""
|
||||
root = shlex.quote(str(workspace.root / CHECK_DIR))
|
||||
workspace.host.execute(f"rm -rf -- {root}", timeout_s=60)
|
||||
workspace.upload_tree(self.path / "hidden", CHECK_DIR)
|
||||
result = workspace.host.execute(
|
||||
self.check, cwd=str(workspace.root), timeout_s=timeout_s
|
||||
)
|
||||
if result.timed_out:
|
||||
return False, f"check timed out after {timeout_s}s"
|
||||
return result.exit_code == 0, (result.stdout + result.stderr)[-2000:]
|
||||
|
||||
|
||||
def load_task(path: Path) -> Task:
|
||||
spec = yaml.safe_load((path / "task.yaml").read_text())
|
||||
|
||||
@@ -47,8 +47,19 @@ REQUIRED_KEYS = (
|
||||
"gitea.token",
|
||||
"gitea.repo",
|
||||
"gitea.operators",
|
||||
"build_host.address",
|
||||
"build_host.key",
|
||||
)
|
||||
OPTIONAL_KEYS = (
|
||||
"llm.max_output_tokens",
|
||||
"llm.request_timeout_s",
|
||||
"gitea.base_branch",
|
||||
"gitea.push_key",
|
||||
"gitea.push_user",
|
||||
"gitea.push_token",
|
||||
"build_host.user",
|
||||
"build_host.port",
|
||||
)
|
||||
OPTIONAL_KEYS = ("llm.max_output_tokens", "llm.request_timeout_s", "gitea.base_branch")
|
||||
|
||||
_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
@@ -205,21 +216,41 @@ def _read_file(path: Path, errors: list[str]) -> dict[str, Any]:
|
||||
return loaded
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildHost:
|
||||
"""The tool VM the workspace lives on (L13, D3). ``key`` is the path to
|
||||
the agent's SSH private key for it."""
|
||||
|
||||
address: str
|
||||
key: Path
|
||||
user: str = "root"
|
||||
port: int = 22
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentConfig:
|
||||
state_db: Path
|
||||
workspace: Path
|
||||
project: str
|
||||
llm_base_url: str
|
||||
llm_token: str
|
||||
# Credentials never appear in a repr: a failing test or a log line
|
||||
# would print them.
|
||||
llm_token: str = field(repr=False)
|
||||
roles: RoleMap
|
||||
matrix: MatrixConfig
|
||||
operators: frozenset[str]
|
||||
gitea_url: str
|
||||
gitea_token: str
|
||||
gitea_token: str = field(repr=False)
|
||||
gitea_repo: str
|
||||
gitea_operators: frozenset[str]
|
||||
build_host: BuildHost
|
||||
base_branch: str = "main"
|
||||
# What may push to the project repository, handed to each push on the
|
||||
# build VM and to nothing else (D4): a push-only account's HTTPS token
|
||||
# (push_user, push_token), or the path to a deploy key (push_key).
|
||||
push_key: Path | None = None
|
||||
push_user: str | None = None
|
||||
push_token: str | None = field(default=None, repr=False)
|
||||
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
|
||||
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
|
||||
tuning: Tuning = field(default_factory=Tuning)
|
||||
@@ -300,9 +331,23 @@ class AgentConfig:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
errors.append("llm.request_timeout_s: must be a positive number of seconds")
|
||||
port = values.get("build_host.port", 22)
|
||||
try:
|
||||
port = int(port)
|
||||
if not 0 < port < 65536:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
errors.append("build_host.port: must be a TCP port number")
|
||||
push_user = values.get("gitea.push_user")
|
||||
push_token = values.get("gitea.push_token")
|
||||
if bool(push_user) != bool(push_token):
|
||||
errors.append("gitea.push_user and gitea.push_token: set both or neither")
|
||||
if push_token and values.get("gitea.push_key"):
|
||||
errors.append("gitea: set a push token or a push key, not both")
|
||||
if errors:
|
||||
raise ConfigError("cannot start: " + "; ".join(errors))
|
||||
|
||||
push_key = values.get("gitea.push_key")
|
||||
config = cls(
|
||||
state_db=Path(values["state_db"]).expanduser(),
|
||||
workspace=Path(values["workspace"]).expanduser(),
|
||||
@@ -326,6 +371,15 @@ class AgentConfig:
|
||||
gitea_token=str(values["gitea.token"]),
|
||||
gitea_repo=str(values["gitea.repo"]),
|
||||
gitea_operators=_people(values["gitea.operators"]),
|
||||
build_host=BuildHost(
|
||||
address=str(values["build_host.address"]),
|
||||
key=Path(values["build_host.key"]).expanduser(),
|
||||
user=str(values.get("build_host.user") or "root"),
|
||||
port=port,
|
||||
),
|
||||
push_key=Path(push_key).expanduser() if push_key else None,
|
||||
push_user=str(push_user) if push_user else None,
|
||||
push_token=str(push_token) if push_token else None,
|
||||
base_branch=str(values.get("gitea.base_branch") or "main"),
|
||||
max_output_tokens=max_output,
|
||||
request_timeout_s=request_timeout,
|
||||
@@ -337,7 +391,9 @@ class AgentConfig:
|
||||
|
||||
def _refuse_secrets_inside_the_clone(self, env: Mapping[str, str]) -> None:
|
||||
"""The workspace is a git clone the agent commits with ``git add -A``
|
||||
and pushes. Anything inside it reaches the project repository, so a
|
||||
and pushes. It lives on the build VM, so this only bites when the
|
||||
build host is this machine (tests, a local bring-up), and there it
|
||||
matters exactly as before. Anything inside it reaches the project repository, so a
|
||||
state store or an env file placed there would publish credentials as a
|
||||
side effect of the agent working correctly — silently, and once. The
|
||||
config file holds no secrets itself, but it names where they come
|
||||
@@ -367,7 +423,7 @@ class AgentConfig:
|
||||
"""What the agent may safely announce about how it is configured."""
|
||||
return {
|
||||
"project": self.project,
|
||||
"workspace": str(self.workspace),
|
||||
"workspace": f"{self.build_host.address}:{self.workspace}",
|
||||
"repo": self.gitea_repo,
|
||||
"planner": self.roles.planner,
|
||||
"executor": self.roles.executor,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Running commands on a tool VM over SSH (architecture L13).
|
||||
|
||||
``SSHHost.execute`` is the only way the agent runs anything: there is no
|
||||
local execution path, so the host the agent lives on never runs a command a
|
||||
model chose. The harness binds the host; a model never names one.
|
||||
|
||||
How a command travels. The remote side runs ``bash -s`` and reads a small
|
||||
script from SSH's stdin: the secrets handed to this one call as ``export``
|
||||
lines, a ``cd``, then the command under ``timeout``. Nothing secret is ever on
|
||||
a command line (``ps`` and ``/proc/*/cmdline`` would show it); the values exist
|
||||
only in that shell and the command's environment. The command's own stdin is
|
||||
``/dev/null``, or a here-document carrying ``stdin``, so it can never read the
|
||||
rest of the script.
|
||||
|
||||
Every value handed out is redacted from what comes back (L12): a test that
|
||||
prints its environment must not put a key into the transcript.
|
||||
|
||||
The connection is reused across calls (OpenSSH's ControlMaster), so a tool
|
||||
call costs one round trip, not a handshake.
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets as _random
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# The remote command's exit code is passed back as is, except that 255 is
|
||||
# what ssh itself uses for "could not connect"; a command exiting 255 is
|
||||
# reported as 254 so the two can't be confused.
|
||||
SSH_FAILED = 255
|
||||
_TIMED_OUT = 124 # coreutils `timeout`
|
||||
_KILL_GRACE_S = 5
|
||||
# The local backstop, beyond the remote timeout, for a connection that hangs.
|
||||
_LOCAL_SLACK_S = 30
|
||||
|
||||
REDACTED = "[redacted]"
|
||||
|
||||
|
||||
class HostUnavailable(RuntimeError):
|
||||
"""The host could not be reached: not something the command did."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
timed_out: bool = False
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.exit_code == 0 and not self.timed_out
|
||||
|
||||
|
||||
def redact(text: str, values: Iterable[str]) -> str:
|
||||
"""Mask every non-trivial secret value in ``text``. Longest first, so a
|
||||
value containing another is masked whole."""
|
||||
for value in sorted((v for v in values if len(v) >= 4), key=len, reverse=True):
|
||||
text = text.replace(value, REDACTED)
|
||||
return text
|
||||
|
||||
|
||||
def _heredoc(body: str) -> tuple[str, str]:
|
||||
"""A here-document redirection whose delimiter can't occur in ``body``."""
|
||||
while True:
|
||||
tag = f"NISSE_EOF_{_random.token_hex(8)}"
|
||||
if tag not in body:
|
||||
return f"<<'{tag}'", f"\n{body}\n{tag}"
|
||||
|
||||
|
||||
def _script(
|
||||
command: str,
|
||||
cwd: str | None,
|
||||
secrets: Mapping[str, str],
|
||||
stdin: str | None,
|
||||
timeout_s: int,
|
||||
) -> str:
|
||||
lines = [f"export {name}={shlex.quote(value)}" for name, value in secrets.items()]
|
||||
if cwd is not None:
|
||||
lines.append(f"cd -- {shlex.quote(cwd)} || exit 125")
|
||||
redirect, body = ("< /dev/null", "") if stdin is None else _heredoc(stdin)
|
||||
# `timeout` runs the command in its own process group and signals the
|
||||
# whole group, so a backgrounded grandchild doesn't outlive it.
|
||||
lines.append(
|
||||
f"timeout --kill-after={_KILL_GRACE_S} {timeout_s} "
|
||||
f"bash -c {shlex.quote(command)} {redirect}{body}"
|
||||
)
|
||||
lines.append("rc=$?; [ $rc -eq 255 ] && rc=254; exit $rc")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
class SSHHost:
|
||||
"""One tool VM, reached as ``user@address`` with a private key."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address: str,
|
||||
*,
|
||||
key: Path,
|
||||
state_dir: Path,
|
||||
user: str = "root",
|
||||
port: int = 22,
|
||||
) -> None:
|
||||
self.address = address
|
||||
self.user = user
|
||||
self.port = port
|
||||
self._key = Path(key)
|
||||
state_dir = Path(state_dir)
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._known_hosts = state_dir / "known_hosts"
|
||||
# A Unix socket's path is limited to about 100 bytes, so the shared
|
||||
# connection's socket lives in a short private directory rather than
|
||||
# beside the state, named by %C, ssh's hash of the connection.
|
||||
sockets = Path(tempfile.gettempdir()) / f"nisse-ssh-{os.getuid()}"
|
||||
sockets.mkdir(mode=0o700, exist_ok=True)
|
||||
self._control = sockets / "%C"
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"{self.user}@{self.address}"
|
||||
|
||||
def _ssh(self) -> list[str]:
|
||||
return [
|
||||
"ssh",
|
||||
"-i",
|
||||
str(self._key),
|
||||
"-p",
|
||||
str(self.port),
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"IdentitiesOnly=yes",
|
||||
"-o",
|
||||
f"UserKnownHostsFile={self._known_hosts}",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-o",
|
||||
"ControlMaster=auto",
|
||||
"-o",
|
||||
f"ControlPath={self._control}",
|
||||
"-o",
|
||||
"ControlPersist=120",
|
||||
"-o",
|
||||
"ConnectTimeout=15",
|
||||
"-o",
|
||||
"ServerAliveInterval=15",
|
||||
"-o",
|
||||
"LogLevel=ERROR",
|
||||
f"{self.user}@{self.address}",
|
||||
"bash -s",
|
||||
]
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
secrets: Mapping[str, str] | None = None,
|
||||
stdin: str | None = None,
|
||||
timeout_s: int = 120,
|
||||
) -> Result:
|
||||
"""Run ``command`` with bash in ``cwd``. ``secrets`` become environment
|
||||
variables of this one command and are redacted from its output.
|
||||
Raises HostUnavailable when the host can't be reached."""
|
||||
handed_out = dict(secrets or {})
|
||||
script = _script(command, cwd, handed_out, stdin, timeout_s)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
self._ssh(),
|
||||
input=script.encode(),
|
||||
capture_output=True,
|
||||
timeout=timeout_s + _KILL_GRACE_S + _LOCAL_SLACK_S,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HostUnavailable(
|
||||
f"{self.describe()} stopped responding during a command"
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise HostUnavailable(f"could not run ssh: {exc}") from exc
|
||||
stdout = completed.stdout.decode(errors="replace")
|
||||
stderr = completed.stderr.decode(errors="replace")
|
||||
if completed.returncode == SSH_FAILED:
|
||||
raise HostUnavailable(
|
||||
f"could not reach {self.describe()}: "
|
||||
+ redact(stderr.strip() or "ssh failed", handed_out.values())
|
||||
)
|
||||
values = handed_out.values()
|
||||
return Result(
|
||||
exit_code=completed.returncode,
|
||||
stdout=redact(stdout, values),
|
||||
stderr=redact(stderr, values),
|
||||
timed_out=completed.returncode in (_TIMED_OUT, 137),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""End the shared connection, if one is open."""
|
||||
args = self._ssh()[:-2] # without the destination and the command
|
||||
subprocess.run(
|
||||
[*args, "-O", "exit", f"{self.user}@{self.address}"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
@@ -134,7 +134,7 @@ class LLMExecutor:
|
||||
result = self._delegate.run(
|
||||
transcript.as_actor("helper"),
|
||||
brief=brief,
|
||||
branch=f"delegation/milestone-{milestone}-{failures}",
|
||||
branch=f"delegation-milestone-{milestone}-{failures}",
|
||||
)
|
||||
if result.usable:
|
||||
return None
|
||||
|
||||
+212
-59
@@ -1,111 +1,272 @@
|
||||
"""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 credential for that one operation, and the
|
||||
model's commands never receive it:
|
||||
|
||||
- an HTTPS token (the default: HTTPS reaches the forge from everywhere) goes
|
||||
to git through a credential helper that names environment variables, never
|
||||
values; the values are that one command's secrets, so they are never on a
|
||||
command line and are redacted from its output;
|
||||
- an SSH key is fed over stdin to a short-lived ``ssh-agent`` on the VM, gone
|
||||
when the command ends.
|
||||
|
||||
Pull requests are opened from the agent's own host with its Gitea API token
|
||||
(publish.py), which never reaches the VM.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
import shlex
|
||||
from dataclasses import dataclass, field
|
||||
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
|
||||
|
||||
|
||||
# Build artifacts and caches that are never the work (D4, #80). Kept out of
|
||||
# every commit the harness makes, tracked or not, and excluded in the clone
|
||||
# so they don't even show as changes. Conservative on purpose: a pattern here
|
||||
# must never match a project's real source.
|
||||
ARTIFACT_PATTERNS = (
|
||||
"__pycache__/",
|
||||
"*.py[cod]",
|
||||
".pytest_cache/",
|
||||
".mypy_cache/",
|
||||
".ruff_cache/",
|
||||
".tox/",
|
||||
".nox/",
|
||||
"*.egg-info/",
|
||||
".coverage",
|
||||
"htmlcov/",
|
||||
".venv/",
|
||||
"node_modules/",
|
||||
".DS_Store",
|
||||
)
|
||||
_EXCLUDES_MARK = "# nisse-dev: build artifacts are never the work (D4)"
|
||||
|
||||
|
||||
def _pathspec(pattern: str) -> str:
|
||||
"""A pathspec leaving ``pattern`` out wherever it occurs in the tree."""
|
||||
glob = pattern.rstrip("/")
|
||||
tail = "/**" if pattern.endswith("/") else ""
|
||||
return f":(exclude,glob)**/{glob}{tail}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HTTPSToken:
|
||||
"""A push-only account's token, for HTTPS remotes (D4)."""
|
||||
|
||||
username: str
|
||||
# Never in a repr: a failing test or a log line would print it.
|
||||
token: str = field(repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SSHKey:
|
||||
"""A deploy key's private half, for SSH remotes (D4)."""
|
||||
|
||||
private_key: str = field(repr=False)
|
||||
|
||||
|
||||
PushCredential = HTTPSToken | SSHKey
|
||||
|
||||
# git asks the helper for credentials; it answers from the environment, so
|
||||
# the command line names variables, never values.
|
||||
_HELPER = (
|
||||
'!f() { test "$1" = get || return 0; '
|
||||
'echo "username=$NISSE_PUSH_USER"; echo "password=$NISSE_PUSH_TOKEN"; }; f'
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=self.root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=TIMEOUT_S,
|
||||
check=False,
|
||||
def _git(
|
||||
self,
|
||||
*args: str,
|
||||
credential: PushCredential | None = None,
|
||||
cwd: str | None = None,
|
||||
raw: bool = False,
|
||||
) -> str:
|
||||
quoted = " ".join(shlex.quote(a) for a in args)
|
||||
command, stdin, secrets = f"git {quoted}", None, {}
|
||||
if isinstance(credential, SSHKey):
|
||||
command = _with_key(command, self.known_hosts)
|
||||
stdin = credential.private_key
|
||||
elif isinstance(credential, HTTPSToken):
|
||||
# The empty helper first clears any configured on the VM.
|
||||
command = (
|
||||
"GIT_TERMINAL_PROMPT=0 git -c credential.helper= "
|
||||
f"-c credential.helper={shlex.quote(_HELPER)} {quoted}"
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise GitError(f"git {args[0]} failed: {_scrub(str(exc), secret)}") from exc
|
||||
if completed.returncode != 0:
|
||||
secrets = {
|
||||
"NISSE_PUSH_USER": credential.username,
|
||||
"NISSE_PUSH_TOKEN": credential.token,
|
||||
}
|
||||
try:
|
||||
result = self.host.execute(
|
||||
command,
|
||||
cwd=cwd or str(self.root),
|
||||
stdin=stdin,
|
||||
secrets=secrets,
|
||||
timeout_s=TIMEOUT_S,
|
||||
)
|
||||
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()
|
||||
# Raw for output whose leading whitespace means something (porcelain).
|
||||
return result.stdout if raw else 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, *, credential: PushCredential | 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}", credential=credential
|
||||
)
|
||||
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, *, credential: PushCredential | 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), credential=credential, 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
|
||||
|
||||
def install_excludes(self) -> None:
|
||||
"""List the build artifacts in the clone's own exclude file, so they
|
||||
never appear as changes. Local to the clone: nothing in the project
|
||||
changes. Idempotent."""
|
||||
path = shlex.quote(str(self.root / ".git" / "info" / "exclude"))
|
||||
block = "\n".join([_EXCLUDES_MARK, *ARTIFACT_PATTERNS])
|
||||
result = self.host.execute(
|
||||
f'mkdir -p -- "$(dirname -- {path})" && touch -- {path} && '
|
||||
f"grep -qxF {shlex.quote(_EXCLUDES_MARK)} {path} || "
|
||||
f"cat >> {path}",
|
||||
stdin=block,
|
||||
timeout_s=TIMEOUT_S,
|
||||
)
|
||||
if not result.ok:
|
||||
raise GitError(f"could not set excludes: {result.stderr.strip()}")
|
||||
|
||||
def commit_all(self, message: str) -> str | None:
|
||||
"""Returns the new commit sha, or None when there was nothing to
|
||||
commit — an empty milestone is a fact to report, not an error."""
|
||||
"""Commit the work: every change except build artifacts, which are
|
||||
left out even when the project tracks them (#80). Returns the new
|
||||
commit sha, or None when there was nothing to 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", "--", ".", *(_pathspec(p) for p in ARTIFACT_PATTERNS))
|
||||
staged = self._git("diff", "--cached", "--name-only")
|
||||
if not staged:
|
||||
return None
|
||||
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 left_out(self) -> list[str]:
|
||||
"""Changes not in the last commit: build artifacts the harness left
|
||||
out, to be reported rather than silently dropped (D4)."""
|
||||
out = self._git("status", "--porcelain", "--untracked-files=all", raw=True)
|
||||
return sorted(line[3:] for line in out.splitlines() if line)
|
||||
|
||||
def push(
|
||||
self, remote_url: str, branch: str, *, credential: PushCredential | 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}", credential=credential
|
||||
)
|
||||
|
||||
def discard_branch(self, branch: str, *, back_to: str) -> None:
|
||||
"""Throw away a branch and everything on it.
|
||||
@@ -114,20 +275,12 @@ 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, *, credential: PushCredential | 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}", credential=credential
|
||||
)
|
||||
@@ -70,6 +70,14 @@ class GiteaClient:
|
||||
)
|
||||
return response.json() if response.content else None
|
||||
|
||||
def ssh_url(self, repo: str) -> str:
|
||||
"""The repository's SSH clone URL, which carries no credential."""
|
||||
return str(self._request("GET", f"/repos/{repo}")["ssh_url"])
|
||||
|
||||
def clone_url(self, repo: str) -> str:
|
||||
"""The repository's HTTPS clone URL, which carries no credential."""
|
||||
return str(self._request("GET", f"/repos/{repo}")["clone_url"])
|
||||
|
||||
def whoami(self) -> str:
|
||||
return str(self._request("GET", "/user")["login"])
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import re
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
@@ -40,7 +40,7 @@ class TransportError(RuntimeError):
|
||||
class MatrixConfig:
|
||||
homeserver: str
|
||||
user_id: str
|
||||
token: str
|
||||
token: str = field(repr=False) # never in a repr or a log line
|
||||
room_id: str
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ PR exists rather than repeating the push-and-open — the spec's own example of
|
||||
an idempotent transition reconciled against external truth.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nisse_dev.git import GitRepo, authenticated_url
|
||||
from nisse_dev.git import GitRepo, PushCredential
|
||||
from nisse_dev.gitea import (
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
@@ -36,9 +37,8 @@ class MilestonePublisher:
|
||||
repo: str,
|
||||
git_repo: GitRepo,
|
||||
*,
|
||||
base_url: str,
|
||||
account: str,
|
||||
token: str,
|
||||
remote_url: str,
|
||||
push_credential: PushCredential | None = None,
|
||||
base_branch: str = "main",
|
||||
) -> None:
|
||||
self._store = store
|
||||
@@ -46,16 +46,20 @@ class MilestonePublisher:
|
||||
self._repo = repo
|
||||
self._git = git_repo
|
||||
self._base_branch = base_branch
|
||||
self._remote = authenticated_url(base_url, repo, account, token)
|
||||
self._token = token
|
||||
# A URL with no credential in it; the push credential is handed to
|
||||
# each push on the build VM and nothing else (D4, L12).
|
||||
self._remote = remote_url
|
||||
self._credential = push_credential
|
||||
|
||||
def reconcilers(self) -> dict[str, Reconciler]:
|
||||
return {OPEN_PR_INTENT: pull_request_reconciler(self._client, self._repo)}
|
||||
|
||||
def branch_for(self, task_id: str, milestone: int) -> str:
|
||||
# Namespaced by task so re-running a project against the same repo
|
||||
# cannot collide with an earlier run's branch.
|
||||
return f"{task_id}/milestone-{milestone}"
|
||||
# cannot collide with an earlier run's branch. Flat, with no "/":
|
||||
# git stores branch names as paths, so a branch named after the task
|
||||
# (which a model did make, live) would block "<task>/milestone-1".
|
||||
return f"{task_id}-milestone-{milestone}"
|
||||
|
||||
def publish(
|
||||
self,
|
||||
@@ -77,6 +81,8 @@ class MilestonePublisher:
|
||||
return PublishResult(None, "no changes to publish")
|
||||
# The executor committed its work itself; publish that.
|
||||
sha = self._git.head_sha()
|
||||
# D4: what was left out (build artifacts) is reported, not dropped.
|
||||
left_out = self._git.left_out()
|
||||
|
||||
intent_id = self._store.begin_intent(
|
||||
OPEN_PR_INTENT,
|
||||
@@ -84,7 +90,7 @@ class MilestonePublisher:
|
||||
cause=f"publishing milestone {milestone}",
|
||||
work_item=task_id, # owned by the work item it publishes (L1)
|
||||
)
|
||||
self._git.push(self._remote, branch, token=self._token)
|
||||
self._git.push(self._remote, branch, credential=self._credential)
|
||||
# Idempotent: if a PR for this branch is already open (a crash after
|
||||
# opening it, or a response lost on the way back), the push above
|
||||
# has updated it; adopt it rather than fail on a second.
|
||||
@@ -96,17 +102,19 @@ class MilestonePublisher:
|
||||
return PublishResult(
|
||||
existing,
|
||||
f"PR #{existing.number} was already open for this milestone; "
|
||||
f"pushed to it: {existing.url}",
|
||||
f"pushed to it: {existing.url}" + _left_out_note(left_out),
|
||||
)
|
||||
pr = self._client.open_pull_request(
|
||||
self._repo,
|
||||
head=branch,
|
||||
base=self._base_branch,
|
||||
title=f"Milestone {milestone}: {project}",
|
||||
body=_pr_body(milestone, plan, service_url),
|
||||
body=_pr_body(milestone, plan, service_url, left_out),
|
||||
)
|
||||
self._store.commit_intent(intent_id, cause=f"opened PR #{pr.number}")
|
||||
return PublishResult(pr, f"opened PR #{pr.number}: {pr.url}")
|
||||
return PublishResult(
|
||||
pr, f"opened PR #{pr.number}: {pr.url}" + _left_out_note(left_out)
|
||||
)
|
||||
|
||||
def published_pull_request(
|
||||
self, task_id: str, milestone: int
|
||||
@@ -149,14 +157,30 @@ class MilestonePublisher:
|
||||
if sha is None:
|
||||
# The executor may have committed the fix itself.
|
||||
sha = self._git.head_sha()
|
||||
pushed = self._git.remote_sha(self._remote, branch, token=self._token)
|
||||
pushed = self._git.remote_sha(
|
||||
self._remote, branch, credential=self._credential
|
||||
)
|
||||
if sha == pushed:
|
||||
return None
|
||||
self._git.push(self._remote, branch, token=self._token)
|
||||
self._git.push(self._remote, branch, credential=self._credential)
|
||||
return sha
|
||||
|
||||
|
||||
def _pr_body(milestone: int, plan: str, service_url: str | None) -> str:
|
||||
def _left_out_note(paths: list[str]) -> str:
|
||||
if not paths:
|
||||
return ""
|
||||
shown = ", ".join(paths[:5]) + (
|
||||
f" and {len(paths) - 5} more" if len(paths) > 5 else ""
|
||||
)
|
||||
return f" (left out {len(paths)} build artifact(s): {shown})"
|
||||
|
||||
|
||||
def _pr_body(
|
||||
milestone: int,
|
||||
plan: str,
|
||||
service_url: str | None,
|
||||
left_out: Sequence[str] = (),
|
||||
) -> str:
|
||||
parts = [
|
||||
f"Milestone {milestone}, implemented by nisse-dev against the approved plan.",
|
||||
"",
|
||||
@@ -169,4 +193,13 @@ def _pr_body(milestone: int, plan: str, service_url: str | None) -> str:
|
||||
# so a reviewer can exercise it. Nothing reports one yet; the browser
|
||||
# tool (D7) is what will.
|
||||
parts += ["", "## Running test service", "", service_url]
|
||||
if left_out:
|
||||
parts += [
|
||||
"",
|
||||
"## Left out",
|
||||
"",
|
||||
"Build artifacts in the workspace, never part of the work:",
|
||||
"",
|
||||
*(f"- `{path}`" for path in left_out),
|
||||
]
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Test support: a throwaway sshd standing in for a tool VM (L13).
|
||||
|
||||
Runs the system's sshd as the current user, on a free local port, with a
|
||||
host key and client key made for the occasion. Tests then reach "the build
|
||||
VM" over real SSH, so the code path under test is the production path.
|
||||
"""
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from nisse_dev.execution import SSHHost
|
||||
|
||||
SSHD = "/usr/sbin/sshd"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _keygen(path: Path) -> None:
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(path)], check=True
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def local_sshd(root: Path) -> Iterator[SSHHost]:
|
||||
"""An SSHHost for a private sshd on 127.0.0.1, torn down afterwards."""
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
host_key, client_key = root / "host_key", root / "client_key"
|
||||
_keygen(host_key)
|
||||
_keygen(client_key)
|
||||
authorized = root / "authorized_keys"
|
||||
authorized.write_text(client_key.with_suffix(".pub").read_text())
|
||||
authorized.chmod(0o600)
|
||||
port = _free_port()
|
||||
config = root / "sshd_config"
|
||||
config.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
f"Port {port}",
|
||||
"ListenAddress 127.0.0.1",
|
||||
f"HostKey {host_key}",
|
||||
f"AuthorizedKeysFile {authorized}",
|
||||
f"PidFile {root / 'sshd.pid'}",
|
||||
"PasswordAuthentication no",
|
||||
"KbdInteractiveAuthentication no",
|
||||
"PubkeyAuthentication yes",
|
||||
"PermitRootLogin prohibit-password",
|
||||
"StrictModes no",
|
||||
"UsePAM no",
|
||||
"MaxSessions 50",
|
||||
"MaxStartups 50",
|
||||
# Stands in for a provisioned build VM: sessions see the test
|
||||
# runner's PATH, so its Python and tools are "installed".
|
||||
f"SetEnv PATH={os.environ.get('PATH', '/usr/bin:/bin')}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
)
|
||||
process = subprocess.Popen(
|
||||
[SSHD, "-D", "-e", "-f", str(config)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
user = getpass.getuser()
|
||||
host = SSHHost(
|
||||
"127.0.0.1", key=client_key, state_dir=root / "client", user=user, port=port
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(
|
||||
"sshd exited: " + (process.stderr.read() or b"").decode()
|
||||
if process.stderr
|
||||
else "sshd exited"
|
||||
)
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("sshd did not start") from None
|
||||
time.sleep(0.05)
|
||||
yield host
|
||||
finally:
|
||||
host.close()
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
@@ -1,26 +1,27 @@
|
||||
"""Executor tools (architecture L9: reduced schema, flat arguments).
|
||||
"""Executor tools (architecture L9: reduced schema, flat arguments; L13).
|
||||
|
||||
Deliberately unsandboxed. The VM is the trust boundary (D7) and tool use is
|
||||
auto-approved inside an approved unit of work (L3), so per-command
|
||||
confinement would add failure modes without adding safety. The workspace is
|
||||
where work happens — the default for relative paths and the command cwd — not
|
||||
a wall. Protecting agent state from the agent is a mount-layout concern
|
||||
(L8/D7), not this module's job.
|
||||
Every tool runs on the work item's build VM over SSH (execution.py); the
|
||||
agent's own host never runs a command a model chose. Within the VM nothing is
|
||||
sandboxed: the VM is the trust boundary (D7), so per-command confinement would
|
||||
add failure modes without adding safety. The workspace is where work happens,
|
||||
the default for relative paths and the command's directory, not a wall.
|
||||
|
||||
What *is* enforced here is operational: commands cannot hang the loop, cannot
|
||||
consume the context window with megabytes of build log, and do not inherit
|
||||
the agent's own credentials.
|
||||
What *is* enforced here is operational: commands cannot hang the loop and
|
||||
cannot flood the context window with megabytes of build log. The agent's own
|
||||
credentials are never on the build VM to inherit.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import base64
|
||||
import io
|
||||
import shlex
|
||||
import tarfile
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.context import head_and_tail
|
||||
from nisse_dev.execution import HostUnavailable, Result, SSHHost
|
||||
|
||||
# Tools that change or verify something. Reading a file or listing a
|
||||
# directory is a model looking around, not doing the work, so neither counts
|
||||
@@ -88,12 +89,6 @@ EXECUTOR_TOOLS: list[dict[str, Any]] = [
|
||||
]
|
||||
|
||||
|
||||
def _child_env() -> dict[str, str]:
|
||||
"""The project's build and test code is arbitrary; it has no business
|
||||
reading the agent's LiteLLM, Matrix, or Gitea credentials."""
|
||||
return {k: v for k, v in os.environ.items() if not k.startswith("NISSE_")}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolOutcome:
|
||||
result: str
|
||||
@@ -101,30 +96,53 @@ class ToolOutcome:
|
||||
|
||||
|
||||
class Workspace:
|
||||
"""The work item's workspace: a directory on its tool VM (L13, D3).
|
||||
|
||||
Every operation runs there over SSH; nothing touches the agent's own
|
||||
host. ``root`` is the path on that host.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
host: SSHHost,
|
||||
root: Path | str,
|
||||
*,
|
||||
timeout_s: int = DEFAULT_TIMEOUT_S,
|
||||
max_output_chars: int = MAX_OUTPUT_CHARS,
|
||||
secrets: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self._timeout_s = timeout_s
|
||||
self._max_output_chars = max_output_chars
|
||||
# The project secrets the model's commands may see (D7); redacted from
|
||||
# whatever comes back.
|
||||
self._secrets = dict(secrets or {})
|
||||
self._run(f"mkdir -p -- {shlex.quote(str(self.root))}")
|
||||
|
||||
def _run(
|
||||
self, command: str, *, stdin: str | None = None, cwd: str | None = None
|
||||
) -> Result:
|
||||
return self.host.execute(
|
||||
command, cwd=cwd, stdin=stdin, timeout_s=self._timeout_s
|
||||
)
|
||||
|
||||
def _truncate(self, text: str) -> str:
|
||||
return head_and_tail(text, self._max_output_chars)
|
||||
|
||||
def _resolve(self, path: str) -> Path:
|
||||
def _resolve(self, path: str) -> str:
|
||||
candidate = Path(path)
|
||||
return candidate if candidate.is_absolute() else self.root / candidate
|
||||
return str(candidate if candidate.is_absolute() else self.root / candidate)
|
||||
|
||||
def _inside(self, target: Path) -> bool:
|
||||
try:
|
||||
return target.resolve().is_relative_to(self.root.resolve())
|
||||
except OSError:
|
||||
def _inside(self, target: str) -> bool:
|
||||
result = self._run(
|
||||
f"realpath -m -- {shlex.quote(target)} {shlex.quote(str(self.root))}"
|
||||
)
|
||||
lines = result.stdout.splitlines()
|
||||
if not result.ok or len(lines) != 2:
|
||||
return False
|
||||
resolved, root = (Path(line) for line in lines)
|
||||
return resolved == root or root in resolved.parents
|
||||
|
||||
def dispatch(self, tool: str, arguments: dict[str, Any]) -> ToolOutcome:
|
||||
try:
|
||||
@@ -143,13 +161,24 @@ class Workspace:
|
||||
return ToolOutcome(f"no such tool: {tool}", success=False)
|
||||
except KeyError as exc:
|
||||
return ToolOutcome(f"missing argument {exc} for {tool}", success=False)
|
||||
except HostUnavailable as exc:
|
||||
# Not the work failing: the build VM is out of reach.
|
||||
return ToolOutcome(f"the build VM is unreachable: {exc}", success=False)
|
||||
|
||||
def read_file(self, path: str) -> ToolOutcome:
|
||||
target = self._resolve(path)
|
||||
# base64 on the wire, so the bytes arrive intact and decoding is
|
||||
# strict here, as it was for a local file.
|
||||
result = self._run(f"base64 -w0 -- {shlex.quote(target)}")
|
||||
if not result.ok:
|
||||
return ToolOutcome(
|
||||
f"could not read {path}: {result.stderr.strip()}", success=False
|
||||
)
|
||||
try:
|
||||
return ToolOutcome(self._truncate(target.read_text()), success=True)
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
text = base64.b64decode(result.stdout).decode()
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
return ToolOutcome(f"could not read {path}: {exc}", success=False)
|
||||
return ToolOutcome(self._truncate(text), success=True)
|
||||
|
||||
def write_file(self, path: str, content: str) -> ToolOutcome:
|
||||
target = self._resolve(path)
|
||||
@@ -165,21 +194,25 @@ class Workspace:
|
||||
"relative paths.",
|
||||
success=False,
|
||||
)
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content)
|
||||
except OSError as exc:
|
||||
return ToolOutcome(f"could not write {path}: {exc}", success=False)
|
||||
quoted = shlex.quote(target)
|
||||
result = self._run(
|
||||
f'mkdir -p -- "$(dirname -- {quoted})" && base64 -d > {quoted}',
|
||||
stdin=base64.b64encode(content.encode()).decode(),
|
||||
)
|
||||
if not result.ok:
|
||||
return ToolOutcome(
|
||||
f"could not write {path}: {result.stderr.strip()}", success=False
|
||||
)
|
||||
return ToolOutcome(f"wrote {len(content)} characters to {path}", success=True)
|
||||
|
||||
def list_dir(self, path: str) -> ToolOutcome:
|
||||
target = self._resolve(path)
|
||||
try:
|
||||
entries = sorted(
|
||||
f"{p.name}/" if p.is_dir() else p.name for p in target.iterdir()
|
||||
result = self._run(f"ls -1Ap -- {shlex.quote(target)}")
|
||||
if not result.ok:
|
||||
return ToolOutcome(
|
||||
f"could not list {path}: {result.stderr.strip()}", success=False
|
||||
)
|
||||
except OSError as exc:
|
||||
return ToolOutcome(f"could not list {path}: {exc}", success=False)
|
||||
entries = sorted(line for line in result.stdout.splitlines() if line)
|
||||
return ToolOutcome(
|
||||
self._truncate("\n".join(entries) or "(empty)"), success=True
|
||||
)
|
||||
@@ -187,56 +220,48 @@ class Workspace:
|
||||
def describe(self) -> str:
|
||||
"""Told to the model, so it does not have to guess where it is."""
|
||||
return (
|
||||
f"Your workspace is {self.root}. It is a git repository and the "
|
||||
"only place your work is recorded; commands run there by default. "
|
||||
"Use relative paths."
|
||||
f"Your workspace is {self.root} on the build VM. It is a git "
|
||||
"repository and the only place your work is recorded; commands run "
|
||||
"there by default. Use relative paths."
|
||||
)
|
||||
|
||||
def run_command(self, command: str) -> ToolOutcome:
|
||||
try:
|
||||
# Popen rather than run(): on timeout, run() kills only the direct
|
||||
# child, and TimeoutExpired carries no pid, so a backgrounded
|
||||
# grandchild (a dev server, say) would survive. Holding the pid
|
||||
# lets us kill the whole session.
|
||||
process = subprocess.Popen(
|
||||
result = self.host.execute(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=self.root,
|
||||
env=_child_env(),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
cwd=str(self.root),
|
||||
secrets=self._secrets,
|
||||
timeout_s=self._timeout_s,
|
||||
)
|
||||
except OSError as exc:
|
||||
return ToolOutcome(f"could not run command: {exc}", success=False)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=self._timeout_s)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._kill_group(process)
|
||||
except HostUnavailable as exc:
|
||||
return ToolOutcome(f"the build VM is unreachable: {exc}", success=False)
|
||||
if result.timed_out:
|
||||
return ToolOutcome(
|
||||
f"command timed out after {self._timeout_s}s: {command}",
|
||||
success=False,
|
||||
)
|
||||
body = self._truncate(
|
||||
f"exit code: {process.returncode}\nstdout:\n{stdout}\nstderr:\n{stderr}"
|
||||
f"exit code: {result.exit_code}\nstdout:\n{result.stdout}"
|
||||
f"\nstderr:\n{result.stderr}"
|
||||
)
|
||||
return ToolOutcome(body, success=process.returncode == 0)
|
||||
return ToolOutcome(body, success=result.exit_code == 0)
|
||||
|
||||
@staticmethod
|
||||
def _kill_group(process: "subprocess.Popen[str]") -> None:
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
process.kill()
|
||||
# Drain the pipes of the now-dead group so no zombie is left behind.
|
||||
try:
|
||||
process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
def upload_tree(self, local: Path, dest: str = ".") -> None:
|
||||
"""Copy a local directory's contents into the workspace (the bench
|
||||
seeding a task); raises RuntimeError if it fails."""
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
||||
for entry in sorted(Path(local).iterdir()):
|
||||
archive.add(entry, arcname=entry.name)
|
||||
target = shlex.quote(self._resolve(dest))
|
||||
result = self._run(
|
||||
f"mkdir -p -- {target} && base64 -d | tar -xzf - -C {target}",
|
||||
stdin=base64.b64encode(buffer.getvalue()).decode(),
|
||||
)
|
||||
if not result.ok:
|
||||
raise RuntimeError(f"upload to {dest} failed: {result.stderr.strip()}")
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Discard workspace contents; used by tests, not by the agent."""
|
||||
shutil.rmtree(self.root, ignore_errors=True)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
root = shlex.quote(str(self.root))
|
||||
self._run(f"rm -rf -- {root} && mkdir -p -- {root}")
|
||||
@@ -3,6 +3,7 @@ delegation harness and the comment poller — were fully built, fully tested,
|
||||
and reachable from nothing but their own tests. Unit tests that construct a
|
||||
component directly cannot catch that; only assembly can."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -13,7 +14,7 @@ from test_config import SECRETS, tree
|
||||
from nisse_dev.agent import build_agent, discover_models, prepare_workspace
|
||||
from nisse_dev.catalog import ModelCatalog, UnknownModel
|
||||
from nisse_dev.config import AgentConfig
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.oplog import OpLog
|
||||
|
||||
# What the test roles resolve to, as LiteLLM would report them.
|
||||
@@ -36,47 +37,77 @@ TEST_CATALOG = ModelCatalog.from_payload(
|
||||
)
|
||||
|
||||
|
||||
# The build VM for every test here: the session's throwaway sshd.
|
||||
HOST: SSHHost
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _build_host(ssh_host: SSHHost) -> None:
|
||||
global HOST
|
||||
HOST = ssh_host
|
||||
|
||||
|
||||
def config_for(tmp_path: Path, **changes: str) -> AgentConfig:
|
||||
return AgentConfig.from_tree(
|
||||
tree(
|
||||
state_db=str(tmp_path / "state.db"),
|
||||
workspace=str(tmp_path / "work"),
|
||||
build_host__address=HOST.address,
|
||||
build_host__user=HOST.user,
|
||||
build_host__port=str(HOST.port),
|
||||
build_host__key=str(HOST._key),
|
||||
**changes,
|
||||
),
|
||||
SECRETS,
|
||||
)
|
||||
|
||||
|
||||
def git(*args: str) -> None:
|
||||
subprocess.run(["git", *args], check=True, capture_output=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def origin(tmp_path: Path) -> Path:
|
||||
"""A bare repo standing in for the project on the forge."""
|
||||
remote = tmp_path / "origin.git"
|
||||
GitRepo(tmp_path)._run("init", "-q", "--bare", "-b", "main", str(remote))
|
||||
seed = GitRepo(tmp_path / "seed")
|
||||
seed.init()
|
||||
seed.configure_identity("seed", "seed@test")
|
||||
(seed.root / "README.md").write_text("project\n")
|
||||
seed.commit_all("seed")
|
||||
seed.push(str(remote), "main")
|
||||
git("init", "-q", "--bare", "-b", "main", str(remote))
|
||||
seed = tmp_path / "seed"
|
||||
git("init", "-q", "-b", "main", str(seed))
|
||||
(seed / "README.md").write_text("project\n")
|
||||
git("-C", str(seed), "add", "-A")
|
||||
git(
|
||||
"-C",
|
||||
str(seed),
|
||||
"-c",
|
||||
"user.name=s",
|
||||
"-c",
|
||||
"user.email=s@t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"s",
|
||||
)
|
||||
git("-C", str(seed), "push", "-q", str(remote), "HEAD:refs/heads/main")
|
||||
return remote
|
||||
|
||||
|
||||
def test_workspace_is_cloned_when_absent(tmp_path: Path, origin: Path) -> None:
|
||||
config = config_for(tmp_path, gitea__url=f"file://{origin}")
|
||||
# authenticated_url would mangle a file:// remote, so clone directly.
|
||||
GitRepo(tmp_path).clone(str(origin), config.workspace)
|
||||
repo = prepare_workspace(config, "nisse-dev-testing")
|
||||
def test_workspace_is_cloned_on_the_build_host_when_absent(
|
||||
tmp_path: Path, origin: Path
|
||||
) -> None:
|
||||
config = config_for(tmp_path)
|
||||
repo = prepare_workspace(HOST, config, "nisse-dev-testing", str(origin), None)
|
||||
|
||||
assert (config.workspace / "README.md").exists()
|
||||
assert repo.current_branch() == "main"
|
||||
# The clone's configuration holds the plain URL it was given (#89).
|
||||
assert str(origin) in (config.workspace / ".git" / "config").read_text()
|
||||
|
||||
|
||||
def test_workspace_is_reused_when_already_cloned(tmp_path: Path, origin: Path) -> None:
|
||||
config = config_for(tmp_path)
|
||||
GitRepo(tmp_path).clone(str(origin), config.workspace)
|
||||
prepare_workspace(HOST, config, "nisse-dev-testing", str(origin), None)
|
||||
(config.workspace / "local-work.txt").write_text("in progress")
|
||||
|
||||
prepare_workspace(config, "nisse-dev-testing")
|
||||
prepare_workspace(HOST, config, "nisse-dev-testing", str(origin), None)
|
||||
|
||||
# Re-cloning would silently destroy uncommitted work.
|
||||
assert (config.workspace / "local-work.txt").exists()
|
||||
@@ -110,7 +141,6 @@ class FakeTransport:
|
||||
def build_offline(tmp_path: Path, origin: Path, monkeypatch: Any) -> Any:
|
||||
"""Assemble the real graph with only the network boundaries faked."""
|
||||
config = config_for(tmp_path)
|
||||
GitRepo(tmp_path).clone(str(origin), config.workspace)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nisse_dev.agent.GiteaClient.whoami", lambda self: "nisse-dev-testing"
|
||||
@@ -121,8 +151,7 @@ def build_offline(tmp_path: Path, origin: Path, monkeypatch: Any) -> Any:
|
||||
)
|
||||
monkeypatch.setattr("nisse_dev.agent.MatrixTransport", lambda cfg: FakeTransport())
|
||||
monkeypatch.setattr(
|
||||
"nisse_dev.agent.authenticated_url",
|
||||
lambda base, repo, user, token: str(origin),
|
||||
"nisse_dev.agent.GiteaClient.clone_url", lambda self, repo: str(origin)
|
||||
)
|
||||
return build_agent(config)
|
||||
|
||||
@@ -317,7 +346,6 @@ def test_tuning_reaches_every_component(
|
||||
tools=ToolsTuning(command_timeout_s=300, max_output_chars=2000),
|
||||
),
|
||||
)
|
||||
GitRepo(tmp_path).clone(str(origin), config.workspace)
|
||||
monkeypatch.setattr(
|
||||
"nisse_dev.agent.GiteaClient.whoami", lambda self: "nisse-dev-testing"
|
||||
)
|
||||
@@ -327,8 +355,7 @@ def test_tuning_reaches_every_component(
|
||||
)
|
||||
monkeypatch.setattr("nisse_dev.agent.MatrixTransport", lambda cfg: FakeTransport())
|
||||
monkeypatch.setattr(
|
||||
"nisse_dev.agent.authenticated_url",
|
||||
lambda base, repo, user, token: str(origin),
|
||||
"nisse_dev.agent.GiteaClient.clone_url", lambda self, repo: str(origin)
|
||||
)
|
||||
agent = build_agent(config)
|
||||
executor = agent.orchestrator._executor
|
||||
|
||||
@@ -20,6 +20,17 @@ from nisse_dev.bench.tasks import CHECK_DIR, load_tasks
|
||||
from nisse_dev.canonical import AssistantText, ToolCall
|
||||
from nisse_dev.catalog import ModelFacts
|
||||
from nisse_dev.config import ExecutorTuning, Tuning
|
||||
from nisse_dev.execution import SSHHost
|
||||
|
||||
# The build VM for every test here: the session's throwaway sshd.
|
||||
HOST: SSHHost
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _build_host(ssh_host: SSHHost) -> None:
|
||||
global HOST
|
||||
HOST = ssh_host
|
||||
|
||||
|
||||
TASKS = load_tasks()
|
||||
FACTS = ModelFacts(
|
||||
@@ -187,7 +198,9 @@ SOLVE_SLUGIFY = [
|
||||
|
||||
def test_a_solved_task_is_a_success(tmp_path: Path) -> None:
|
||||
[task] = load_tasks(["write-function"])
|
||||
record = run_task(task, FACTS, Scripted(list(SOLVE_SLUGIFY)), Tuning(), note="n")
|
||||
record = run_task(
|
||||
task, FACTS, Scripted(list(SOLVE_SLUGIFY)), Tuning(), host=HOST, note="n"
|
||||
)
|
||||
|
||||
assert record.success
|
||||
assert record.outcome == "completed"
|
||||
@@ -212,7 +225,7 @@ def test_saying_finished_is_not_success(tmp_path: Path) -> None:
|
||||
],
|
||||
[call("finish", "f", summary="done")],
|
||||
]
|
||||
record = run_task(task, FACTS, Scripted(wrong), Tuning())
|
||||
record = run_task(task, FACTS, Scripted(wrong), Tuning(), host=HOST)
|
||||
assert record.outcome == "completed"
|
||||
assert not record.success
|
||||
|
||||
@@ -227,7 +240,7 @@ def test_misbehaviour_is_counted(tmp_path: Path) -> None:
|
||||
# A higher failure limit, so the invented tool and the failed command
|
||||
# don't escalate before the prose turn is reached.
|
||||
tuning = Tuning(executor=ExecutorTuning(failure_limit=5))
|
||||
record = run_task(task, FACTS, Scripted(turns), tuning)
|
||||
record = run_task(task, FACTS, Scripted(turns), tuning, host=HOST)
|
||||
assert record.invented_tools == 1
|
||||
assert record.failed_commands == 1
|
||||
assert record.prose_turns >= 1
|
||||
@@ -237,7 +250,7 @@ def test_misbehaviour_is_counted(tmp_path: Path) -> None:
|
||||
def test_a_fallback_answer_can_never_be_a_success(tmp_path: Path) -> None:
|
||||
[task] = load_tasks(["write-function"])
|
||||
client = Scripted(list(SOLVE_SLUGIFY), answered="claude-sonnet-5")
|
||||
record = run_task(task, FACTS, client, Tuning())
|
||||
record = run_task(task, FACTS, client, Tuning(), host=HOST)
|
||||
assert record.fallbacks == ["claude-sonnet-5"] * 3
|
||||
assert not record.success # the check passed, but not for this model
|
||||
|
||||
@@ -255,7 +268,9 @@ def test_wilson_intervals_behave() -> None:
|
||||
|
||||
def record_json(**overrides: Any) -> dict[str, Any]:
|
||||
[task] = load_tasks(["write-function"])
|
||||
base = run_task(task, FACTS, Scripted(list(SOLVE_SLUGIFY)), Tuning()).to_json()
|
||||
base = run_task(
|
||||
task, FACTS, Scripted(list(SOLVE_SLUGIFY)), Tuning(), host=HOST
|
||||
).to_json()
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
@@ -338,7 +353,7 @@ def test_a_run_records_its_rate_limit_waits(tmp_path: Path) -> None:
|
||||
[task] = load_tasks(["write-function"])
|
||||
client = Scripted(list(SOLVE_SLUGIFY))
|
||||
client.last_rate_limit_wait_s = 20.0 # every call waited once
|
||||
record = run_task(task, FACTS, client, Tuning())
|
||||
record = run_task(task, FACTS, client, Tuning(), host=HOST)
|
||||
assert record.rate_limit_wait_s == 60.0
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ COMPLETE: dict[str, Any] = {
|
||||
"repo": "don/demo",
|
||||
"operators": ["don"],
|
||||
},
|
||||
"build_host": {"address": "build.test", "key": "~/.ssh/nisse-dev"},
|
||||
}
|
||||
SECRETS = {
|
||||
"TEST_LLM_TOKEN": "sk-x",
|
||||
@@ -223,6 +224,9 @@ gitea:
|
||||
token: ${TEST_GITEA_TOKEN}
|
||||
repo: don/demo
|
||||
operators: [don]
|
||||
build_host:
|
||||
address: build.test
|
||||
key: ~/.ssh/nisse-dev
|
||||
"""
|
||||
|
||||
|
||||
@@ -328,3 +332,38 @@ def test_context_tuning_is_read_and_checked() -> None:
|
||||
assert build().tuning.context.keep_recent_turns == 6
|
||||
with pytest.raises(ConfigError, match="safety_margin: must be less than 1"):
|
||||
build(tuning={"context": {"safety_margin": 1}})
|
||||
|
||||
|
||||
def test_the_build_host_is_required_and_defaults_to_root_on_22() -> None:
|
||||
config = build()
|
||||
assert (config.build_host.address, config.build_host.user) == ("build.test", "root")
|
||||
assert config.build_host.port == 22
|
||||
assert config.build_host.key.is_absolute()
|
||||
with pytest.raises(ConfigError, match="build_host.address"):
|
||||
build(build_host__address=None)
|
||||
|
||||
|
||||
def test_a_bad_build_host_port_is_reported() -> None:
|
||||
with pytest.raises(ConfigError, match="build_host.port"):
|
||||
build(build_host__port="ssh")
|
||||
|
||||
|
||||
def test_the_push_key_is_optional_and_a_path() -> None:
|
||||
assert build().push_key is None
|
||||
assert build(gitea__push_key="~/k").push_key == Path("~/k").expanduser()
|
||||
|
||||
|
||||
def test_a_push_token_needs_its_user_and_excludes_a_key() -> None:
|
||||
config = build(gitea__push_user="pusher", gitea__push_token="${TEST_GITEA_TOKEN}")
|
||||
assert (config.push_user, config.push_token) == ("pusher", "gt")
|
||||
with pytest.raises(ConfigError, match="set both or neither"):
|
||||
build(gitea__push_user="pusher")
|
||||
with pytest.raises(ConfigError, match="not both"):
|
||||
build(gitea__push_user="p", gitea__push_token="t", gitea__push_key="~/k")
|
||||
|
||||
|
||||
def test_no_credential_appears_in_the_config_repr() -> None:
|
||||
config = build(gitea__push_user="pusher", gitea__push_token="${TEST_GITEA_TOKEN}")
|
||||
text = repr(config)
|
||||
for secret in SECRETS.values():
|
||||
assert secret not in text
|
||||
@@ -22,6 +22,7 @@ from nisse_dev.delegation import (
|
||||
DelegationResult,
|
||||
delegation_reconciler,
|
||||
)
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.llm import BudgetExhausted, LLMError, UpstreamUnavailable
|
||||
from nisse_dev.oplog import OpLog
|
||||
@@ -73,8 +74,8 @@ def store(tmp_path: Path) -> StateStore:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> GitRepo:
|
||||
r = GitRepo(tmp_path / "work")
|
||||
def repo(ssh_host: SSHHost, tmp_path: Path) -> GitRepo:
|
||||
r = GitRepo(ssh_host, tmp_path / "work")
|
||||
r.init()
|
||||
r.configure_identity("bot", "bot@example.test")
|
||||
(r.root / "seed.txt").write_text("seed")
|
||||
@@ -84,7 +85,7 @@ def repo(tmp_path: Path) -> GitRepo:
|
||||
|
||||
@pytest.fixture
|
||||
def ws(repo: GitRepo) -> Workspace:
|
||||
return Workspace(repo.root, timeout_s=10)
|
||||
return Workspace(repo.host, repo.root, timeout_s=10)
|
||||
|
||||
|
||||
ITEM = "t" # the work item these delegations run in
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Running commands on a tool VM over SSH (L13), against a throwaway sshd."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.execution import REDACTED, HostUnavailable, SSHHost, redact
|
||||
|
||||
|
||||
def test_exit_codes_and_streams_come_back(ssh_host: SSHHost) -> None:
|
||||
result = ssh_host.execute("echo out; echo err >&2; exit 3")
|
||||
assert (result.exit_code, result.stdout, result.stderr) == (3, "out\n", "err\n")
|
||||
assert not result.ok
|
||||
|
||||
|
||||
def test_cwd_and_stdin(ssh_host: SSHHost, tmp_path: Path) -> None:
|
||||
result = ssh_host.execute("pwd; cat", cwd=str(tmp_path), stdin="line one\nline two")
|
||||
assert result.stdout == f"{tmp_path}\nline one\nline two\n"
|
||||
|
||||
|
||||
def test_stdin_that_looks_like_a_delimiter_survives(ssh_host: SSHHost) -> None:
|
||||
payload = "NISSE_EOF\nEOF\n'quoted' $notexpanded `nor this`"
|
||||
assert ssh_host.execute("cat", stdin=payload).stdout == payload + "\n"
|
||||
|
||||
|
||||
def test_without_stdin_a_command_cannot_read_the_script(ssh_host: SSHHost) -> None:
|
||||
# The script arrives on the same channel; the command must see /dev/null.
|
||||
result = ssh_host.execute("cat; echo done")
|
||||
assert result.stdout == "done\n"
|
||||
|
||||
|
||||
def test_a_missing_cwd_fails_before_running(ssh_host: SSHHost, tmp_path: Path) -> None:
|
||||
marker = tmp_path / "ran"
|
||||
result = ssh_host.execute(f"touch {marker}", cwd=str(tmp_path / "absent"))
|
||||
assert result.exit_code == 125
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_a_command_exiting_255_is_not_mistaken_for_ssh(ssh_host: SSHHost) -> None:
|
||||
assert ssh_host.execute("exit 255").exit_code == 254
|
||||
|
||||
|
||||
def test_an_unreachable_host_raises(tmp_path: Path) -> None:
|
||||
host = SSHHost("127.0.0.1", key=tmp_path / "k", state_dir=tmp_path / "s", port=1)
|
||||
with pytest.raises(HostUnavailable):
|
||||
host.execute("true")
|
||||
|
||||
|
||||
def test_a_timeout_is_reported(ssh_host: SSHHost) -> None:
|
||||
result = ssh_host.execute("sleep 30", timeout_s=1)
|
||||
assert result.timed_out
|
||||
|
||||
|
||||
def test_secrets_reach_the_command_and_never_its_output(ssh_host: SSHHost) -> None:
|
||||
value = "s-" + os.urandom(12).hex()
|
||||
result = ssh_host.execute(
|
||||
'echo "$API_KEY"; echo "$API_KEY" >&2', secrets={"API_KEY": value}
|
||||
)
|
||||
assert result.ok
|
||||
assert value not in result.stdout + result.stderr
|
||||
assert result.stdout == f"{REDACTED}\n"
|
||||
|
||||
|
||||
def test_secrets_are_never_on_a_command_line(ssh_host: SSHHost, tmp_path: Path) -> None:
|
||||
# Made up here, so no command line in this test run can contain it except
|
||||
# by leaking it.
|
||||
value = "s-" + os.urandom(12).hex()
|
||||
seen = tmp_path / "ps.txt"
|
||||
ssh_host.execute(f"ps -eo args > {seen}", secrets={"API_KEY": value})
|
||||
assert "bash -s" in seen.read_text() # the listing did include our shell
|
||||
assert value not in seen.read_text()
|
||||
|
||||
|
||||
def test_redact_masks_longest_values_first() -> None:
|
||||
assert redact("abc-abcdef", ["abcdef", "abc-abcdef"]) == REDACTED
|
||||
assert redact("short ab stays", ["ab"]) == "short ab stays" # too short to mask
|
||||
@@ -11,6 +11,7 @@ from nisse_dev.canonical import (
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.executor import LLMExecutor
|
||||
from nisse_dev.llm import LLMError, RoleMap
|
||||
from nisse_dev.oplog import OpLog
|
||||
@@ -62,8 +63,8 @@ def store(tmp_path: Path) -> StateStore:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws(tmp_path: Path) -> Workspace:
|
||||
return Workspace(tmp_path / "work", timeout_s=10)
|
||||
def ws(ssh_host: SSHHost, tmp_path: Path) -> Workspace:
|
||||
return Workspace(ssh_host, tmp_path / "work", timeout_s=10)
|
||||
|
||||
|
||||
ITEM = "t" # the work item these tests run in
|
||||
|
||||
+209
-35
@@ -1,18 +1,28 @@
|
||||
"""Git on the build VM (D4, L13): here, a throwaway sshd on this machine."""
|
||||
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.git import GitError, GitRepo, _scrub, authenticated_url
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.git import GitError, GitRepo, HTTPSToken, SSHKey
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> GitRepo:
|
||||
r = GitRepo(tmp_path / "work")
|
||||
def repo(ssh_host: SSHHost, tmp_path: Path) -> GitRepo:
|
||||
r = GitRepo(ssh_host, tmp_path / "work")
|
||||
r.init()
|
||||
r.configure_identity("nisse-dev-testing", "nisse@example.test")
|
||||
return r
|
||||
|
||||
|
||||
def bare_remote(path: Path) -> str:
|
||||
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(path)], check=True)
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_init_creates_repo_on_default_branch(repo: GitRepo) -> None:
|
||||
assert repo.current_branch() == "main"
|
||||
|
||||
@@ -40,31 +50,8 @@ def test_has_changes_tracks_the_worktree(repo: GitRepo) -> None:
|
||||
assert repo.has_changes()
|
||||
|
||||
|
||||
def test_authenticated_url_shape() -> None:
|
||||
url = authenticated_url("https://gitea.test/", "o/r", "bot", "secret")
|
||||
assert url == "https://bot:secret@gitea.test/o/r.git"
|
||||
|
||||
|
||||
def test_push_failure_does_not_leak_the_token(repo: GitRepo) -> None:
|
||||
(repo.root / "a.txt").write_text("a")
|
||||
repo.commit_all("first")
|
||||
url = authenticated_url("https://127.0.0.1:1", "o/r", "bot", "sup3rsecret")
|
||||
with pytest.raises(GitError) as excinfo:
|
||||
repo.push(url, "main", token="sup3rsecret")
|
||||
# git redacts credentials from its own messages, but that is git's
|
||||
# behaviour, not ours; the scrub below is the guarantee we control.
|
||||
assert "sup3rsecret" not in str(excinfo.value)
|
||||
|
||||
|
||||
def test_scrub_removes_the_token_when_git_does_echo_it() -> None:
|
||||
assert _scrub("failed to push to https://bot:s3cret@h/o/r.git", "s3cret") == (
|
||||
"failed to push to https://bot:***@h/o/r.git"
|
||||
)
|
||||
assert _scrub("no secret here", None) == "no secret here"
|
||||
|
||||
|
||||
def test_git_error_on_bad_command(tmp_path: Path) -> None:
|
||||
repo = GitRepo(tmp_path / "not-a-repo")
|
||||
def test_git_error_on_bad_command(ssh_host: SSHHost, tmp_path: Path) -> None:
|
||||
repo = GitRepo(ssh_host, tmp_path / "not-a-repo")
|
||||
repo.root.mkdir()
|
||||
with pytest.raises(GitError):
|
||||
repo.head_sha()
|
||||
@@ -73,15 +60,202 @@ def test_git_error_on_bad_command(tmp_path: Path) -> None:
|
||||
def test_commits_since_and_remote_sha_against_a_local_remote(
|
||||
repo: GitRepo, tmp_path: Path
|
||||
) -> None:
|
||||
remote = GitRepo(tmp_path / "remote.git")
|
||||
remote.root.mkdir()
|
||||
remote._run("init", "-q", "--bare")
|
||||
remote = bare_remote(tmp_path / "remote.git")
|
||||
(repo.root / "a.txt").write_text("a")
|
||||
base = repo.commit_all("base")
|
||||
repo.push(str(remote.root), "main")
|
||||
repo._run("update-ref", "refs/remotes/origin/main", "HEAD")
|
||||
assert repo.remote_sha(str(remote.root), "main") == base
|
||||
assert repo.remote_sha(str(remote.root), "absent") is None
|
||||
repo.push(remote, "main")
|
||||
repo._git("update-ref", "refs/remotes/origin/main", "HEAD")
|
||||
assert repo.remote_sha(remote, "main") == base
|
||||
assert repo.remote_sha(remote, "absent") is None
|
||||
(repo.root / "b.txt").write_text("b")
|
||||
repo.commit_all("ahead")
|
||||
assert repo.commits_since("origin/main") == 1
|
||||
|
||||
|
||||
# --- the push key (#89) ------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ssh_remote(ssh_host: SSHHost, tmp_path: Path) -> tuple[str, SSHKey]:
|
||||
"""A bare repository reached by git over SSH, and the key that may push
|
||||
to it: the forge, as the build VM sees it."""
|
||||
remote = bare_remote(tmp_path / "forge.git")
|
||||
url = f"ssh://{ssh_host.user}@127.0.0.1:{ssh_host.port}{remote}"
|
||||
return url, SSHKey(ssh_host._key.read_text())
|
||||
|
||||
|
||||
def test_clone_and_push_with_the_key_store_no_credential(
|
||||
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
||||
) -> None:
|
||||
url, key = ssh_remote
|
||||
seed = GitRepo(ssh_host, tmp_path / "seed", known_hosts=str(tmp_path / "kh"))
|
||||
seed.init()
|
||||
seed.configure_identity("t", "t@example.test")
|
||||
(seed.root / "a.txt").write_text("a")
|
||||
seed.commit_all("first")
|
||||
seed.push(url, "main", credential=key)
|
||||
|
||||
clone = GitRepo(ssh_host, tmp_path / "clone", known_hosts=str(tmp_path / "kh"))
|
||||
clone.clone(url, credential=key)
|
||||
assert (clone.root / "a.txt").read_text() == "a"
|
||||
config = (clone.root / ".git" / "config").read_text()
|
||||
assert "PRIVATE KEY" not in config
|
||||
# The URL recorded is the credential-free one we gave.
|
||||
assert url in config
|
||||
|
||||
|
||||
def test_the_key_is_gone_when_the_command_ends(
|
||||
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
||||
) -> None:
|
||||
url, key = ssh_remote
|
||||
repo = GitRepo(ssh_host, tmp_path / "w", known_hosts=str(tmp_path / "kh"))
|
||||
repo.init()
|
||||
repo.configure_identity("t", "t@example.test")
|
||||
(repo.root / "a.txt").write_text("a")
|
||||
repo.commit_all("first")
|
||||
before = _agents()
|
||||
repo.push(url, "main", credential=key)
|
||||
assert _agents() == before # the throwaway ssh-agent was killed
|
||||
|
||||
|
||||
def test_a_failed_push_still_kills_its_agent(
|
||||
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
||||
) -> None:
|
||||
_, key = ssh_remote
|
||||
repo = GitRepo(ssh_host, tmp_path / "w", known_hosts=str(tmp_path / "kh"))
|
||||
repo.init()
|
||||
before = _agents()
|
||||
with pytest.raises(GitError):
|
||||
repo.push(
|
||||
f"ssh://nobody@127.0.0.1:{ssh_host.port}/nowhere.git",
|
||||
"main",
|
||||
credential=key,
|
||||
)
|
||||
assert _agents() == before
|
||||
|
||||
|
||||
def _agents() -> set[str]:
|
||||
"""Live ssh-agents. A killed one lingers as a zombie in a CI container,
|
||||
where nothing reaps orphans, so zombies don't count."""
|
||||
out = subprocess.run(
|
||||
["pgrep", "-x", "ssh-agent"], capture_output=True, text=True, check=False
|
||||
).stdout
|
||||
live = set()
|
||||
for pid in out.split():
|
||||
try:
|
||||
stat = Path(f"/proc/{pid}/stat").read_text()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if stat.rsplit(")", 1)[1].split()[0] != "Z":
|
||||
live.add(pid)
|
||||
return live
|
||||
|
||||
|
||||
def test_credentials_never_appear_in_a_repr() -> None:
|
||||
assert "s3cr3t" not in repr(HTTPSToken("pusher", "s3cr3t"))
|
||||
assert "PRIVATE" not in repr(SSHKey("-----BEGIN OPENSSH PRIVATE KEY-----"))
|
||||
|
||||
|
||||
def test_a_remote_url_is_quoted_not_interpreted(repo: GitRepo, tmp_path: Path) -> None:
|
||||
marker = tmp_path / "pwned"
|
||||
with pytest.raises(GitError):
|
||||
repo.push(f"x; touch {shlex.quote(str(marker))}", "main")
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
class RecordingHost:
|
||||
"""Captures what would be sent to the build VM."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def execute(self, command: str, **kwargs):
|
||||
from nisse_dev.execution import Result
|
||||
|
||||
self.calls.append({"command": command, **kwargs})
|
||||
return Result(0, "", "")
|
||||
|
||||
|
||||
def test_an_https_token_travels_only_as_a_secret(tmp_path: Path) -> None:
|
||||
host = RecordingHost()
|
||||
repo = GitRepo(host, tmp_path) # type: ignore[arg-type]
|
||||
token = "tok-" + "9f3a" * 6
|
||||
repo.push(
|
||||
"https://gitea.test/o/r.git", "m1", credential=HTTPSToken("pusher", token)
|
||||
)
|
||||
[call] = host.calls
|
||||
assert token not in call["command"] # never on a command line
|
||||
assert "pusher" not in call["command"]
|
||||
assert call["secrets"] == {"NISSE_PUSH_USER": "pusher", "NISSE_PUSH_TOKEN": token}
|
||||
# Git asks a helper that reads the environment, and any helper configured
|
||||
# on the VM is cleared first.
|
||||
assert "credential.helper= " in call["command"]
|
||||
assert "$NISSE_PUSH_TOKEN" in call["command"]
|
||||
assert "GIT_TERMINAL_PROMPT=0" in call["command"]
|
||||
|
||||
|
||||
def test_an_https_token_answers_git_through_the_helper(
|
||||
ssh_host: SSHHost, tmp_path: Path
|
||||
) -> None:
|
||||
# The helper as git runs it, on the build host: asked to "get", it
|
||||
# answers from the environment; asked anything else, nothing.
|
||||
from nisse_dev.git import _HELPER
|
||||
|
||||
body = _HELPER.removeprefix("!")
|
||||
asked = ssh_host.execute(
|
||||
f"{body} get",
|
||||
secrets={"NISSE_PUSH_USER": "pusher", "NISSE_PUSH_TOKEN": "tok-1234567"},
|
||||
)
|
||||
# Both travel as secrets, so both are masked in what comes back.
|
||||
assert asked.stdout == "username=[redacted]\npassword=[redacted]\n"
|
||||
stored = ssh_host.execute(f"{body} store", secrets={"NISSE_PUSH_TOKEN": "x"})
|
||||
assert stored.stdout == ""
|
||||
|
||||
|
||||
# --- build artifacts are never the work (#80, D4) ----------------------------
|
||||
|
||||
|
||||
def test_artifacts_are_left_out_even_when_the_project_tracks_them(
|
||||
repo: GitRepo,
|
||||
) -> None:
|
||||
# The sandbox's main tracks .pyc files (an earlier run published them).
|
||||
cache = repo.root / "pkg" / "__pycache__"
|
||||
cache.mkdir(parents=True)
|
||||
(repo.root / "pkg" / "m.py").write_text("x = 1\n")
|
||||
(cache / "m.cpython-312.pyc").write_bytes(b"old")
|
||||
repo._git("add", "-A")
|
||||
repo._git("commit", "-qm", "a project that tracks its caches")
|
||||
|
||||
# A test run: the tracked .pyc changes, new caches appear, and the work.
|
||||
(cache / "m.cpython-312.pyc").write_bytes(b"new")
|
||||
(cache / "m.cpython-310.pyc").write_bytes(b"x")
|
||||
(repo.root / ".pytest_cache").mkdir()
|
||||
(repo.root / ".pytest_cache" / "v").write_text("x")
|
||||
(repo.root / "pkg" / "m.py").write_text("x = 2\n")
|
||||
|
||||
assert repo.commit_all("the work") is not None
|
||||
committed = repo._git("show", "--name-only", "--format=", "HEAD").split()
|
||||
assert committed == ["pkg/m.py"]
|
||||
assert repo.left_out() == [
|
||||
".pytest_cache/v",
|
||||
"pkg/__pycache__/m.cpython-310.pyc",
|
||||
"pkg/__pycache__/m.cpython-312.pyc",
|
||||
]
|
||||
|
||||
|
||||
def test_only_artifacts_changed_means_nothing_to_commit(repo: GitRepo) -> None:
|
||||
(repo.root / "__pycache__").mkdir()
|
||||
(repo.root / "__pycache__" / "a.pyc").write_bytes(b"x")
|
||||
assert repo.commit_all("nothing real") is None
|
||||
|
||||
|
||||
def test_excludes_hide_new_artifacts_and_install_once(repo: GitRepo) -> None:
|
||||
repo.install_excludes()
|
||||
repo.install_excludes()
|
||||
exclude = (repo.root / ".git" / "info" / "exclude").read_text()
|
||||
assert exclude.count("__pycache__/") == 1
|
||||
(repo.root / "node_modules").mkdir()
|
||||
(repo.root / "node_modules" / "x.js").write_text("x")
|
||||
(repo.root / "real.py").write_text("x")
|
||||
# Even the model's own `git add -A` now skips them.
|
||||
assert repo._git("status", "--porcelain", "--untracked-files=all") == "?? real.py"
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -200,7 +199,6 @@ def test_intent_without_a_branch_is_unverifiable() -> None:
|
||||
|
||||
def test_closing_comments_first_then_closes_issue_and_pr() -> None:
|
||||
from nisse_dev.approvals import PlanBoard
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.publish import MilestonePublisher
|
||||
|
||||
seen: list[tuple[str, str, dict]] = []
|
||||
@@ -220,10 +218,8 @@ def test_closing_comments_first_then_closes_issue_and_pr() -> None:
|
||||
None, # type: ignore[arg-type] -- closing touches neither store nor git
|
||||
gitea,
|
||||
"o/r",
|
||||
GitRepo(Path(".")),
|
||||
base_url="https://gitea.test",
|
||||
account="bot",
|
||||
token="tok",
|
||||
None, # type: ignore[arg-type]
|
||||
remote_url="ssh://git@gitea.test/o/r.git",
|
||||
)
|
||||
publisher.close_pull_request(20, "abandoned by don")
|
||||
|
||||
|
||||
@@ -5,12 +5,22 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.git import GitRepo, HTTPSToken
|
||||
from nisse_dev.gitea import GiteaError, PullRequest
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.publish import OPEN_PR_INTENT, MilestonePublisher
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
# The build VM for every test here: the session's throwaway sshd.
|
||||
HOST: SSHHost
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _build_host(ssh_host: SSHHost) -> None:
|
||||
global HOST
|
||||
HOST = ssh_host
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self, *, fail: bool = False) -> None:
|
||||
@@ -46,7 +56,7 @@ class FakeGit:
|
||||
def __init__(
|
||||
self, root: Path, *, changes: bool = True, committed: bool = False
|
||||
) -> None:
|
||||
self.repo = GitRepo(root)
|
||||
self.repo = GitRepo(HOST, root)
|
||||
self.repo.init()
|
||||
self.repo.configure_identity("bot", "bot@example.test")
|
||||
(root / "README.md").write_text("sandbox")
|
||||
@@ -67,7 +77,7 @@ class FakeGit:
|
||||
return self.repo.commits_since(ref)
|
||||
|
||||
def remote_sha(
|
||||
self, remote_url: str, branch: str, *, token: str | None = None
|
||||
self, remote_url: str, branch: str, *, credential: object = None
|
||||
) -> str | None:
|
||||
return self.remote.get(branch)
|
||||
|
||||
@@ -80,7 +90,10 @@ class FakeGit:
|
||||
def commit_all(self, message: str) -> str | None:
|
||||
return self.repo.commit_all(message)
|
||||
|
||||
def push(self, remote_url: str, branch: str, *, token: str | None = None) -> None:
|
||||
def left_out(self) -> list[str]:
|
||||
return self.repo.left_out()
|
||||
|
||||
def push(self, remote_url: str, branch: str, *, credential: object = None) -> None:
|
||||
self.pushed.append(branch)
|
||||
self.remote[branch] = self.repo.head_sha()
|
||||
|
||||
@@ -100,9 +113,8 @@ def build(store: StateStore, git: Any, client: Any) -> MilestonePublisher:
|
||||
client,
|
||||
"o/sandbox",
|
||||
git,
|
||||
base_url="https://gitea.test",
|
||||
account="bot",
|
||||
token="tok",
|
||||
remote_url="ssh://git@gitea.test/o/sandbox.git",
|
||||
push_credential=HTTPSToken("pusher", "tok"),
|
||||
)
|
||||
|
||||
|
||||
@@ -115,14 +127,46 @@ def test_publish_commits_pushes_and_opens_a_pr(
|
||||
task_id="demo-abc", milestone=2, project="demo", plan="the plan"
|
||||
)
|
||||
|
||||
assert git.pushed == ["demo-abc/milestone-2"]
|
||||
assert client.opened[0]["head"] == "demo-abc/milestone-2"
|
||||
assert git.pushed == ["demo-abc-milestone-2"]
|
||||
assert client.opened[0]["head"] == "demo-abc-milestone-2"
|
||||
assert client.opened[0]["base"] == "main"
|
||||
assert "the plan" in client.opened[0]["body"]
|
||||
assert result.pull_request is not None
|
||||
assert "opened PR #1" in result.note
|
||||
|
||||
|
||||
def test_a_branch_named_after_the_task_does_not_block_publishing(
|
||||
store: StateStore, tmp_path: Path
|
||||
) -> None:
|
||||
# Live run 3: the executor ran `git checkout -b <task id>` itself. With
|
||||
# "<task>/milestone-1" as the harness's branch, git refused it, since a
|
||||
# branch name can't also be a directory of other branch names.
|
||||
git = FakeGit(tmp_path / "work")
|
||||
git.repo.checkout_new_branch("demo-abc")
|
||||
result = build(store, git, FakeGitea()).publish(
|
||||
task_id="demo-abc", milestone=1, project="demo", plan="p"
|
||||
)
|
||||
assert result.pull_request is not None
|
||||
assert git.pushed == ["demo-abc-milestone-1"]
|
||||
|
||||
|
||||
def test_left_out_artifacts_are_reported_not_published(
|
||||
store: StateStore, tmp_path: Path
|
||||
) -> None:
|
||||
git = FakeGit(tmp_path / "work")
|
||||
(tmp_path / "work" / "__pycache__").mkdir()
|
||||
(tmp_path / "work" / "__pycache__" / "work.cpython-310.pyc").write_bytes(b"x")
|
||||
client = FakeGitea()
|
||||
result = build(store, git, client).publish(
|
||||
task_id="t", milestone=1, project="demo", plan="p"
|
||||
)
|
||||
assert "left out 1 build artifact(s): __pycache__/work.cpython-310.pyc" in (
|
||||
result.note
|
||||
)
|
||||
assert "## Left out" in client.opened[0]["body"]
|
||||
assert "`__pycache__/work.cpython-310.pyc`" in client.opened[0]["body"]
|
||||
|
||||
|
||||
def test_intent_is_recorded_then_committed(store: StateStore, tmp_path: Path) -> None:
|
||||
build(store, FakeGit(tmp_path / "work"), FakeGitea()).publish(
|
||||
task_id="t", milestone=1, project="demo", plan="p"
|
||||
@@ -141,7 +185,7 @@ def test_failure_after_intent_leaves_it_pending_for_reconciliation(
|
||||
|
||||
[pending] = store.pending_intents()
|
||||
assert pending.kind == OPEN_PR_INTENT
|
||||
assert pending.payload["branch"] == "t/milestone-1"
|
||||
assert pending.payload["branch"] == "t-milestone-1"
|
||||
assert pending.payload["sha"]
|
||||
|
||||
|
||||
@@ -162,7 +206,7 @@ def test_nothing_to_commit_is_reported_not_raised(
|
||||
|
||||
def test_branch_is_namespaced_by_task(store: StateStore, tmp_path: Path) -> None:
|
||||
publisher = build(store, FakeGit(tmp_path / "work"), FakeGitea())
|
||||
assert publisher.branch_for("greet-9f2", 3) == "greet-9f2/milestone-3"
|
||||
assert publisher.branch_for("greet-9f2", 3) == "greet-9f2-milestone-3"
|
||||
|
||||
|
||||
def test_reconciler_settles_an_intent_left_by_a_crash(
|
||||
@@ -174,9 +218,9 @@ def test_reconciler_settles_an_intent_left_by_a_crash(
|
||||
client = FakeGitea()
|
||||
publisher = build(store, FakeGit(tmp_path / "work"), client)
|
||||
intent_id = store.begin_intent(
|
||||
OPEN_PR_INTENT, {"branch": "t/milestone-1"}, cause="crash simulation"
|
||||
OPEN_PR_INTENT, {"branch": "t-milestone-1"}, cause="crash simulation"
|
||||
)
|
||||
client.existing.append("t/milestone-1")
|
||||
client.existing.append("t-milestone-1")
|
||||
|
||||
reconcilers = publisher.reconcilers()
|
||||
assert set(reconcilers) == {OPEN_PR_INTENT}
|
||||
@@ -196,8 +240,8 @@ def test_work_the_executor_committed_itself_is_published(
|
||||
task_id="t", milestone=1, project="demo", plan="p"
|
||||
)
|
||||
assert result.pull_request is not None
|
||||
assert git.pushed == ["t/milestone-1"]
|
||||
assert git.remote["t/milestone-1"] == git.head_sha()
|
||||
assert git.pushed == ["t-milestone-1"]
|
||||
assert git.remote["t-milestone-1"] == git.head_sha()
|
||||
|
||||
|
||||
def test_a_followup_the_executor_committed_itself_is_pushed(
|
||||
@@ -210,7 +254,7 @@ def test_a_followup_the_executor_committed_itself_is_pushed(
|
||||
git.repo.commit_all("Address review")
|
||||
sha = publisher.push_followup(task_id="t", milestone=1, message="m")
|
||||
assert sha == git.head_sha()
|
||||
assert git.pushed == ["t/milestone-1", "t/milestone-1"]
|
||||
assert git.pushed == ["t-milestone-1", "t-milestone-1"]
|
||||
# Nothing new since that push: no second push, and it says so.
|
||||
assert publisher.push_followup(task_id="t", milestone=1, message="m") is None
|
||||
assert len(git.pushed) == 2
|
||||
@@ -230,15 +274,15 @@ def test_publishing_again_adopts_the_open_pr_instead_of_opening_another(
|
||||
assert len(client.opened) == 1
|
||||
assert result.pull_request is not None and result.pull_request.number == 1
|
||||
assert "already open" in result.note
|
||||
assert git.pushed == ["t/milestone-1", "t/milestone-1"] # updated it
|
||||
assert git.pushed == ["t-milestone-1", "t-milestone-1"] # updated it
|
||||
assert store.pending_intents() == []
|
||||
|
||||
|
||||
def test_a_closed_pr_is_not_adopted(store: StateStore, tmp_path: Path) -> None:
|
||||
git = FakeGit(tmp_path / "work")
|
||||
client = FakeGitea()
|
||||
client.existing.append("t/milestone-1")
|
||||
client.closed.add("t/milestone-1")
|
||||
client.existing.append("t-milestone-1")
|
||||
client.closed.add("t-milestone-1")
|
||||
publisher = build(store, git, client)
|
||||
assert publisher.published_pull_request("t", 1) is None
|
||||
publisher.publish(task_id="t", milestone=1, project="demo", plan="p")
|
||||
|
||||
@@ -121,7 +121,7 @@ class Publisher:
|
||||
return {"open_pr": lambda intent: intent.payload["branch"] in self._gitea.prs}
|
||||
|
||||
def publish(self, **kwargs: Any) -> PublishResult:
|
||||
branch = f"{kwargs['task_id']}/milestone-{kwargs['milestone']}"
|
||||
branch = f"{kwargs['task_id']}-milestone-{kwargs['milestone']}"
|
||||
self.attempts += 1
|
||||
intent = self._store.begin_intent("open_pr", {"branch": branch}, cause="t")
|
||||
if branch in self._gitea.prs:
|
||||
@@ -135,7 +135,7 @@ class Publisher:
|
||||
)
|
||||
|
||||
def published_pull_request(self, task_id: str, milestone: int) -> Any:
|
||||
branch = f"{task_id}/milestone-{milestone}"
|
||||
branch = f"{task_id}-milestone-{milestone}"
|
||||
if branch in self._gitea.prs:
|
||||
return PullRequest(number=20, state="open", head=branch, url="u")
|
||||
return None
|
||||
|
||||
@@ -4,12 +4,13 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.execution import REDACTED, SSHHost
|
||||
from nisse_dev.tools import EXECUTOR_TOOLS, MAX_OUTPUT_CHARS, Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws(tmp_path: Path) -> Workspace:
|
||||
return Workspace(tmp_path / "work", timeout_s=10)
|
||||
def ws(ssh_host: SSHHost, tmp_path: Path) -> Workspace:
|
||||
return Workspace(ssh_host, tmp_path / "work", timeout_s=10)
|
||||
|
||||
|
||||
def test_manifest_is_small_and_flat() -> None:
|
||||
@@ -71,8 +72,8 @@ def test_command_runs_in_the_workspace(ws: Workspace) -> None:
|
||||
assert "marker.txt" in ws.run_command("ls").result
|
||||
|
||||
|
||||
def test_command_timeout_is_reported(tmp_path: Path) -> None:
|
||||
ws = Workspace(tmp_path / "work", timeout_s=1)
|
||||
def test_command_timeout_is_reported(ssh_host: SSHHost, tmp_path: Path) -> None:
|
||||
ws = Workspace(ssh_host, tmp_path / "work", timeout_s=1)
|
||||
outcome = ws.run_command("sleep 30")
|
||||
assert not outcome.success
|
||||
assert "timed out" in outcome.result
|
||||
@@ -90,8 +91,10 @@ def _is_running(pid: int) -> bool:
|
||||
return stat.rsplit(")", 1)[1].split()[0] != "Z"
|
||||
|
||||
|
||||
def test_timeout_kills_the_whole_process_group(tmp_path: Path) -> None:
|
||||
ws = Workspace(tmp_path / "work", timeout_s=1)
|
||||
def test_timeout_kills_the_whole_process_group(
|
||||
ssh_host: SSHHost, tmp_path: Path
|
||||
) -> None:
|
||||
ws = Workspace(ssh_host, tmp_path / "work", timeout_s=1)
|
||||
pidfile = ws.root / "child.pid"
|
||||
# A backgrounded grandchild must not outlive the timeout.
|
||||
ws.run_command(f"(sleep 30 & echo $! > {pidfile}); sleep 30")
|
||||
@@ -156,3 +159,45 @@ def test_escaping_via_parent_traversal_is_refused(ws: Workspace) -> None:
|
||||
|
||||
def test_describe_names_the_root(ws: Workspace) -> None:
|
||||
assert str(ws.root) in ws.describe()
|
||||
|
||||
|
||||
# --- on the build VM (L13) -----------------------------------------------------
|
||||
|
||||
|
||||
def test_project_secrets_reach_commands_and_are_redacted(
|
||||
ssh_host: SSHHost, tmp_path: Path
|
||||
) -> None:
|
||||
value = "tok-" + os.urandom(8).hex()
|
||||
ws = Workspace(ssh_host, tmp_path / "work", secrets={"TEST_TOKEN": value})
|
||||
outcome = ws.run_command('test -n "$TEST_TOKEN" && echo "token=$TEST_TOKEN"')
|
||||
assert outcome.success # the command had it
|
||||
assert value not in outcome.result
|
||||
assert f"token={REDACTED}" in outcome.result
|
||||
|
||||
|
||||
def test_an_unreachable_build_vm_is_a_failed_tool_call(tmp_path: Path) -> None:
|
||||
ws = Workspace.__new__(Workspace) # no host to create the root on
|
||||
ws.host = SSHHost(
|
||||
"127.0.0.1", key=tmp_path / "no-key", state_dir=tmp_path / "s", port=1
|
||||
)
|
||||
ws.root = tmp_path / "work"
|
||||
ws._timeout_s, ws._max_output_chars, ws._secrets = 5, MAX_OUTPUT_CHARS, {}
|
||||
outcome = ws.dispatch("run_command", {"command": "true"})
|
||||
assert not outcome.success
|
||||
assert "unreachable" in outcome.result
|
||||
|
||||
|
||||
def test_upload_tree_copies_a_directory_in(ws: Workspace, tmp_path: Path) -> None:
|
||||
src = tmp_path / "seed"
|
||||
(src / "pkg").mkdir(parents=True)
|
||||
(src / "pkg" / "m.py").write_text("x = 1\n")
|
||||
ws.upload_tree(src)
|
||||
assert (ws.root / "pkg" / "m.py").read_text() == "x = 1\n"
|
||||
ws.upload_tree(src, "copy")
|
||||
assert (ws.root / "copy" / "pkg" / "m.py").exists()
|
||||
|
||||
|
||||
def test_non_text_files_read_as_a_failure(ws: Workspace) -> None:
|
||||
(ws.root / "blob.bin").write_bytes(b"\xff\xfe\x00")
|
||||
outcome = ws.read_file("blob.bin")
|
||||
assert not outcome.success
|
||||
@@ -14,7 +14,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from nisse_dev.agent import make_llm_clients
|
||||
from nisse_dev.agent import build_host, make_llm_clients
|
||||
from nisse_dev.bench.results import append, load, render, results_file, summarise
|
||||
from nisse_dev.bench.runner import agent_commit, run_task
|
||||
from nisse_dev.bench.tasks import load_tasks
|
||||
@@ -46,6 +46,7 @@ def run(args: argparse.Namespace) -> int:
|
||||
catalog = ModelCatalog.discover(config.llm_base_url, config.llm_token)
|
||||
catalog.require_limits({m: m for m in models})
|
||||
normalized, _passthrough = make_llm_clients(config, catalog)
|
||||
host = build_host(config) # runs happen on the build VM, as the agent's do
|
||||
commit = agent_commit(REPO)
|
||||
|
||||
for model in models:
|
||||
@@ -60,6 +61,7 @@ def run(args: argparse.Namespace) -> int:
|
||||
facts,
|
||||
normalized,
|
||||
config.tuning,
|
||||
host=host,
|
||||
note=args.note,
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
+24
-2
@@ -17,7 +17,8 @@
|
||||
# Layout, under $E2E (default /root/nisse-e2e):
|
||||
# nisse-dev.yaml, .env the run config and its secrets (made by hand once)
|
||||
# code/ the exported code under test, with its own .venv
|
||||
# state.db, project/ the agent's state and workspace
|
||||
# state.db the agent's state (its workspace is on the build VM)
|
||||
# ssh/ the agent's key for the build VM, and push keys
|
||||
# run-info, collected/ what was run, and the output of `collect`
|
||||
# runs/<time>/ earlier runs, set aside by `start`
|
||||
set -euo pipefail
|
||||
@@ -42,12 +43,15 @@ cmd_start() {
|
||||
for item in "$E2E"/state.db* "$E2E/project" "$E2E/collected" "$E2E/run-info"; do
|
||||
[ -e "$item" ] && items+=("$item")
|
||||
done
|
||||
local stamp
|
||||
stamp=$(date +%Y%m%d-%H%M%S)
|
||||
if [ ${#items[@]} -gt 0 ]; then
|
||||
aside=$E2E/runs/$(date +%Y%m%d-%H%M%S)
|
||||
aside=$E2E/runs/$stamp
|
||||
mkdir -p "$aside"
|
||||
mv "${items[@]}" "$aside/"
|
||||
echo "previous run set aside in $aside"
|
||||
fi
|
||||
remote_aside "$stamp"
|
||||
|
||||
# Export the code under test, so edits to the working tree can't change a
|
||||
# running agent. Uncommitted changes are not part of a run.
|
||||
@@ -67,6 +71,24 @@ cmd_start() {
|
||||
echo "started $UNIT at ${sha:0:8} ($ref)"
|
||||
}
|
||||
|
||||
# The workspace lives on the build VM (L13): set the last run's aside there
|
||||
# too, so each run starts from a fresh clone.
|
||||
remote_aside() {
|
||||
local stamp=$1 where
|
||||
where=$(python3 - "$E2E/nisse-dev.yaml" <<'PY'
|
||||
import sys, yaml
|
||||
c = yaml.safe_load(open(sys.argv[1]))
|
||||
b = c["build_host"]
|
||||
print(b.get("user", "root"), b["address"], b.get("port", 22), b["key"], c["workspace"])
|
||||
PY
|
||||
) || die "cannot read build_host and workspace from the config"
|
||||
read -r user address port key workspace <<< "$where"
|
||||
ssh -i "$key" -p "$port" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
|
||||
-o UserKnownHostsFile="$E2E/ssh/known_hosts" "$user@$address" \
|
||||
"if [ -e '$workspace' ]; then mv '$workspace' '$workspace.$stamp' && echo 'build VM: set aside $workspace.$stamp'; fi" \
|
||||
|| die "cannot reach the build VM $address"
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
running || { echo "not running"; return 0; }
|
||||
systemctl stop "$UNIT" # SIGTERM: the agent shuts down cleanly (main.py)
|
||||
|
||||
@@ -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
|
||||
@@ -38,3 +42,7 @@ gitea:
|
||||
token: ${NISSE_GITEA_TOKEN}
|
||||
repo: dongar/nisse-dev-testing-sandbox # the sandbox, never a real project
|
||||
operators: [dongar]
|
||||
# What pushes go by (D4): the sandbox's deploy key, whose private half CI
|
||||
# writes to this path from the NISSE_PUSH_KEY secret (locally: copy it
|
||||
# there). An HTTPS push token works too: push_user plus push_token.
|
||||
push_key: /tmp/nisse-test/push_key
|
||||
@@ -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(
|
||||
|
||||
@@ -10,8 +10,10 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.agent import push_credential, remote_url
|
||||
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
|
||||
|
||||
@@ -30,10 +32,8 @@ def intent_for(branch: str) -> Intent:
|
||||
|
||||
|
||||
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 +48,20 @@ 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), with the push credential the config names, never the API token.
|
||||
key = push_credential(live_config)
|
||||
assert key is not None, "nisse-test.yaml names no push credential (D4)"
|
||||
url = remote_url(client, repo_path, key)
|
||||
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, credential=key)
|
||||
pr = client.open_pull_request(
|
||||
repo_path,
|
||||
head=branch,
|
||||
@@ -75,8 +79,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 credential
|
||||
# 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 +89,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", credential=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, credential=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