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.
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
The deploy key with write access on dongar/nisse-dev-testing-sandbox (public key posted in the session).
A CI secret NISSE_PUSH_KEY holding the private half (/root/nisse-e2e/ssh/push_sandbox on the dev box).
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.
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
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
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
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
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
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
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.
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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.exports,cd, then the command undertimeout. The command's stdin is/dev/nullor a here-document, so it can never read the rest of the script.HostUnavailable. A command exiting 255 is reported as 254, so it can't be confused with ssh failing.WorkspaceandGitReporun on the build VM. Their interfaces are unchanged apart from taking a host, so the executor, delegation and publisher don't change.realpath.Workspace.upload_treeseeds bench tasks.No credential in the clone (#89).
GiteaClient.ssh_url).ssh-agenton the VM that's killed when the command ends.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=)andscripts/benchmark.py.bringup.sh startalso sets the previous workspace aside on the build VM.Tests
sshd(nisse_dev.testing.local_sshd, the session fixturessh_hostin a new rootconftest.py) stands in for the build VM. CI installsopenssh-server.test_execution.py:pson the host).test_git.py:ssh-agentis gone after success and after failure;test_tools.py: project secrets reach commands and are redacted; an unreachable VM is a failed tool call; uploads.test_gitea_livenow pushes the production way (SSH, deploy key), so it needs theNISSE_PUSH_KEYsecret, and CI will fail on it until that secret exists.Needs from the operator
dongar/nisse-dev-testing-sandbox(public key posted in the session).NISSE_PUSH_KEYholding the private half (/root/nisse-e2e/ssh/push_sandboxon the dev box).nisse-dev.garrett-hall.comfor 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
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 rungit 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,AgentConfigandMatrixConfigtokens) are kept out of reprs, after a failing CI test printed the fixture's throwaway key. Zombiessh-agents in CI's container no longer count as live.The acceptance run:
CI is green, including the live Gitea push.
🤖 Generated with Claude Code