Run every command on the build VM over SSH (M1.3) #93

Merged
dongar merged 5 commits from ssh-execution into main 2026-10-02 22:44:22 +00:00
Collaborator

Development Plan M1.3: Execution over SSH (architecture L13, D4, L12). Fixes #89.

What changes

  • execution.py: SSHHost.execute(command, *, cwd, secrets, stdin, timeout_s) is the only way the agent runs anything.

    • Transport: the OpenSSH client with a shared connection (ControlMaster), so a tool call costs one round trip, not a handshake.
    • Secrets never touch a command line. Each call sends a small script on stdin: exports, cd, then the command under timeout. The command's stdin is /dev/null or a here-document, so it can never read the rest of the script.
    • Redaction: every secret handed to a call is masked in its output (L12).
    • Failures: a timeout kills the whole remote process group, and an unreachable host raises HostUnavailable. A command exiting 255 is reported as 254, so it can't be confused with ssh failing.
  • Workspace and GitRepo run on the build VM. Their interfaces are unchanged apart from taking a host, so the executor, delegation and publisher don't change.

    • File contents travel as base64.
    • The write-outside-the-workspace check uses the remote realpath.
    • Workspace.upload_tree seeds bench tasks.
  • No credential in the clone (#89).

    • The clone uses Gitea's SSH URL (GiteaClient.ssh_url).
    • Clones, fetches and pushes get the push key over stdin, in a short-lived ssh-agent on the VM that's killed when the command ends.
    • PRs are still opened from the agent's host with its API token, which never reaches the VM.
  • Configuration:

    • build_host (address, key, user, port);
    • gitea.push_key, the path to a deploy key with write access;
    • workspace, now a path on the build host.

    M1.4 folds these into the project entry.

  • The bench runs on the build host too: run_task(..., host=) and scripts/benchmark.py.

  • bringup.sh start also sets the previous workspace aside on the build VM.

Tests

  • A throwaway local sshd (nisse_dev.testing.local_sshd, the session fixture ssh_host in a new root conftest.py) stands in for the build VM. CI installs openssh-server.
  • New test_execution.py:
    • exit codes, streams, cwd and stdin;
    • stdin that looks like a delimiter;
    • a command that can't read the script;
    • a missing cwd;
    • 255 vs. ssh failure;
    • an unreachable host;
    • timeouts;
    • secrets redacted and never on any command line (checked with ps on the host).
  • test_git.py:
    • clone and push over SSH with the key store no credential;
    • the throwaway ssh-agent is gone after success and after failure;
    • remote URLs are quoted, not interpreted.
  • test_tools.py: project secrets reach commands and are redacted; an unreachable VM is a failed tool call; uploads.
  • Every existing executor, delegation, publisher, bench and agent-assembly test now runs over SSH. 543 unit tests pass.
  • Live tests: the milestone, delegation and benchmark tests pass over SSH. test_gitea_live now pushes the production way (SSH, deploy key), so it needs the NISSE_PUSH_KEY secret, and CI will fail on it until that secret exists.

Needs from the operator

  1. The deploy key with write access on dongar/nisse-dev-testing-sandbox (public key posted in the session).
  2. A CI secret NISSE_PUSH_KEY holding the private half (/root/nisse-e2e/ssh/push_sandbox on the dev box).
  3. SSH access to nisse-dev.garrett-hall.com for the acceptance live run.

Acceptance still to do: a live run of one milestone on the build VM.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE

Development Plan **M1.3: Execution over SSH** (architecture L13, D4, L12). Fixes #89. ## What changes - **`execution.py`: `SSHHost.execute(command, *, cwd, secrets, stdin, timeout_s)`** is the only way the agent runs anything. - **Transport:** the OpenSSH client with a shared connection (ControlMaster), so a tool call costs one round trip, not a handshake. - **Secrets never touch a command line.** Each call sends a small script on stdin: `export`s, `cd`, then the command under `timeout`. The command's stdin is `/dev/null` or a here-document, so it can never read the rest of the script. - **Redaction:** every secret handed to a call is masked in its output (L12). - **Failures:** a timeout kills the whole remote process group, and an unreachable host raises `HostUnavailable`. A command exiting 255 is reported as 254, so it can't be confused with ssh failing. - **`Workspace` and `GitRepo` run on the build VM.** Their interfaces are unchanged apart from taking a host, so the executor, delegation and publisher don't change. - File contents travel as base64. - The write-outside-the-workspace check uses the remote `realpath`. - `Workspace.upload_tree` seeds bench tasks. - **No credential in the clone (#89).** - The clone uses Gitea's SSH URL (`GiteaClient.ssh_url`). - Clones, fetches and pushes get the **push key** over stdin, in a short-lived `ssh-agent` on the VM that's killed when the command ends. - PRs are still opened from the agent's host with its API token, which never reaches the VM. - **Configuration:** - `build_host` (address, key, user, port); - `gitea.push_key`, the path to a deploy key with write access; - `workspace`, now a path on the build host. M1.4 folds these into the project entry. - **The bench runs on the build host too:** `run_task(..., host=)` and `scripts/benchmark.py`. - **`bringup.sh start`** also sets the previous workspace aside on the build VM. ## Tests - **A throwaway local `sshd`** (`nisse_dev.testing.local_sshd`, the session fixture `ssh_host` in a new root `conftest.py`) stands in for the build VM. CI installs `openssh-server`. - **New `test_execution.py`:** - exit codes, streams, cwd and stdin; - stdin that looks like a delimiter; - a command that can't read the script; - a missing cwd; - 255 vs. ssh failure; - an unreachable host; - timeouts; - secrets redacted and **never on any command line** (checked with `ps` on the host). - **`test_git.py`:** - clone and push over SSH with the key store no credential; - the throwaway `ssh-agent` is gone after success and after failure; - remote URLs are quoted, not interpreted. - **`test_tools.py`:** project secrets reach commands and are redacted; an unreachable VM is a failed tool call; uploads. - **Every existing executor, delegation, publisher, bench and agent-assembly test now runs over SSH.** 543 unit tests pass. - **Live tests:** the milestone, delegation and benchmark tests pass over SSH. **`test_gitea_live` now pushes the production way** (SSH, deploy key), so it needs the `NISSE_PUSH_KEY` secret, and **CI will fail on it until that secret exists.** ## Needs from the operator 1. **The deploy key with write access** on `dongar/nisse-dev-testing-sandbox` (public key posted in the session). 2. **A CI secret `NISSE_PUSH_KEY`** holding the private half (`/root/nisse-e2e/ssh/push_sandbox` on the dev box). 3. **SSH access** to `nisse-dev.garrett-hall.com` for the acceptance live run. **Acceptance still to do:** a live run of one milestone on the build VM. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
dev-agent added 1 commit 2026-10-02 21:39:46 +00:00
Run every command on the build VM over SSH (M1.3)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 55s
761ebdb857
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
dev-agent added 1 commit 2026-10-02 21:59:56 +00:00
Let pushes go by an HTTPS token or an SSH deploy key
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 56s
c931ac939b
The push credential is either a push-only account's token, handed to git
through a credential helper that names environment variables, never
values, or a deploy key in a short-lived ssh-agent. The remote URL
follows the credential. Configured as gitea.push_user and push_token, or
gitea.push_key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
dev-agent added 1 commit 2026-10-02 22:29:21 +00:00
Flatten harness branch names so a model's branch can't block them
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 55s
4f152af170
Live run 3: the executor ran `git checkout -b <task id>` itself, and
publishing then failed, because git can't create '<task>/milestone-1'
while a branch named '<task>' exists. Milestone and delegation branches
are now '<task>-milestone-N' and 'delegation-milestone-N-F'. D4's
harness-owned branches (M2.1) remove the cause; this removes the
collision on the current loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
dev-agent added 1 commit 2026-10-02 22:38:14 +00:00
Publish only the work: leave build artifacts out and report them (#80)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 56s
e02bac110d
The clone gets the standard build artifacts in its own exclude file, so
they never show as changes, even to a model's `git add -A`. The
harness's commits leave the same patterns out even when the project
tracks them, as the sandbox's main did. What was left out is reported in
the publish note and the PR description. Found again in bring-up run 3
(sandbox PR #208).

Fixes #80.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
dev-agent added 1 commit 2026-10-02 22:41:02 +00:00
Keep credentials out of reprs; count only live ssh-agents in tests
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m43s
d5cef02e89
A failing CI test printed a test key through SSHKey's default repr. Push
credentials, AgentConfig's tokens and MatrixConfig's token are now
excluded from their reprs, so no failure or log line can print them. In
CI's container killed ssh-agents linger as zombies, so the tests count
only live ones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Author
Collaborator

Added after the acceptance run (bring-up run 3, sandbox PR #208, on the build VM nisse-dev.garrett-hall.com):

  • c931ac9: the push credential is either an HTTPS token for a push-only account or an SSH deploy key, and the remote URL follows the choice. The sandbox uses the deploy key, now that Gitea's SSH hairpins.
  • 4f152af: flat harness branch names (<task>-milestone-N). The executor had run git checkout -b <task id> itself, which blocked <task>/milestone-1.
  • e02bac1, fixes #80: the clone excludes build artifacts, and the harness's commits leave them out even when the project tracks them. What's left out is reported in the publish note and the PR body.
  • d5cef02: credentials (push credentials, AgentConfig and MatrixConfig tokens) are kept out of reprs, after a failing CI test printed the fixture's throwaway key. Zombie ssh-agents in CI's container no longer count as live.

The acceptance run:

  • every command ran on the build VM over SSH;
  • the push used the deploy key, and PR #208 opened;
  • the planner's view after the milestone confirmed #78, now closed.

CI is green, including the live Gitea push.

🤖 Generated with Claude Code

Added after the acceptance run (bring-up run 3, sandbox PR #208, on the build VM `nisse-dev.garrett-hall.com`): - **`c931ac9`:** the push credential is either an HTTPS token for a push-only account or an SSH deploy key, and the remote URL follows the choice. The sandbox uses the deploy key, now that Gitea's SSH hairpins. - **`4f152af`:** flat harness branch names (`<task>-milestone-N`). The executor had run `git checkout -b <task id>` itself, which blocked `<task>/milestone-1`. - **`e02bac1`, fixes #80:** the clone excludes build artifacts, and the harness's commits leave them out even when the project tracks them. What's left out is reported in the publish note and the PR body. - **`d5cef02`:** credentials (push credentials, `AgentConfig` and `MatrixConfig` tokens) are kept out of reprs, after a failing CI test printed the fixture's throwaway key. Zombie `ssh-agent`s in CI's container no longer count as live. **The acceptance run:** - every command ran on the build VM over SSH; - the push used the deploy key, and PR #208 opened; - the planner's view after the milestone confirmed #78, now closed. CI is green, including the live Gitea push. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
dongar approved these changes 2026-10-02 22:44:18 +00:00
dongar merged commit 3cc3eed8f3 into main 2026-10-02 22:44:22 +00:00
dongar deleted branch ssh-execution 2026-10-02 22:44:22 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dev/nisse#93