Run every command on the build VM over SSH (M1.3)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 55s
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 55s
execution.SSHHost.execute is now the only way the agent runs anything (L13). Commands travel as a script on SSH's stdin, so the secrets handed to a call are never on a command line, and every value handed out is redacted from the output. Timeouts kill the remote process group, and an unreachable host is reported as such, not as a failed command. Workspace and GitRepo run on the build VM. The clone uses the repository's SSH URL, so no credential is stored in it (#89); fetches and pushes get the push key over stdin, in a short-lived ssh-agent, and PRs are still opened from the agent's host with its API token. The bench runs on the build host too. Tests reach "the build VM" through a throwaway local sshd (nisse_dev .testing); CI installs openssh-server. The live push test now pushes with the sandbox's deploy key (NISSE_PUSH_KEY). Fixes #89. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
b7f1f3371c
commit
761ebdb857
32 files changed
+1098
-280
No files matched your search
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
Reference in new issue
Block a user