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

+48 -21
View File
@@ -3,6 +3,7 @@ delegation harness and the comment poller — were fully built, fully tested,
and reachable from nothing but their own tests. Unit tests that construct a
component directly cannot catch that; only assembly can."""
import subprocess
from pathlib import Path
from typing import Any
@@ -13,7 +14,7 @@ from test_config import SECRETS, tree
from nisse_dev.agent import build_agent, discover_models, prepare_workspace
from nisse_dev.catalog import ModelCatalog, UnknownModel
from nisse_dev.config import AgentConfig
from nisse_dev.git import GitRepo
from nisse_dev.execution import SSHHost
from nisse_dev.oplog import OpLog
# What the test roles resolve to, as LiteLLM would report them.
@@ -36,47 +37,77 @@ TEST_CATALOG = ModelCatalog.from_payload(
)
# The build VM for every test here: the session's throwaway sshd.
HOST: SSHHost
@pytest.fixture(autouse=True)
def _build_host(ssh_host: SSHHost) -> None:
global HOST
HOST = ssh_host
def config_for(tmp_path: Path, **changes: str) -> AgentConfig:
return AgentConfig.from_tree(
tree(
state_db=str(tmp_path / "state.db"),
workspace=str(tmp_path / "work"),
build_host__address=HOST.address,
build_host__user=HOST.user,
build_host__port=str(HOST.port),
build_host__key=str(HOST._key),
**changes,
),
SECRETS,
)
def git(*args: str) -> None:
subprocess.run(["git", *args], check=True, capture_output=True)
@pytest.fixture
def origin(tmp_path: Path) -> Path:
"""A bare repo standing in for the project on the forge."""
remote = tmp_path / "origin.git"
GitRepo(tmp_path)._run("init", "-q", "--bare", "-b", "main", str(remote))
seed = GitRepo(tmp_path / "seed")
seed.init()
seed.configure_identity("seed", "seed@test")
(seed.root / "README.md").write_text("project\n")
seed.commit_all("seed")
seed.push(str(remote), "main")
git("init", "-q", "--bare", "-b", "main", str(remote))
seed = tmp_path / "seed"
git("init", "-q", "-b", "main", str(seed))
(seed / "README.md").write_text("project\n")
git("-C", str(seed), "add", "-A")
git(
"-C",
str(seed),
"-c",
"user.name=s",
"-c",
"user.email=s@t",
"commit",
"-qm",
"s",
)
git("-C", str(seed), "push", "-q", str(remote), "HEAD:refs/heads/main")
return remote
def test_workspace_is_cloned_when_absent(tmp_path: Path, origin: Path) -> None:
config = config_for(tmp_path, gitea__url=f"file://{origin}")
# authenticated_url would mangle a file:// remote, so clone directly.
GitRepo(tmp_path).clone(str(origin), config.workspace)
repo = prepare_workspace(config, "nisse-dev-testing")
def test_workspace_is_cloned_on_the_build_host_when_absent(
tmp_path: Path, origin: Path
) -> None:
config = config_for(tmp_path)
repo = prepare_workspace(HOST, config, "nisse-dev-testing", str(origin), None)
assert (config.workspace / "README.md").exists()
assert repo.current_branch() == "main"
# The clone's configuration holds the plain URL it was given (#89).
assert str(origin) in (config.workspace / ".git" / "config").read_text()
def test_workspace_is_reused_when_already_cloned(tmp_path: Path, origin: Path) -> None:
config = config_for(tmp_path)
GitRepo(tmp_path).clone(str(origin), config.workspace)
prepare_workspace(HOST, config, "nisse-dev-testing", str(origin), None)
(config.workspace / "local-work.txt").write_text("in progress")
prepare_workspace(config, "nisse-dev-testing")
prepare_workspace(HOST, config, "nisse-dev-testing", str(origin), None)
# Re-cloning would silently destroy uncommitted work.
assert (config.workspace / "local-work.txt").exists()
@@ -110,7 +141,6 @@ class FakeTransport:
def build_offline(tmp_path: Path, origin: Path, monkeypatch: Any) -> Any:
"""Assemble the real graph with only the network boundaries faked."""
config = config_for(tmp_path)
GitRepo(tmp_path).clone(str(origin), config.workspace)
monkeypatch.setattr(
"nisse_dev.agent.GiteaClient.whoami", lambda self: "nisse-dev-testing"
@@ -121,8 +151,7 @@ def build_offline(tmp_path: Path, origin: Path, monkeypatch: Any) -> Any:
)
monkeypatch.setattr("nisse_dev.agent.MatrixTransport", lambda cfg: FakeTransport())
monkeypatch.setattr(
"nisse_dev.agent.authenticated_url",
lambda base, repo, user, token: str(origin),
"nisse_dev.agent.GiteaClient.ssh_url", lambda self, repo: str(origin)
)
return build_agent(config)
@@ -317,7 +346,6 @@ def test_tuning_reaches_every_component(
tools=ToolsTuning(command_timeout_s=300, max_output_chars=2000),
),
)
GitRepo(tmp_path).clone(str(origin), config.workspace)
monkeypatch.setattr(
"nisse_dev.agent.GiteaClient.whoami", lambda self: "nisse-dev-testing"
)
@@ -327,8 +355,7 @@ def test_tuning_reaches_every_component(
)
monkeypatch.setattr("nisse_dev.agent.MatrixTransport", lambda cfg: FakeTransport())
monkeypatch.setattr(
"nisse_dev.agent.authenticated_url",
lambda base, repo, user, token: str(origin),
"nisse_dev.agent.GiteaClient.ssh_url", lambda self, repo: str(origin)
)
agent = build_agent(config)
executor = agent.orchestrator._executor
+21 -6
View File
@@ -20,6 +20,17 @@ from nisse_dev.bench.tasks import CHECK_DIR, load_tasks
from nisse_dev.canonical import AssistantText, ToolCall
from nisse_dev.catalog import ModelFacts
from nisse_dev.config import ExecutorTuning, Tuning
from nisse_dev.execution import SSHHost
# The build VM for every test here: the session's throwaway sshd.
HOST: SSHHost
@pytest.fixture(autouse=True)
def _build_host(ssh_host: SSHHost) -> None:
global HOST
HOST = ssh_host
TASKS = load_tasks()
FACTS = ModelFacts(
@@ -187,7 +198,9 @@ SOLVE_SLUGIFY = [
def test_a_solved_task_is_a_success(tmp_path: Path) -> None:
[task] = load_tasks(["write-function"])
record = run_task(task, FACTS, Scripted(list(SOLVE_SLUGIFY)), Tuning(), note="n")
record = run_task(
task, FACTS, Scripted(list(SOLVE_SLUGIFY)), Tuning(), host=HOST, note="n"
)
assert record.success
assert record.outcome == "completed"
@@ -212,7 +225,7 @@ def test_saying_finished_is_not_success(tmp_path: Path) -> None:
],
[call("finish", "f", summary="done")],
]
record = run_task(task, FACTS, Scripted(wrong), Tuning())
record = run_task(task, FACTS, Scripted(wrong), Tuning(), host=HOST)
assert record.outcome == "completed"
assert not record.success
@@ -227,7 +240,7 @@ def test_misbehaviour_is_counted(tmp_path: Path) -> None:
# A higher failure limit, so the invented tool and the failed command
# don't escalate before the prose turn is reached.
tuning = Tuning(executor=ExecutorTuning(failure_limit=5))
record = run_task(task, FACTS, Scripted(turns), tuning)
record = run_task(task, FACTS, Scripted(turns), tuning, host=HOST)
assert record.invented_tools == 1
assert record.failed_commands == 1
assert record.prose_turns >= 1
@@ -237,7 +250,7 @@ def test_misbehaviour_is_counted(tmp_path: Path) -> None:
def test_a_fallback_answer_can_never_be_a_success(tmp_path: Path) -> None:
[task] = load_tasks(["write-function"])
client = Scripted(list(SOLVE_SLUGIFY), answered="claude-sonnet-5")
record = run_task(task, FACTS, client, Tuning())
record = run_task(task, FACTS, client, Tuning(), host=HOST)
assert record.fallbacks == ["claude-sonnet-5"] * 3
assert not record.success # the check passed, but not for this model
@@ -255,7 +268,9 @@ def test_wilson_intervals_behave() -> None:
def record_json(**overrides: Any) -> dict[str, Any]:
[task] = load_tasks(["write-function"])
base = run_task(task, FACTS, Scripted(list(SOLVE_SLUGIFY)), Tuning()).to_json()
base = run_task(
task, FACTS, Scripted(list(SOLVE_SLUGIFY)), Tuning(), host=HOST
).to_json()
base.update(overrides)
return base
@@ -338,7 +353,7 @@ def test_a_run_records_its_rate_limit_waits(tmp_path: Path) -> None:
[task] = load_tasks(["write-function"])
client = Scripted(list(SOLVE_SLUGIFY))
client.last_rate_limit_wait_s = 20.0 # every call waited once
record = run_task(task, FACTS, client, Tuning())
record = run_task(task, FACTS, client, Tuning(), host=HOST)
assert record.rate_limit_wait_s == 60.0
+23
View File
@@ -32,6 +32,7 @@ COMPLETE: dict[str, Any] = {
"repo": "don/demo",
"operators": ["don"],
},
"build_host": {"address": "build.test", "key": "~/.ssh/nisse-dev"},
}
SECRETS = {
"TEST_LLM_TOKEN": "sk-x",
@@ -223,6 +224,9 @@ gitea:
token: ${TEST_GITEA_TOKEN}
repo: don/demo
operators: [don]
build_host:
address: build.test
key: ~/.ssh/nisse-dev
"""
@@ -328,3 +332,22 @@ def test_context_tuning_is_read_and_checked() -> None:
assert build().tuning.context.keep_recent_turns == 6
with pytest.raises(ConfigError, match="safety_margin: must be less than 1"):
build(tuning={"context": {"safety_margin": 1}})
def test_the_build_host_is_required_and_defaults_to_root_on_22() -> None:
config = build()
assert (config.build_host.address, config.build_host.user) == ("build.test", "root")
assert config.build_host.port == 22
assert config.build_host.key.is_absolute()
with pytest.raises(ConfigError, match="build_host.address"):
build(build_host__address=None)
def test_a_bad_build_host_port_is_reported() -> None:
with pytest.raises(ConfigError, match="build_host.port"):
build(build_host__port="ssh")
def test_the_push_key_is_optional_and_a_path() -> None:
assert build().push_key is None
assert build(gitea__push_key="~/k").push_key == Path("~/k").expanduser()
+4 -3
View File
@@ -22,6 +22,7 @@ from nisse_dev.delegation import (
DelegationResult,
delegation_reconciler,
)
from nisse_dev.execution import SSHHost
from nisse_dev.git import GitRepo
from nisse_dev.llm import BudgetExhausted, LLMError, UpstreamUnavailable
from nisse_dev.oplog import OpLog
@@ -73,8 +74,8 @@ def store(tmp_path: Path) -> StateStore:
@pytest.fixture
def repo(tmp_path: Path) -> GitRepo:
r = GitRepo(tmp_path / "work")
def repo(ssh_host: SSHHost, tmp_path: Path) -> GitRepo:
r = GitRepo(ssh_host, tmp_path / "work")
r.init()
r.configure_identity("bot", "bot@example.test")
(r.root / "seed.txt").write_text("seed")
@@ -84,7 +85,7 @@ def repo(tmp_path: Path) -> GitRepo:
@pytest.fixture
def ws(repo: GitRepo) -> Workspace:
return Workspace(repo.root, timeout_s=10)
return Workspace(repo.host, repo.root, timeout_s=10)
ITEM = "t" # the work item these delegations run in
+77
View File
@@ -0,0 +1,77 @@
"""Running commands on a tool VM over SSH (L13), against a throwaway sshd."""
import os
from pathlib import Path
import pytest
from nisse_dev.execution import REDACTED, HostUnavailable, SSHHost, redact
def test_exit_codes_and_streams_come_back(ssh_host: SSHHost) -> None:
result = ssh_host.execute("echo out; echo err >&2; exit 3")
assert (result.exit_code, result.stdout, result.stderr) == (3, "out\n", "err\n")
assert not result.ok
def test_cwd_and_stdin(ssh_host: SSHHost, tmp_path: Path) -> None:
result = ssh_host.execute("pwd; cat", cwd=str(tmp_path), stdin="line one\nline two")
assert result.stdout == f"{tmp_path}\nline one\nline two\n"
def test_stdin_that_looks_like_a_delimiter_survives(ssh_host: SSHHost) -> None:
payload = "NISSE_EOF\nEOF\n'quoted' $notexpanded `nor this`"
assert ssh_host.execute("cat", stdin=payload).stdout == payload + "\n"
def test_without_stdin_a_command_cannot_read_the_script(ssh_host: SSHHost) -> None:
# The script arrives on the same channel; the command must see /dev/null.
result = ssh_host.execute("cat; echo done")
assert result.stdout == "done\n"
def test_a_missing_cwd_fails_before_running(ssh_host: SSHHost, tmp_path: Path) -> None:
marker = tmp_path / "ran"
result = ssh_host.execute(f"touch {marker}", cwd=str(tmp_path / "absent"))
assert result.exit_code == 125
assert not marker.exists()
def test_a_command_exiting_255_is_not_mistaken_for_ssh(ssh_host: SSHHost) -> None:
assert ssh_host.execute("exit 255").exit_code == 254
def test_an_unreachable_host_raises(tmp_path: Path) -> None:
host = SSHHost("127.0.0.1", key=tmp_path / "k", state_dir=tmp_path / "s", port=1)
with pytest.raises(HostUnavailable):
host.execute("true")
def test_a_timeout_is_reported(ssh_host: SSHHost) -> None:
result = ssh_host.execute("sleep 30", timeout_s=1)
assert result.timed_out
def test_secrets_reach_the_command_and_never_its_output(ssh_host: SSHHost) -> None:
value = "s-" + os.urandom(12).hex()
result = ssh_host.execute(
'echo "$API_KEY"; echo "$API_KEY" >&2', secrets={"API_KEY": value}
)
assert result.ok
assert value not in result.stdout + result.stderr
assert result.stdout == f"{REDACTED}\n"
def test_secrets_are_never_on_a_command_line(ssh_host: SSHHost, tmp_path: Path) -> None:
# Made up here, so no command line in this test run can contain it except
# by leaking it.
value = "s-" + os.urandom(12).hex()
seen = tmp_path / "ps.txt"
ssh_host.execute(f"ps -eo args > {seen}", secrets={"API_KEY": value})
assert "bash -s" in seen.read_text() # the listing did include our shell
assert value not in seen.read_text()
def test_redact_masks_longest_values_first() -> None:
assert redact("abc-abcdef", ["abcdef", "abc-abcdef"]) == REDACTED
assert redact("short ab stays", ["ab"]) == "short ab stays" # too short to mask
+3 -2
View File
@@ -11,6 +11,7 @@ from nisse_dev.canonical import (
ToolCall,
ToolResult,
)
from nisse_dev.execution import SSHHost
from nisse_dev.executor import LLMExecutor
from nisse_dev.llm import LLMError, RoleMap
from nisse_dev.oplog import OpLog
@@ -62,8 +63,8 @@ def store(tmp_path: Path) -> StateStore:
@pytest.fixture
def ws(tmp_path: Path) -> Workspace:
return Workspace(tmp_path / "work", timeout_s=10)
def ws(ssh_host: SSHHost, tmp_path: Path) -> Workspace:
return Workspace(ssh_host, tmp_path / "work", timeout_s=10)
ITEM = "t" # the work item these tests run in
+94 -35
View File
@@ -1,18 +1,28 @@
"""Git on the build VM (D4, L13): here, a throwaway sshd on this machine."""
import shlex
import subprocess
from pathlib import Path
import pytest
from nisse_dev.git import GitError, GitRepo, _scrub, authenticated_url
from nisse_dev.execution import SSHHost
from nisse_dev.git import GitError, GitRepo
@pytest.fixture
def repo(tmp_path: Path) -> GitRepo:
r = GitRepo(tmp_path / "work")
def repo(ssh_host: SSHHost, tmp_path: Path) -> GitRepo:
r = GitRepo(ssh_host, tmp_path / "work")
r.init()
r.configure_identity("nisse-dev-testing", "nisse@example.test")
return r
def bare_remote(path: Path) -> str:
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(path)], check=True)
return str(path)
def test_init_creates_repo_on_default_branch(repo: GitRepo) -> None:
assert repo.current_branch() == "main"
@@ -40,31 +50,8 @@ def test_has_changes_tracks_the_worktree(repo: GitRepo) -> None:
assert repo.has_changes()
def test_authenticated_url_shape() -> None:
url = authenticated_url("https://gitea.test/", "o/r", "bot", "secret")
assert url == "https://bot:secret@gitea.test/o/r.git"
def test_push_failure_does_not_leak_the_token(repo: GitRepo) -> None:
(repo.root / "a.txt").write_text("a")
repo.commit_all("first")
url = authenticated_url("https://127.0.0.1:1", "o/r", "bot", "sup3rsecret")
with pytest.raises(GitError) as excinfo:
repo.push(url, "main", token="sup3rsecret")
# git redacts credentials from its own messages, but that is git's
# behaviour, not ours; the scrub below is the guarantee we control.
assert "sup3rsecret" not in str(excinfo.value)
def test_scrub_removes_the_token_when_git_does_echo_it() -> None:
assert _scrub("failed to push to https://bot:s3cret@h/o/r.git", "s3cret") == (
"failed to push to https://bot:***@h/o/r.git"
)
assert _scrub("no secret here", None) == "no secret here"
def test_git_error_on_bad_command(tmp_path: Path) -> None:
repo = GitRepo(tmp_path / "not-a-repo")
def test_git_error_on_bad_command(ssh_host: SSHHost, tmp_path: Path) -> None:
repo = GitRepo(ssh_host, tmp_path / "not-a-repo")
repo.root.mkdir()
with pytest.raises(GitError):
repo.head_sha()
@@ -73,15 +60,87 @@ def test_git_error_on_bad_command(tmp_path: Path) -> None:
def test_commits_since_and_remote_sha_against_a_local_remote(
repo: GitRepo, tmp_path: Path
) -> None:
remote = GitRepo(tmp_path / "remote.git")
remote.root.mkdir()
remote._run("init", "-q", "--bare")
remote = bare_remote(tmp_path / "remote.git")
(repo.root / "a.txt").write_text("a")
base = repo.commit_all("base")
repo.push(str(remote.root), "main")
repo._run("update-ref", "refs/remotes/origin/main", "HEAD")
assert repo.remote_sha(str(remote.root), "main") == base
assert repo.remote_sha(str(remote.root), "absent") is None
repo.push(remote, "main")
repo._git("update-ref", "refs/remotes/origin/main", "HEAD")
assert repo.remote_sha(remote, "main") == base
assert repo.remote_sha(remote, "absent") is None
(repo.root / "b.txt").write_text("b")
repo.commit_all("ahead")
assert repo.commits_since("origin/main") == 1
# --- the push key (#89) ------------------------------------------------------
@pytest.fixture
def ssh_remote(ssh_host: SSHHost, tmp_path: Path) -> tuple[str, str]:
"""A bare repository reached by git over SSH, and the key that may push
to it: the forge, as the build VM sees it."""
remote = bare_remote(tmp_path / "forge.git")
url = f"ssh://{ssh_host.user}@127.0.0.1:{ssh_host.port}{remote}"
return url, ssh_host._key.read_text()
def test_clone_and_push_with_the_key_store_no_credential(
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, str]
) -> None:
url, key = ssh_remote
seed = GitRepo(ssh_host, tmp_path / "seed", known_hosts=str(tmp_path / "kh"))
seed.init()
seed.configure_identity("t", "t@example.test")
(seed.root / "a.txt").write_text("a")
seed.commit_all("first")
seed.push(url, "main", key=key)
clone = GitRepo(ssh_host, tmp_path / "clone", known_hosts=str(tmp_path / "kh"))
clone.clone(url, key=key)
assert (clone.root / "a.txt").read_text() == "a"
config = (clone.root / ".git" / "config").read_text()
assert "PRIVATE KEY" not in config
# The URL recorded is the credential-free one we gave.
assert url in config
def test_the_key_is_gone_when_the_command_ends(
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, str]
) -> None:
url, key = ssh_remote
repo = GitRepo(ssh_host, tmp_path / "w", known_hosts=str(tmp_path / "kh"))
repo.init()
repo.configure_identity("t", "t@example.test")
(repo.root / "a.txt").write_text("a")
repo.commit_all("first")
before = _agents()
repo.push(url, "main", key=key)
assert _agents() == before # the throwaway ssh-agent was killed
def test_a_failed_push_still_kills_its_agent(
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, str]
) -> None:
_, key = ssh_remote
repo = GitRepo(ssh_host, tmp_path / "w", known_hosts=str(tmp_path / "kh"))
repo.init()
before = _agents()
with pytest.raises(GitError):
repo.push(
f"ssh://nobody@127.0.0.1:{ssh_host.port}/nowhere.git", "main", key=key
)
assert _agents() == before
def _agents() -> set[str]:
out = subprocess.run(
["pgrep", "-x", "ssh-agent"], capture_output=True, text=True, check=False
).stdout
return set(out.split())
def test_a_remote_url_is_quoted_not_interpreted(repo: GitRepo, tmp_path: Path) -> None:
marker = tmp_path / "pwned"
with pytest.raises(GitError):
repo.push(f"x; touch {shlex.quote(str(marker))}", "main")
assert not marker.exists()
+2 -6
View File
@@ -1,5 +1,4 @@
import json
from pathlib import Path
import httpx
import pytest
@@ -200,7 +199,6 @@ def test_intent_without_a_branch_is_unverifiable() -> None:
def test_closing_comments_first_then_closes_issue_and_pr() -> None:
from nisse_dev.approvals import PlanBoard
from nisse_dev.git import GitRepo
from nisse_dev.publish import MilestonePublisher
seen: list[tuple[str, str, dict]] = []
@@ -220,10 +218,8 @@ def test_closing_comments_first_then_closes_issue_and_pr() -> None:
None, # type: ignore[arg-type] -- closing touches neither store nor git
gitea,
"o/r",
GitRepo(Path(".")),
base_url="https://gitea.test",
account="bot",
token="tok",
None, # type: ignore[arg-type]
remote_url="ssh://git@gitea.test/o/r.git",
)
publisher.close_pull_request(20, "abandoned by don")
+15 -6
View File
@@ -5,12 +5,22 @@ from typing import Any
import pytest
from nisse_dev.execution import SSHHost
from nisse_dev.git import GitRepo
from nisse_dev.gitea import GiteaError, PullRequest
from nisse_dev.oplog import OpLog
from nisse_dev.publish import OPEN_PR_INTENT, MilestonePublisher
from nisse_dev.state import StateStore
# The build VM for every test here: the session's throwaway sshd.
HOST: SSHHost
@pytest.fixture(autouse=True)
def _build_host(ssh_host: SSHHost) -> None:
global HOST
HOST = ssh_host
class FakeGitea:
def __init__(self, *, fail: bool = False) -> None:
@@ -46,7 +56,7 @@ class FakeGit:
def __init__(
self, root: Path, *, changes: bool = True, committed: bool = False
) -> None:
self.repo = GitRepo(root)
self.repo = GitRepo(HOST, root)
self.repo.init()
self.repo.configure_identity("bot", "bot@example.test")
(root / "README.md").write_text("sandbox")
@@ -67,7 +77,7 @@ class FakeGit:
return self.repo.commits_since(ref)
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:
return self.remote.get(branch)
@@ -80,7 +90,7 @@ class FakeGit:
def commit_all(self, message: str) -> str | None:
return self.repo.commit_all(message)
def push(self, remote_url: str, branch: str, *, token: str | None = None) -> None:
def push(self, remote_url: str, branch: str, *, key: str | None = None) -> None:
self.pushed.append(branch)
self.remote[branch] = self.repo.head_sha()
@@ -100,9 +110,8 @@ def build(store: StateStore, git: Any, client: Any) -> MilestonePublisher:
client,
"o/sandbox",
git,
base_url="https://gitea.test",
account="bot",
token="tok",
remote_url="ssh://git@gitea.test/o/sandbox.git",
push_key="KEY",
)
+51 -6
View File
@@ -4,12 +4,13 @@ from pathlib import Path
import pytest
from nisse_dev.execution import REDACTED, SSHHost
from nisse_dev.tools import EXECUTOR_TOOLS, MAX_OUTPUT_CHARS, Workspace
@pytest.fixture
def ws(tmp_path: Path) -> Workspace:
return Workspace(tmp_path / "work", timeout_s=10)
def ws(ssh_host: SSHHost, tmp_path: Path) -> Workspace:
return Workspace(ssh_host, tmp_path / "work", timeout_s=10)
def test_manifest_is_small_and_flat() -> None:
@@ -71,8 +72,8 @@ def test_command_runs_in_the_workspace(ws: Workspace) -> None:
assert "marker.txt" in ws.run_command("ls").result
def test_command_timeout_is_reported(tmp_path: Path) -> None:
ws = Workspace(tmp_path / "work", timeout_s=1)
def test_command_timeout_is_reported(ssh_host: SSHHost, tmp_path: Path) -> None:
ws = Workspace(ssh_host, tmp_path / "work", timeout_s=1)
outcome = ws.run_command("sleep 30")
assert not outcome.success
assert "timed out" in outcome.result
@@ -90,8 +91,10 @@ def _is_running(pid: int) -> bool:
return stat.rsplit(")", 1)[1].split()[0] != "Z"
def test_timeout_kills_the_whole_process_group(tmp_path: Path) -> None:
ws = Workspace(tmp_path / "work", timeout_s=1)
def test_timeout_kills_the_whole_process_group(
ssh_host: SSHHost, tmp_path: Path
) -> None:
ws = Workspace(ssh_host, tmp_path / "work", timeout_s=1)
pidfile = ws.root / "child.pid"
# A backgrounded grandchild must not outlive the timeout.
ws.run_command(f"(sleep 30 & echo $! > {pidfile}); sleep 30")
@@ -156,3 +159,45 @@ def test_escaping_via_parent_traversal_is_refused(ws: Workspace) -> None:
def test_describe_names_the_root(ws: Workspace) -> None:
assert str(ws.root) in ws.describe()
# --- on the build VM (L13) -----------------------------------------------------
def test_project_secrets_reach_commands_and_are_redacted(
ssh_host: SSHHost, tmp_path: Path
) -> None:
value = "tok-" + os.urandom(8).hex()
ws = Workspace(ssh_host, tmp_path / "work", secrets={"TEST_TOKEN": value})
outcome = ws.run_command('test -n "$TEST_TOKEN" && echo "token=$TEST_TOKEN"')
assert outcome.success # the command had it
assert value not in outcome.result
assert f"token={REDACTED}" in outcome.result
def test_an_unreachable_build_vm_is_a_failed_tool_call(tmp_path: Path) -> None:
ws = Workspace.__new__(Workspace) # no host to create the root on
ws.host = SSHHost(
"127.0.0.1", key=tmp_path / "no-key", state_dir=tmp_path / "s", port=1
)
ws.root = tmp_path / "work"
ws._timeout_s, ws._max_output_chars, ws._secrets = 5, MAX_OUTPUT_CHARS, {}
outcome = ws.dispatch("run_command", {"command": "true"})
assert not outcome.success
assert "unreachable" in outcome.result
def test_upload_tree_copies_a_directory_in(ws: Workspace, tmp_path: Path) -> None:
src = tmp_path / "seed"
(src / "pkg").mkdir(parents=True)
(src / "pkg" / "m.py").write_text("x = 1\n")
ws.upload_tree(src)
assert (ws.root / "pkg" / "m.py").read_text() == "x = 1\n"
ws.upload_tree(src, "copy")
assert (ws.root / "copy" / "pkg" / "m.py").exists()
def test_non_text_files_read_as_a_failure(ws: Workspace) -> None:
(ws.root / "blob.bin").write_bytes(b"\xff\xfe\x00")
outcome = ws.read_file("blob.bin")
assert not outcome.success