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