M7.1: extract nisse-core
What isn't the dev agent's moves to nisse-core, unchanged in behaviour, with its tests: canonical, render, render_anthropic, llm, catalog, probe, context, views, state, reconcile, worker, cancel, dump, transport, matrix, execution, testing, vault, memory, oplog, envfile and gitea (whole). - nisse_core.state.StateStore holds the tables every agent has; an agent's subclass adds its tables (version, migrations), roles and views. nisse_dev.state.StateStore adds the build VM blocks (v5). - views.project takes what each role is shown, rather than a fixed table. - Vault takes the agent's name (agent=), for provenance. - A test that nisse_core never imports an agent. - The loop, config, git, delegation, expert, tools and evals stay in nisse-dev until nisse-ops has a loop to share. Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on the agent host and Terraform; Architecture Part L names the package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
1 parent
4bbcb09c8e
commit
154a0c3fa8
119 files changed
+1086
-912
No files matched your search
@@ -0,0 +1,232 @@
|
||||
"""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."""
|
||||
|
||||
|
||||
class HostKeyChanged(HostUnavailable):
|
||||
"""The host answered with a different key than the one pinned: rebuilt,
|
||||
or not the host it claims to be. Only the operator may trust the new
|
||||
key (M4.1)."""
|
||||
|
||||
|
||||
@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 and (
|
||||
"IDENTIFICATION HAS CHANGED" in stderr
|
||||
or "Host key verification failed" in stderr
|
||||
):
|
||||
raise HostKeyChanged(
|
||||
f"{self.describe()}'s host key changed since it was pinned"
|
||||
)
|
||||
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 forget_host_key(self) -> None:
|
||||
"""Drop the pinned key, so the next connection pins the host's new
|
||||
one. Only on the operator's word (M4.1)."""
|
||||
self.close()
|
||||
name = self.address if self.port == 22 else f"[{self.address}]:{self.port}"
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-R", name, "-f", str(self._known_hosts)],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
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