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 -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,