Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3d6afb5
test(e2e): MCP-only agent E2E harness over nself mcp (P7-SHIP-02)
acamarata Oct 29, 2026
03613c7
test(e2e): drive init without a marker and pass mcp --timeout (P7-SHI…
acamarata Oct 29, 2026
2b5b0d3
test(e2e): let the sandboxed docker CLI find the compose plugin (P7-S…
acamarata Oct 30, 2026
52c75aa
test(e2e): unique fixture project, teardown and a shared TMPDIR (P7-S…
acamarata Oct 30, 2026
aef0cc9
test(e2e): create the first migration before migrate up (P7-SHIP-02)
acamarata Oct 30, 2026
93052b8
test(e2e): look up the compose plugin from test code (P7-SHIP-02)
acamarata Oct 30, 2026
eabaf35
test(e2e): run the ci leg against a ci plugin built from plugins sour…
acamarata Oct 30, 2026
b1afe17
test(e2e): seed the sim hosts with the project's plugin manifests (P7…
acamarata Oct 30, 2026
34ae103
test(e2e): sim prod declares no verified release; scratch under HOME …
acamarata Oct 30, 2026
115a824
test(e2e): sim hosts report the images' real registry digests (P7-SHI…
acamarata Oct 30, 2026
5bf9808
test(e2e): resolve seeded digests through the container image id (P7-…
acamarata Oct 30, 2026
f6c3d33
test(e2e): sim docker stub reports compose services; log stub calls o…
acamarata Oct 30, 2026
ea1b28f
test(e2e): sim docker stub models compose up, ps and inspect like dep…
acamarata Oct 30, 2026
48429fd
test(e2e): sim docker stub answers compose config with a service mode…
acamarata Oct 30, 2026
6ff6a77
test(e2e): sim docker stub reads services at any indentation (P7-SHIP…
acamarata Oct 30, 2026
a6f1c83
ci(agent-e2e): install the pinned gitleaks for the replay's CI run (P…
acamarata Oct 30, 2026
f165809
ci(agent-e2e): install pinned trivy for the replay's CI run (P7-SHIP-02)
acamarata Oct 30, 2026
fdc55fe
ci(agent-e2e): pre-pull the pinned sim host image with backoff (P7-SH…
acamarata Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/wiki/Guide-Agents-MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,24 @@ nself ci
```

`nself ci --check` runs the gates only. `nself ci` also posts the `nself-ci` commit status. See [[Guide-CI-CD]] and the plugins CI [Getting Started](https://github.com/nself-org/plugins/blob/main/.github/wiki/guides/ci/Getting-Started.md) page.

## End-to-end proof

The agent E2E harness proves an agent can run a whole project through MCP alone. It lives in `test/e2e/agent/` (build tag `e2eagent`) and `scripts/e2e/agent/`.

What it covers:

- A replay client speaks MCP to `nself mcp` and nothing else. It runs `init`, `build` (plan, then confirm), `start`, `db migrate up`, `add ci`, an async CI run (detach, wait, evidence), `deploy qa`, then `deploy promote qa prod` as a dry run and as a confirmed run.
- Two legs: failure recovery (the harness stops one service, the agent reads `status` and recovers with `start`) and the destructive gate (`reset` without a confirmation returns the confirmation request and no volume changes).
- `qa` and `prod` are two loopback sim hosts. The server runs in a sandbox: a temp `HOME` holding only the sim key and ssh config, no SSH agent, and an allowlist of `127.0.0.1` for every host it may name.
- Every request, result and harness action goes to a JSONL transcript, and a verifier checks it.

How to run it (shown as text; it needs Docker and a cli checkout):

```text
bash scripts/e2e/agent/run.sh --self-test
bash scripts/e2e/agent/run.sh --replay --transcript /tmp/agent-e2e/replay.jsonl
bash scripts/e2e/agent/run.sh --verify-transcript /tmp/agent-e2e/replay.jsonl
```

The `Agent E2E` workflow runs the replay on GitHub-hosted ubuntu and uploads the transcript as `agent-e2e-transcript`. Details and known gaps: `test/e2e/agent/README.md`.
88 changes: 88 additions & 0 deletions .github/workflows/agent-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Agent E2E

# MCP-only agent E2E proof (P7-SHIP-02, SHIP Epic D7): replays
# test/e2e/agent/testdata/replay.jsonl over a sandboxed `nself mcp` against two
# loopback sim hosts and uploads the transcript. GitHub-hosted ubuntu only; the
# live Claude session runs on the owner machine (P7-SHIP-13).

on:
workflow_dispatch:
pull_request:
paths:
- test/e2e/agent/**
- scripts/e2e/agent/**
- .github/workflows/agent-e2e.yml

permissions:
contents: read

concurrency:
group: agent-e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
replay:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
NSELF_V15: "1"
GITLEAKS_SHA256: "5bc41815076e6ed6ef8fbecc9d9b75bcae31f39029ceb55da08086315316e3ba" # gitleaks 8.21.2 linux_x64
TRIVY_SHA256: "e24b1c51293c1b15e4ba8d45bbc7891ba053f90278ec2698f3562c993e145fe7" # trivy 0.76.0 Linux-64bit
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- name: Checkout plugins main (ci plugin source)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: nself-org/plugins
ref: main
path: plugins-src
persist-credentials: false
# The replay's CI run uses the default pipeline (gitleaks and trivy jobs);
# a job whose tool is missing cannot be placed and fails the run. The
# sandboxed server sees only the system PATH, so both go to /usr/local/bin.
- name: Install gitleaks and trivy
run: |
set -euo pipefail
curl --fail --silent --show-error --location --proto '=https' --output "$RUNNER_TEMP/gitleaks.tar.gz" \
https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz
echo "$GITLEAKS_SHA256 $RUNNER_TEMP/gitleaks.tar.gz" | sha256sum --check --strict -
tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP" gitleaks
sudo install -m 0755 "$RUNNER_TEMP/gitleaks" /usr/local/bin/gitleaks
curl --fail --silent --show-error --location --proto '=https' --output "$RUNNER_TEMP/trivy.tar.gz" \
https://github.com/aquasecurity/trivy/releases/download/v0.76.0/trivy_0.76.0_Linux-64bit.tar.gz
echo "$TRIVY_SHA256 $RUNNER_TEMP/trivy.tar.gz" | sha256sum --check --strict -
tar -xzf "$RUNNER_TEMP/trivy.tar.gz" -C "$RUNNER_TEMP" trivy
sudo install -m 0755 "$RUNNER_TEMP/trivy" /usr/local/bin/trivy
# Pull the digest-pinned sim host image (sdk/go/simharness OpenSSHRef)
# before the replay, so a registry throttle on the shared runner IP is
# absorbed here with a bounded backoff instead of failing the fleet start.
- name: Pre-pull the sim host image
env:
SIM_IMAGE: lscr.io/linuxserver/openssh-server@sha256:46f115de7c251558297e7e87566fc3fc08544b63e55502b5cf294454db5d29d1
run: |
for i in 1 2 3 4; do
docker pull --quiet "$SIM_IMAGE" && exit 0
sleep $((i * 30))
done
exit 1
- name: Self-test (verifier and sandbox refusals)
run: bash scripts/e2e/agent/run.sh --self-test
- name: Replay over MCP
# The ci plugin comes from plugins main until a served ci release
# carries the machine surface (P7-CI-62).
run: bash scripts/e2e/agent/run.sh --replay --transcript "$RUNNER_TEMP/agent-e2e/replay.jsonl" --ci-plugin-src plugins-src/free/ci
- name: Verify the transcript
run: bash scripts/e2e/agent/run.sh --verify-transcript "$RUNNER_TEMP/agent-e2e/replay.jsonl"
- name: Upload transcript
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: agent-e2e-transcript
path: ${{ runner.temp }}/agent-e2e/replay.jsonl
if-no-files-found: warn
retention-days: 14
140 changes: 140 additions & 0 deletions scripts/e2e/agent/mcp-sandbox.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# mcp-sandbox.sh — start an MCP server (normally `nself mcp`) for the agent E2E
# harness in a sealed environment (P7-SHIP-02, SHIP Epic D7).
#
# Usage:
# mcp-sandbox.sh --home DIR --project DIR --operator-home DIR
# [--inventory FILE] [--allow NAME]... [--env K=V]...
# -- BIN [ARGS...]
#
# What it does: refuses to start (exit 1, naming the entry) when
# - SSH_AUTH_SOCK is set (an agent socket could reach real keys),
# - the sandbox HOME is, contains, links to or names the operator's ~/.ssh,
# - HOME, the inventory or the ssh config names a host outside the
# allowlist (127.0.0.1, localhost and each --allow name),
# - an --env entry is outside the fixed list below;
# otherwise it cds into --project and execs BIN under `env -i` with only
# PATH, HOME, TMPDIR, NSELF_V15 and the allowed --env entries.
#
# Allowed --env names: NSELF_V15, NSELF_E2E_SIM_KEY (a path inside HOME),
# DOCKER_HOST (unix:// sockets only).
#
# Exit codes: 0 never returns on success (exec), 1 refused, 2 usage.
# Bash 3.2 compatible.

set -u

die_usage() { printf 'mcp-sandbox: usage: %s\n' "$1" >&2; exit 2; }
refuse() { printf 'mcp-sandbox: refused: %s\n' "$1" >&2; exit 1; }

HOME_DIR="" PROJECT="" OPHOME="" INVENTORY=""
ALLOW="127.0.0.1 localhost"
ENVS=()
while [ $# -gt 0 ]; do
case "$1" in
--home) HOME_DIR="${2:-}"; shift 2 ;;
--project) PROJECT="${2:-}"; shift 2 ;;
--operator-home) OPHOME="${2:-}"; shift 2 ;;
--inventory) INVENTORY="${2:-}"; shift 2 ;;
--allow) ALLOW="$ALLOW ${2:-}"; shift 2 ;;
--env) ENVS+=("${2:-}"); shift 2 ;;
--) shift; break ;;
*) die_usage "unknown argument $1" ;;
esac
done
[ -n "$HOME_DIR" ] && [ -d "$HOME_DIR" ] || die_usage "--home must be an existing directory"
[ -n "$PROJECT" ] && [ -d "$PROJECT" ] || die_usage "--project must be an existing directory"
[ -n "$OPHOME" ] || die_usage "--operator-home is required"
[ $# -gt 0 ] || die_usage "missing -- BIN"

resolve() { readlink -f "$1" 2>/dev/null || printf '%s' "$1"; }

# 1. No SSH agent.
[ -z "${SSH_AUTH_SOCK:-}" ] || refuse "SSH_AUTH_SOCK is set; unset it so no agent key is reachable"

# 2. HOME must not reach the operator's ~/.ssh.
H=$(resolve "$HOME_DIR")
OP=$(resolve "$OPHOME")
OPSSH="$OP/.ssh"
[ "$H" != "$OP" ] || refuse "HOME $HOME_DIR is the operator's home"
case "$H/" in "$OPSSH"/*) refuse "HOME $HOME_DIR is inside the operator's ~/.ssh" ;; esac
while IFS= read -r link; do
[ -n "$link" ] || continue
target=$(resolve "$link")
case "$target/" in
"$OPSSH"/*|"$OP"/) refuse "$link reaches the operator's ~/.ssh ($target)" ;;
esac
case "$target/" in "$H"/*) ;; *) refuse "$link points outside HOME ($target)" ;; esac
done <<EOF
$(find "$HOME_DIR" -type l 2>/dev/null)
EOF

# 3. Files to scan: every regular file in HOME, plus the inventory.
FILES=$(find "$HOME_DIR" -type f 2>/dev/null)
if [ -n "$INVENTORY" ]; then
[ -f "$INVENTORY" ] || die_usage "--inventory $INVENTORY does not exist"
FILES="$FILES
$INVENTORY"
fi

allowed() {
for a in $ALLOW; do [ "$1" = "$a" ] && return 0; done
return 1
}

check_host() { # file host
h="$2"
h="${h#*@}" # user@host
case "$h" in \[*\]*) h="${h#\[}"; h="${h%%\]*}" ;; *) h="${h%%:*}" ;; esac
h=$(printf '%s' "$h" | tr -d '"'"'"' ')
[ -n "$h" ] || return 0
allowed "$h" || refuse "$1 names host $h outside the allowlist ($ALLOW)"
}

while IFS= read -r f; do
[ -n "$f" ] || continue
if grep -qF "$OPSSH" "$f" 2>/dev/null; then
refuse "$f names the operator's ~/.ssh"
fi
# IPv4 literals anywhere.
for ip in $(grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' "$f" 2>/dev/null); do
check_host "$f" "$ip"
done
# ssh config Host / HostName / ProxyJump lines; Include, Match and
# ProxyCommand are refused outright (they can reach anything).
for h in $(awk 'tolower($1)=="host"||tolower($1)=="hostname"||tolower($1)=="proxyjump"{for(i=2;i<=NF;i++)print $i}' "$f" 2>/dev/null); do
check_host "$f" "$h"
done
kw=$(awk 'tolower($1)=="include"||tolower($1)=="match"||tolower($1)=="proxycommand"{print $1; exit}' "$f" 2>/dev/null)
[ -z "$kw" ] || refuse "$f uses $kw, which can reach hosts or config outside the sandbox"
# Inventory host fields (YAML `host: ...`).
for h in $(sed -n 's/^[[:space:]-]*host:[[:space:]]*//p' "$f" 2>/dev/null); do
check_host "$f" "$h"
done
done <<EOF
$FILES
EOF

# 4. The environment the server gets.
V15=1
EXTRA=()
for kv in ${ENVS[@]+"${ENVS[@]}"}; do
k="${kv%%=*}"; v="${kv#*=}"
case "$k" in
NSELF_V15) V15="$v" ;;
NSELF_E2E_SIM_KEY)
case "$(resolve "$v")/" in "$H"/*) EXTRA+=("$kv") ;; *) refuse "--env $k must be a path inside HOME" ;; esac ;;
DOCKER_HOST)
case "$v" in unix://*) EXTRA+=("$kv") ;; *) refuse "--env DOCKER_HOST must be a unix:// socket" ;; esac ;;
*) refuse "--env $k is not on the sandbox list" ;;
esac
done

SAFE_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
[ -d /opt/homebrew/bin ] && SAFE_PATH="/opt/homebrew/bin:$SAFE_PATH"
TMP_DIR="$HOME_DIR/.tmp"
mkdir -p "$TMP_DIR" || refuse "cannot create $TMP_DIR"

cd "$PROJECT" || refuse "cannot enter $PROJECT"
exec env -i PATH="$SAFE_PATH" HOME="$HOME_DIR" TMPDIR="$TMP_DIR" NSELF_V15="$V15" \
${EXTRA[@]+"${EXTRA[@]}"} "$@"
Loading
Loading