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