Run every command on the build VM over SSH (M1.3)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 55s

execution.SSHHost.execute is now the only way the agent runs anything
(L13). Commands travel as a script on SSH's stdin, so the secrets handed
to a call are never on a command line, and every value handed out is
redacted from the output. Timeouts kill the remote process group, and
an unreachable host is reported as such, not as a failed command.

Workspace and GitRepo run on the build VM. The clone uses the
repository's SSH URL, so no credential is stored in it (#89); fetches
and pushes get the push key over stdin, in a short-lived ssh-agent,
and PRs are still opened from the agent's host with its API token.
The bench runs on the build host too.

Tests reach "the build VM" through a throwaway local sshd (nisse_dev
.testing); CI installs openssh-server. The live push test now pushes
with the sandbox's deploy key (NISSE_PUSH_KEY).

Fixes #89.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-02 21:39:46 +00:00
1 parent b7f1f3371c
commit 761ebdb857
32 files changed
+1098 -280

No files matched your search

+44 -17
View File
@@ -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
from nisse_dev.gitea import Comment, GiteaClient
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
from nisse_dev.matrix import MatrixTransport
@@ -164,19 +165,42 @@ 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 read_push_key(config: AgentConfig) -> str | None:
if config.push_key is None:
return None
try:
return config.push_key.read_text()
except OSError as exc:
raise RuntimeError(
f"cannot read the push key {config.push_key}: {exc}"
) from exc
def prepare_workspace(
host: SSHHost, config: AgentConfig, account: str, remote: str, key: str | 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 key 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, key=key)
repo.configure_identity(account, f"{account}@users.noreply.local")
return repo
@@ -204,9 +228,13 @@ def build_agent(
# anything depends on it.
account = gitea.whoami()
git_repo = prepare_workspace(config, account)
host = build_host(config)
push_key = read_push_key(config)
remote = gitea.ssh_url(config.gitea_repo)
git_repo = prepare_workspace(host, config, account, remote, push_key)
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 +277,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_key=push_key,
base_branch=config.base_branch,
)
+10 -3
View File
@@ -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()
+25
View File
@@ -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())
+44 -3
View File
@@ -47,8 +47,17 @@ 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",
"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,6 +214,17 @@ 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
@@ -219,7 +239,11 @@ class AgentConfig:
gitea_token: str
gitea_repo: str
gitea_operators: frozenset[str]
build_host: BuildHost
base_branch: str = "main"
# The key that may push to the project repository, handed to each push on
# the build VM and to nothing else (D4).
push_key: Path | None = None
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
tuning: Tuning = field(default_factory=Tuning)
@@ -300,9 +324,17 @@ 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")
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 +358,13 @@ 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,
base_branch=str(values.get("gitea.base_branch") or "main"),
max_output_tokens=max_output,
request_timeout_s=request_timeout,
@@ -337,7 +376,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 +408,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,
+207
View File
@@ -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,
)
+86 -55
View File
@@ -1,94 +1,135 @@
"""Git operations for the agent's workspace (architecture D4).
"""Git operations in the work item's workspace, on its build VM (D4, L13).
Push authentication uses the Gitea service-account token embedded in the
remote URL. The token is never written to disk in the repo config and is
scrubbed from anything this module raises or returns, so a failed push cannot
leak the credential into a transcript, a log line, or a PR comment.
Every command runs on the build VM over SSH. Remotes are plain URLs with no
credential in them, so nothing secret is ever written to the clone's
configuration (#89). Operations that reach the remote (clone, fetch, push,
ls-remote) are handed the push key for that one operation: it is fed over
stdin to a short-lived ``ssh-agent`` on the VM and gone when the command
ends. The model's commands never receive it.
Pull requests are opened from the agent's own host with its Gitea API token
(publish.py), which never reaches the VM.
"""
import subprocess
import shlex
from dataclasses import dataclass
from pathlib import Path
from nisse_dev.execution import HostUnavailable, SSHHost
TIMEOUT_S = 120
# ssh for git on the VM: no prompts, and trust Gitea's host key on first use.
_GIT_SSH = "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
class GitError(RuntimeError):
pass
def _with_key(command: str, known_hosts: str | None) -> str:
"""Run ``command`` with the key on stdin loaded into a throwaway agent."""
git_ssh = _GIT_SSH
if known_hosts is not None:
git_ssh += f" -o UserKnownHostsFile={shlex.quote(known_hosts)}"
return (
'eval "$(ssh-agent -s)" > /dev/null && '
"ssh-add -q - 2> /dev/null && "
f"GIT_SSH_COMMAND={shlex.quote(git_ssh)} {command}; "
"rc=$?; ssh-agent -k > /dev/null 2>&1; exit $rc"
)
@dataclass(frozen=True)
class GitRepo:
host: SSHHost
root: Path
# Where git's ssh on the VM records the forge's host key; the default is
# the VM user's own known_hosts.
known_hosts: str | None = None
def _run(self, *args: str, secret: str | None = None) -> str:
def _git(self, *args: str, key: str | None = None, cwd: str | None = None) -> str:
command = "git " + " ".join(shlex.quote(a) for a in args)
try:
completed = subprocess.run(
["git", *args],
cwd=self.root,
capture_output=True,
text=True,
timeout=TIMEOUT_S,
check=False,
result = self.host.execute(
_with_key(command, self.known_hosts) if key else command,
cwd=cwd or str(self.root),
stdin=key,
timeout_s=TIMEOUT_S,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise GitError(f"git {args[0]} failed: {_scrub(str(exc), secret)}") from exc
if completed.returncode != 0:
except HostUnavailable as exc:
raise GitError(f"git {args[0]} failed: {exc}") from exc
if result.timed_out:
raise GitError(f"git {args[0]} timed out after {TIMEOUT_S}s")
if result.exit_code != 0:
raise GitError(
f"git {args[0]} failed: "
f"{_scrub(completed.stderr.strip() or completed.stdout.strip(), secret)}"
f"{result.stderr.strip() or result.stdout.strip()}"
)
return completed.stdout.strip()
return result.stdout.strip()
# -- inspection -------------------------------------------------------
def current_branch(self) -> str:
# --show-current rather than rev-parse: it answers correctly on a
# freshly initialised repo, where HEAD points at an unborn branch.
return self._run("branch", "--show-current")
return self._git("branch", "--show-current")
def head_sha(self) -> str:
return self._run("rev-parse", "HEAD")
return self._git("rev-parse", "HEAD")
def has_changes(self) -> bool:
return bool(self._run("status", "--porcelain"))
return bool(self._git("status", "--porcelain"))
def commits_since(self, ref: str) -> int:
"""How many commits HEAD has that ref lacks. The executor may commit
its own work, so a clean tree does not mean nothing to publish."""
return int(self._run("rev-list", "--count", f"{ref}..HEAD"))
return int(self._git("rev-list", "--count", f"{ref}..HEAD"))
def remote_sha(
self, remote_url: str, branch: str, *, token: str | None = None
self, remote_url: str, branch: str, *, key: str | None = None
) -> str | None:
"""What the remote's branch points at, or None if it has no such
branch. Pushes go by URL, so there is no tracking ref to read."""
out = self._run("ls-remote", remote_url, f"refs/heads/{branch}", secret=token)
out = self._git("ls-remote", remote_url, f"refs/heads/{branch}", key=key)
return out.split()[0] if out else None
def exists(self) -> bool:
"""Whether the workspace is already a git repository."""
try:
result = self.host.execute(
f"test -d {shlex.quote(str(self.root / '.git'))}", timeout_s=TIMEOUT_S
)
except HostUnavailable as exc:
raise GitError(f"could not check the workspace: {exc}") from exc
return result.exit_code == 0
# -- mutation ---------------------------------------------------------
def init(self, *, default_branch: str = "main") -> None:
self.root.mkdir(parents=True, exist_ok=True)
self._run("init", "-q", "-b", default_branch)
self.host.execute(f"mkdir -p -- {shlex.quote(str(self.root))}")
self._git("init", "-q", "-b", default_branch)
def clone(self, remote_url: str, dest: Path, *, token: str | None = None) -> None:
self.root.mkdir(parents=True, exist_ok=True)
self._run("clone", "-q", remote_url, str(dest), secret=token)
def clone(self, remote_url: str, *, key: str | None = None) -> None:
"""Clone into the workspace root. The remote URL carries no
credential, so none is stored in the clone's configuration."""
parent = str(self.root.parent)
self.host.execute(f"mkdir -p -- {shlex.quote(parent)}")
self._git("clone", "-q", remote_url, str(self.root), key=key, cwd=parent)
def configure_identity(self, name: str, email: str) -> None:
self._run("config", "user.name", name)
self._run("config", "user.email", email)
self._git("config", "user.name", name)
self._git("config", "user.email", email)
def checkout_new_branch(self, name: str) -> None:
self._run("checkout", "-q", "-b", name)
self._git("checkout", "-q", "-b", name)
def checkout_existing_branch(self, name: str) -> None:
self._run("checkout", "-q", name)
self._git("checkout", "-q", name)
def branch_exists(self, name: str) -> bool:
try:
self._run("rev-parse", "--verify", "--quiet", f"refs/heads/{name}")
self._git("rev-parse", "--verify", "--quiet", f"refs/heads/{name}")
except GitError:
return False
return True
@@ -98,14 +139,14 @@ class GitRepo:
commit — an empty milestone is a fact to report, not an error."""
if not self.has_changes():
return None
self._run("add", "-A")
self._run("commit", "-q", "-m", message)
self._git("add", "-A")
self._git("commit", "-q", "-m", message)
return self.head_sha()
def push(self, remote_url: str, branch: str, *, token: str | None = None) -> None:
# The URL is passed per-invocation rather than stored as a remote, so
# the token never lands in .git/config.
self._run("push", "-q", remote_url, f"HEAD:refs/heads/{branch}", secret=token)
def push(self, remote_url: str, branch: str, *, key: str | None = None) -> None:
# By URL, per invocation, rather than a configured remote with a
# credential in it.
self._git("push", "-q", remote_url, f"HEAD:refs/heads/{branch}", key=key)
def discard_branch(self, branch: str, *, back_to: str) -> None:
"""Throw away a branch and everything on it.
@@ -114,20 +155,10 @@ class GitRepo:
atomicity that reconciliation gives an interrupted transition, and
half-finished work must not survive to be built upon.
"""
self._run("checkout", "-q", "--force", back_to)
self._run("branch", "-q", "-D", branch)
self._git("checkout", "-q", "--force", back_to)
self._git("branch", "-q", "-D", branch)
def delete_remote_branch(
self, remote_url: str, branch: str, *, token: str | None = None
self, remote_url: str, branch: str, *, key: str | None = None
) -> None:
self._run("push", "-q", remote_url, f":refs/heads/{branch}", secret=token)
def authenticated_url(base_url: str, repo: str, user: str, token: str) -> str:
host = base_url.split("://", 1)[-1].rstrip("/")
scheme = base_url.split("://", 1)[0] if "://" in base_url else "https"
return f"{scheme}://{user}:{token}@{host}/{repo}.git"
def _scrub(text: str, secret: str | None) -> str:
return text.replace(secret, "***") if secret else text
self._git("push", "-q", remote_url, f":refs/heads/{branch}", key=key)
+4
View File
@@ -70,6 +70,10 @@ 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 whoami(self) -> str:
return str(self._request("GET", "/user")["login"])
+10 -9
View File
@@ -9,7 +9,7 @@ an idempotent transition reconciled against external truth.
from dataclasses import dataclass
from nisse_dev.git import GitRepo, authenticated_url
from nisse_dev.git import GitRepo
from nisse_dev.gitea import (
GiteaClient,
GiteaError,
@@ -36,9 +36,8 @@ class MilestonePublisher:
repo: str,
git_repo: GitRepo,
*,
base_url: str,
account: str,
token: str,
remote_url: str,
push_key: str | None = None,
base_branch: str = "main",
) -> None:
self._store = store
@@ -46,8 +45,10 @@ 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 key is handed to each push
# on the build VM and nothing else (D4, L12).
self._remote = remote_url
self._key = push_key
def reconcilers(self) -> dict[str, Reconciler]:
return {OPEN_PR_INTENT: pull_request_reconciler(self._client, self._repo)}
@@ -84,7 +85,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, key=self._key)
# 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.
@@ -149,10 +150,10 @@ 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, key=self._key)
if sha == pushed:
return None
self._git.push(self._remote, branch, token=self._token)
self._git.push(self._remote, branch, key=self._key)
return sha
+101
View File
@@ -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()
+102 -77
View File
@@ -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}")