From 3d6afb5f30a43f60e04b9017c9f3d852ecdf6169 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 12:31:08 -0400 Subject: [PATCH 01/18] test(e2e): MCP-only agent E2E harness over nself mcp (P7-SHIP-02) Add test/e2e/agent (build tag e2eagent): an mcp-go stdio client replays testdata/replay.jsonl against `nself mcp` started through scripts/e2e/agent/mcp-sandbox.sh, with two loopback simharness hosts in inventory envs qa (tier local-servers) and prod (tier prod). The flow is init, build (plan, confirm), start, the failure-recovery and destructive-gate legs, db migrate up, add ci, async CI (detach, runs wait, evidence), deploy qa, promote dry run, confirmed promote. Every request, result and harness action goes to a JSONL transcript that verify.go checks; bad fixtures prove the verifier rejects non-MCP use, a missing step, a promote apply before its dry run, CI evidence before the wait and a non-v1 envelope. mcp-sandbox.sh runs the server under env -i with a temp HOME holding only the per-run sim key and ssh config, and refuses SSH_AUTH_SOCK, a reachable operator ~/.ssh and any host other than 127.0.0.1. The only key is the one simharness generates at run time; nothing credential-shaped is committed. run.sh modes: --replay, --live (headless agent session with only the MCP server, optional harness-side prod probe), --verify-transcript, --self-test, --tools-list. The Agent E2E workflow runs the replay on ubuntu-latest and uploads agent-e2e-transcript. Vendors the mcp-go client and client/transport packages (go.mod unchanged). The README lists the product gaps the replay hits today. (P7-SHIP-02) --- .github/wiki/Guide-Agents-MCP.md | 21 + .github/workflows/agent-e2e.yml | 49 + scripts/e2e/agent/mcp-sandbox.sh | 140 ++ scripts/e2e/agent/run.sh | 179 +++ test/e2e/agent/README.md | 55 + test/e2e/agent/agent_test.go | 236 ++++ test/e2e/agent/flow.go | 279 ++++ test/e2e/agent/harness.go | 174 +++ test/e2e/agent/live_test.go | 171 +++ test/e2e/agent/sandbox.go | 135 ++ .../e2e/agent/testdata/bad/bad-envelope.jsonl | 52 + .../testdata/bad/evidence-before-wait.jsonl | 52 + .../e2e/agent/testdata/bad/missing-step.jsonl | 50 + test/e2e/agent/testdata/bad/non-mcp.jsonl | 53 + .../agent/testdata/bad/promote-order.jsonl | 52 + test/e2e/agent/testdata/good.jsonl | 52 + test/e2e/agent/testdata/live-prompt.md | 29 + test/e2e/agent/testdata/replay.jsonl | 30 + test/e2e/agent/testdata/sim/docker.sh | 33 + test/e2e/agent/testdata/sim/nself.sh | 16 + test/e2e/agent/transcript.go | 186 +++ test/e2e/agent/verify.go | 267 ++++ test/e2e/agent/verify_legs.go | 103 ++ .../mark3labs/mcp-go/client/client.go | 814 +++++++++++ .../mark3labs/mcp-go/client/elicitation.go | 19 + .../mark3labs/mcp-go/client/http.go | 22 + .../mark3labs/mcp-go/client/inprocess.go | 38 + .../mark3labs/mcp-go/client/interface.go | 109 ++ .../mark3labs/mcp-go/client/iter.go | 153 +++ .../mark3labs/mcp-go/client/oauth.go | 105 ++ .../mark3labs/mcp-go/client/roots.go | 17 + .../mark3labs/mcp-go/client/sampling.go | 20 + .../github.com/mark3labs/mcp-go/client/sse.go | 61 + .../mark3labs/mcp-go/client/stdio.go | 62 + .../mark3labs/mcp-go/client/tracing.go | 90 ++ .../mcp-go/client/transport/command.go | 44 + .../mcp-go/client/transport/constants.go | 7 + .../mcp-go/client/transport/error.go | 22 + .../mcp-go/client/transport/inprocess.go | 199 +++ .../mcp-go/client/transport/interface.go | 74 + .../mcp-go/client/transport/logging.go | 304 +++++ .../mcp-go/client/transport/oauth.go | 1202 +++++++++++++++++ .../mcp-go/client/transport/oauth_utils.go | 68 + .../mark3labs/mcp-go/client/transport/sse.go | 767 +++++++++++ .../mcp-go/client/transport/stdio.go | 592 ++++++++ .../client/transport/streamable_http.go | 1154 ++++++++++++++++ .../mcp-go/client/transport/utils.go | 26 + vendor/modules.txt | 2 + 48 files changed, 8385 insertions(+) create mode 100644 .github/workflows/agent-e2e.yml create mode 100755 scripts/e2e/agent/mcp-sandbox.sh create mode 100755 scripts/e2e/agent/run.sh create mode 100644 test/e2e/agent/README.md create mode 100644 test/e2e/agent/agent_test.go create mode 100644 test/e2e/agent/flow.go create mode 100644 test/e2e/agent/harness.go create mode 100644 test/e2e/agent/live_test.go create mode 100644 test/e2e/agent/sandbox.go create mode 100644 test/e2e/agent/testdata/bad/bad-envelope.jsonl create mode 100644 test/e2e/agent/testdata/bad/evidence-before-wait.jsonl create mode 100644 test/e2e/agent/testdata/bad/missing-step.jsonl create mode 100644 test/e2e/agent/testdata/bad/non-mcp.jsonl create mode 100644 test/e2e/agent/testdata/bad/promote-order.jsonl create mode 100644 test/e2e/agent/testdata/good.jsonl create mode 100644 test/e2e/agent/testdata/live-prompt.md create mode 100644 test/e2e/agent/testdata/replay.jsonl create mode 100755 test/e2e/agent/testdata/sim/docker.sh create mode 100755 test/e2e/agent/testdata/sim/nself.sh create mode 100644 test/e2e/agent/transcript.go create mode 100644 test/e2e/agent/verify.go create mode 100644 test/e2e/agent/verify_legs.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/client.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/elicitation.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/http.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/inprocess.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/interface.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/iter.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/oauth.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/roots.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/sampling.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/sse.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/stdio.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/tracing.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/command.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/constants.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/error.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/interface.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/logging.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/oauth.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/oauth_utils.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/sse.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go create mode 100644 vendor/github.com/mark3labs/mcp-go/client/transport/utils.go diff --git a/.github/wiki/Guide-Agents-MCP.md b/.github/wiki/Guide-Agents-MCP.md index 5bd75ccdd..fda042888 100644 --- a/.github/wiki/Guide-Agents-MCP.md +++ b/.github/wiki/Guide-Agents-MCP.md @@ -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`. diff --git a/.github/workflows/agent-e2e.yml b/.github/workflows/agent-e2e.yml new file mode 100644 index 000000000..5cdf27d35 --- /dev/null +++ b/.github/workflows/agent-e2e.yml @@ -0,0 +1,49 @@ +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" + 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: Self-test (verifier and sandbox refusals) + run: bash scripts/e2e/agent/run.sh --self-test + - name: Replay over MCP + run: bash scripts/e2e/agent/run.sh --replay --transcript "$RUNNER_TEMP/agent-e2e/replay.jsonl" + - 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 diff --git a/scripts/e2e/agent/mcp-sandbox.sh b/scripts/e2e/agent/mcp-sandbox.sh new file mode 100755 index 000000000..5fad72e58 --- /dev/null +++ b/scripts/e2e/agent/mcp-sandbox.sh @@ -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 </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 <&2; exit 2; } +fail() { printf 'agent-e2e: %s\n' "$1" >&2; exit 1; } + +scratch() { mktemp -d "${TMPDIR:-/tmp}/agent-e2e.XXXXXX"; } + +need_docker() { + command -v docker >/dev/null 2>&1 || fail "Docker is required (no docker CLI)" + v=$(docker info --format '{{.ServerVersion}}' 2>/dev/null) + [ -n "$v" ] || fail "Docker is required (the daemon is not reachable)" +} + +build_bin() { # out + (cd "$ROOT" && CGO_ENABLED=0 go build -mod=vendor -o "$1" ./cmd/nself) || fail "go build failed" +} + +abspath() { (cd "$(dirname "$1")" && printf '%s/%s' "$(pwd)" "$(basename "$1")"); } + +mode_replay() { + out="${AGENT_E2E_OUT:-${TMPDIR:-/tmp}/agent-e2e-out}" + transcript="$out/replay.jsonl" + while [ $# -gt 0 ]; do + case "$1" in + --transcript) transcript="${2:-}"; shift 2 ;; + *) usage ;; + esac + done + need_docker + mkdir -p "$(dirname "$transcript")" || fail "cannot create $(dirname "$transcript")" + transcript=$(abspath "$transcript") + tmp=$(scratch); bin="$tmp/nself" + build_bin "$bin" + (cd "$ROOT" && INTEGRATION=1 NSELF_V15=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ + $GOTEST -timeout 60m -v -run '^TestAgentReplay$' "$PKG") + rc=$? + rm -rf "$tmp" + [ $rc -eq 0 ] || fail "replay failed; transcript: $transcript" + printf 'agent-e2e: replay passed; transcript: %s\n' "$transcript" +} + +prod_probe() { # dir label + [ -n "${NSELF_PROD_PROBE_HOST:-}" ] || fail "--prod-probe needs NSELF_PROD_PROBE_HOST (never committed)" + ssh -o BatchMode=yes "$NSELF_PROD_PROBE_HOST" \ + 'docker ps -q | xargs -r docker inspect --format "{{.Name}} {{.Id}} {{.State.StartedAt}} {{.RestartCount}}" | sort' \ + > "$1/$2.txt" || fail "prod probe ($2) failed" +} + +mode_live() { + transcript="" probe="" + while [ $# -gt 0 ]; do + case "$1" in + --transcript) transcript="${2:-}"; shift 2 ;; + --prod-probe) probe="${2:-}"; shift 2 ;; + *) usage ;; + esac + done + [ -n "$transcript" ] || usage + need_docker + command -v claude >/dev/null 2>&1 || fail "claude is not on PATH" + mkdir -p "$(dirname "$transcript")" || fail "cannot create $(dirname "$transcript")" + transcript=$(abspath "$transcript") + if [ -n "$probe" ]; then mkdir -p "$probe" && prod_probe "$probe" before; fi + tmp=$(scratch); bin="$tmp/nself" + build_bin "$bin" + (cd "$ROOT" && INTEGRATION=1 NSELF_V15=1 AGENT_E2E_LIVE=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ + $GOTEST -timeout 90m -v -run '^TestAgentLive$' "$PKG") + rc=$? + rm -rf "$tmp" + if [ -n "$probe" ]; then + prod_probe "$probe" after + diff "$probe/before.txt" "$probe/after.txt" > "$probe/diff.txt" || fail "production changed during the live run: $probe/diff.txt" + fi + [ $rc -eq 0 ] || fail "live run failed; transcript: $transcript" +} + +mode_verify() { + [ $# -eq 1 ] || usage + [ -f "$1" ] || fail "no transcript at $1" + f=$(abspath "$1") + (cd "$ROOT" && AGENT_E2E_TRANSCRIPT="$f" $GOTEST -run '^TestVerifyTranscriptFile$' "$PKG") || fail "transcript $1 does not verify" +} + +expect_refusal() { # name want-substring sandbox-args... + name="$1"; want="$2"; shift 2 + err=$("$@" 2>&1 >/dev/null); rc=$? + [ $rc -eq 1 ] || fail "self-test $name: want exit 1, got $rc ($err)" + case "$err" in *"$want"*) ;; *) fail "self-test $name: refusal does not name $want: $err" ;; esac + printf 'agent-e2e: self-test %s: refused as expected\n' "$name" +} + +mode_self_test() { + (cd "$ROOT" && $GOTEST -run '^(TestVerifyFixtures|TestReplayFile)$' "$PKG") || fail "unit tests failed" + tmp=$(scratch) + op="$tmp/operator"; mkdir -p "$op/.ssh" "$tmp/project" + mk_home() { d="$tmp/$1"; mkdir -p "$d/.ssh"; printf 'Host 127.0.0.1\n IdentitiesOnly yes\n' > "$d/.ssh/config"; printf '%s' "$d"; } + base="--project $tmp/project --operator-home $op" + h=$(mk_home clean) + # shellcheck disable=SC2086 + env -u SSH_AUTH_SOCK bash "$SANDBOX" --home "$h" $base -- true || fail "self-test clean: a clean sandbox was refused" + printf 'agent-e2e: self-test clean: accepted\n' + h=$(mk_home foreign); printf 'Host sim\n HostName 192.0.2.10\n' >> "$h/.ssh/config" + # shellcheck disable=SC2086 + expect_refusal foreign-host 192.0.2.10 env -u SSH_AUTH_SOCK bash "$SANDBOX" --home "$h" $base -- true + h=$(mk_home inventory); printf 'environments:\n prod:\n servers:\n - host: deploy@db.example.com:22\n' > "$tmp/inv.yaml" + # shellcheck disable=SC2086 + expect_refusal foreign-inventory db.example.com env -u SSH_AUTH_SOCK bash "$SANDBOX" --home "$h" $base --inventory "$tmp/inv.yaml" -- true + h=$(mk_home agent) + # shellcheck disable=SC2086 + expect_refusal ssh-auth-sock SSH_AUTH_SOCK env SSH_AUTH_SOCK="$tmp/agent.sock" bash "$SANDBOX" --home "$h" $base -- true + h=$(mk_home link); ln -s "$op/.ssh" "$h/operator-ssh" + # shellcheck disable=SC2086 + expect_refusal operator-ssh "$h/operator-ssh" env -u SSH_AUTH_SOCK bash "$SANDBOX" --home "$h" $base -- true + rm -rf "$tmp" + printf 'agent-e2e: self-test passed\n' +} + +mode_tools_list() { + [ $# -ge 1 ] || usage + bin=$(abspath "$1"); shift + envs="" + while [ $# -gt 0 ]; do + case "$1" in + --env) envs="${envs:+$envs,}${2:-}"; shift 2 ;; + *) usage ;; + esac + done + [ -x "$bin" ] || fail "$bin is not an executable" + tmp=$(scratch) + (cd "$ROOT" && AGENT_E2E_BIN="$bin" AGENT_E2E_TOOLS_OUT="$tmp/tools.json" AGENT_E2E_ENV="$envs" \ + $GOTEST -run '^TestToolsList$' "$PKG" >&2) || { rm -rf "$tmp"; fail "tools/list failed"; } + cat "$tmp/tools.json" + rm -rf "$tmp" +} + +[ $# -ge 1 ] || usage +mode="$1"; shift +case "$mode" in + --replay) mode_replay "$@" ;; + --live) mode_live "$@" ;; + --verify-transcript) mode_verify "$@" ;; + --self-test) mode_self_test "$@" ;; + --tools-list) mode_tools_list "$@" ;; + -h|--help) usage ;; + *) usage ;; +esac diff --git a/test/e2e/agent/README.md b/test/e2e/agent/README.md new file mode 100644 index 000000000..72cafd56b --- /dev/null +++ b/test/e2e/agent/README.md @@ -0,0 +1,55 @@ +# Agent E2E harness + +The MCP-only agent end-to-end proof (P7-SHIP-02, SHIP Epic D7). An agent runs a whole nSelf project through the `nself` MCP server and nothing else. The harness checks every step from a transcript. + +## Layout + +| File | Role | +|---|---| +| `agent_test.go` | Tests: verifier fixtures, replay file, transcript check, tools/list, the replay | +| `live_test.go` | Live mode: a headless `claude -p` session with only the nself MCP server | +| `flow.go` | Replay client (mcp-go `client`): one `tools/call` per step, confirmation round trips | +| `harness.go` | Sim hosts and harness actions (fixture, git commit, service stop, probes) | +| `sandbox.go` | Sandbox HOME, inventory v2 file, the `mcp-sandbox.sh` argv | +| `transcript.go` | Transcript JSONL writer and reader | +| `verify.go`, `verify_legs.go` | Transcript rules | +| `testdata/replay.jsonl` | The replayed agent session | +| `testdata/live-prompt.md` | The live-session prompt | +| `testdata/good.jsonl`, `testdata/bad/*.jsonl` | Verifier fixtures | +| `testdata/sim/*.sh` | Recording `docker` and `nself` stubs for the sim hosts | + +Scripts: `scripts/e2e/agent/run.sh` (all modes) and `scripts/e2e/agent/mcp-sandbox.sh` (sealed server start). Workflow: `.github/workflows/agent-e2e.yml`. + +## Run it + +```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 +bash scripts/e2e/agent/run.sh --tools-list ./nself --env NSELF_V15=1 +bash scripts/e2e/agent/run.sh --live --transcript /tmp/agent-e2e/live.jsonl [--prod-probe DIR] +``` + +`--replay` and `--live` need Docker. Linux runs are the evidence; macOS runs only the live session. + +## Sandbox + +`mcp-sandbox.sh` starts the server with `env -i` (PATH, HOME, TMPDIR, NSELF_V15 and a fixed list of extras). HOME is a temp dir with only the sim key (generated per run by `sdk/go/simharness`, never committed), an ssh config and known hosts. The script refuses to start when SSH_AUTH_SOCK is set, when HOME links to or names the operator's `~/.ssh`, or when HOME, the inventory or the ssh config names a host other than `127.0.0.1` or `localhost`. + +## Transcript + +One JSON object per line: `{seq, time, role, kind, tool, arguments, envelope, is_error, note}`. Role is `agent`, `server` or `harness`. Kind is `tools/list`, `tools/call`, `result`, `action` or `probe`. Confirmation tokens and secret arguments are written as `[REDACTED]`. + +The verifier fails a transcript that has a non-MCP agent step, a missing D7 step, steps out of order, a promote apply before its dry run, CI evidence before the run wait, a `tools/call` result that is not a v1 envelope, or a failed recovery or gate leg. + +## Known gaps (found while building P7-SHIP-02) + +The replay drives the real product and stops at the first failing step. These gaps stop it today: + +1. `nself mcp` in v1.5 will not start outside a project (`resolveProjectDir`), so an MCP-only agent cannot reach `init`. The harness writes an empty `.env` marker and the agent calls `nself_init` with `force`. Owner: P7-SURF. +2. `nself_build` on a fresh project fails with E451: the gate runs `--plan`, the plan needs no confirmation, the gate then runs `--plan-id`, and a build that generates secrets cannot bind a plan id. The agent never sees a plan to confirm. Owner: P7-SURF-25 / P7-LIVE-03. +3. MCP tools are built once at server start, so `nself_ci` and its sub-tools do not appear in `tools/list` after `nself_add ci` in the same session. The ci plugin's machine surface (P7-CI-62) is also not merged yet. Owner: P7-SURF-03, P7-CI-62. +4. `nself_deploy_promote` accepts at most one argument (the registry names one arg `source target`), so `["qa", "prod"]` is rejected. Owner: P7-SURF-15 / P7-DEPL-15. +5. Document invocations stop at 300 s (`invoke.DefaultTimeout`) and `nself mcp` has no `--timeout` flag, so a cold `start` that pulls every image may time out. + +The sim hosts get recording `docker` and `nself` stubs, as the deploy-sim tests do; whether `deploy qa` and `deploy promote` run end to end against them is not proven until the gaps above are closed. diff --git a/test/e2e/agent/agent_test.go b/test/e2e/agent/agent_test.go new file mode 100644 index 000000000..529d08860 --- /dev/null +++ b/test/e2e/agent/agent_test.go @@ -0,0 +1,236 @@ +//go:build e2eagent + +package agent + +// Agent E2E harness tests (P7-SHIP-02, SHIP Epic D7). Run through +// scripts/e2e/agent/run.sh; each test reads its inputs from AGENT_E2E_* env. +// +// TestVerifyFixtures, TestReplayFile unit tests, always run +// TestVerifyTranscriptFile AGENT_E2E_TRANSCRIPT= +// TestToolsList AGENT_E2E_BIN, AGENT_E2E_TOOLS_OUT[, AGENT_E2E_ENV] +// TestAgentReplay AGENT_E2E_BIN, INTEGRATION=1, Docker[, AGENT_E2E_TRANSCRIPT_OUT] + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + + "github.com/nself-org/cli/internal/controlplane" + "github.com/nself-org/cli/sdk/go/v2/remote" +) + +func TestVerifyFixtures(t *testing.T) { + good, err := ReadTranscript(filepath.Join("testdata", "good.jsonl")) + if err != nil { + t.Fatal(err) + } + if err := Verify(good); err != nil { + t.Fatalf("good fixture: %v", err) + } + for name, want := range map[string]string{ + "non-mcp": "non-MCP tool use", + "missing-step": `missing step "db migrate up"`, + "promote-order": "promote apply before its dry-run", + "bad-envelope": "fails the v1 schema", + "evidence-before-wait": "ci evidence before ci runs wait", + } { + recs, err := ReadTranscript(filepath.Join("testdata", "bad", name+".jsonl")) + if err != nil { + t.Fatal(err) + } + if err := Verify(recs); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("bad/%s: want an error containing %q, got %v", name, want, err) + } + } +} + +func TestReplayFile(t *testing.T) { + steps, err := LoadReplay(filepath.Join("testdata", "replay.jsonl")) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, s := range steps { + if seen[s.ID] { + t.Errorf("duplicate step id %q", s.ID) + } + seen[s.ID] = true + if s.Role == RoleAgent && s.Kind != KindToolsList && !strings.HasPrefix(s.Tool, "nself_") { + t.Errorf("step %s: agent tool %q is not an nself MCP tool", s.ID, s.Tool) + } + if s.Role == RoleHarness && s.Action == "" { + t.Errorf("step %s: harness step without an action", s.ID) + } + } +} + +func TestVerifyTranscriptFile(t *testing.T) { + path := os.Getenv("AGENT_E2E_TRANSCRIPT") + if path == "" { + t.Skip("AGENT_E2E_TRANSCRIPT is not set") + } + recs, err := ReadTranscript(path) + if err != nil { + t.Fatal(err) + } + if err := Verify(recs); err != nil { + t.Fatalf("%s:\n%v", path, err) + } +} + +// startClient starts and initialises an MCP client over the sandboxed server. +func startClient(t *testing.T, box *Sandbox, bin, operatorHome string, env ...string) *client.Client { + t.Helper() + t.Setenv("SSH_AUTH_SOCK", "") + prog, args := box.ServerCommand(bin, operatorHome, env...) + c, err := client.NewStdioMCPClient(prog, nil, args...) + if err != nil { + t.Fatalf("start nself mcp in the sandbox: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + req := mcp.InitializeRequest{} + req.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + req.Params.ClientInfo = mcp.Implementation{Name: "nself-agent-e2e", Version: "1"} + if _, err := c.Initialize(context.Background(), req); err != nil { + t.Fatalf("initialize: %v", err) + } + return c +} + +func TestToolsList(t *testing.T) { + bin, out := os.Getenv("AGENT_E2E_BIN"), os.Getenv("AGENT_E2E_TOOLS_OUT") + if bin == "" || out == "" { + t.Skip("AGENT_E2E_BIN and AGENT_E2E_TOOLS_OUT are not set") + } + box, err := NewSandbox(t.TempDir()) + if err != nil { + t.Fatal(err) + } + // `nself mcp` needs a project marker to start (see README, gap 1). + if err := os.WriteFile(filepath.Join(box.Project, ".env"), nil, 0o600); err != nil { + t.Fatal(err) + } + var env []string + if e := os.Getenv("AGENT_E2E_ENV"); e != "" { + env = strings.Split(e, ",") + } + c := startClient(t, box, bin, os.Getenv("HOME"), env...) + res, err := c.ListTools(context.Background(), mcp.ListToolsRequest{}) + if err != nil { + t.Fatal(err) + } + type row struct { + Name string `json:"name"` + InputSchema json.RawMessage `json:"inputSchema"` + } + rows := make([]row, 0, len(res.Tools)) + for _, tool := range res.Tools { + b, err := json.Marshal(tool) + if err != nil { + t.Fatal(err) + } + var r row + if err := json.Unmarshal(b, &r); err != nil { + t.Fatal(err) + } + rows = append(rows, r) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) + b, err := json.MarshalIndent(rows, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(out, append(b, '\n'), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestAgentReplay(t *testing.T) { + bin := os.Getenv("AGENT_E2E_BIN") + if bin == "" { + t.Skip("AGENT_E2E_BIN is not set") + } + operatorHome := os.Getenv("HOME") + if out, err := exec.Command("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}").Output(); err == nil { + t.Setenv("DOCKER_HOST", strings.TrimSpace(string(out))) + } + fleet, hosts := StartSim(t) + box, err := NewSandbox(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, step := range []func() error{ + func() error { return box.InstallKey(fleet.KeyPath()) }, + box.WriteSSHConfig, + func() error { return box.WriteInventory(hosts...) }, + } { + if err := step(); err != nil { + t.Fatal(err) + } + } + t.Setenv("HOME", box.Home) + trustHosts(t, hosts) + tpath := os.Getenv("AGENT_E2E_TRANSCRIPT_OUT") + if tpath == "" { + tpath = filepath.Join(t.TempDir(), "replay.jsonl") + } + w, err := NewWriter(tpath) + if err != nil { + t.Fatal(err) + } + defer w.Close() + h := &SimHarness{T: t, Fleet: fleet, Box: box, Hosts: hosts} + ctx := context.Background() + if _, err := run(ctx, box.Project, "git", "init", "-q"); err != nil { + t.Fatal(err) + } + if err := h.Do(ctx, Step{ID: "fixture", Action: "fixture-marker", Note: "project marker so nself mcp can start (README gap 1)"}, w); err != nil { + t.Fatal(err) + } + steps, err := LoadReplay(filepath.Join("testdata", "replay.jsonl")) + if err != nil { + t.Fatal(err) + } + c := startClient(t, box, bin, operatorHome) + r := &Runner{C: c, H: h, W: w} + if err := r.Run(ctx, steps); err != nil { + t.Fatalf("replay stopped (transcript %s): %v", tpath, err) + } + if err := Verify(w.Records); err != nil { + t.Fatalf("transcript %s does not verify:\n%v", tpath, err) + } +} + +// trustHosts records each sim host's ed25519 key in the sandbox HOME (the +// HOME must already be the sandbox's), as the deploy-sim tests do. +func trustHosts(t *testing.T, hosts []SimHost) { + t.Helper() + for _, h := range hosts { + scan, err := exec.Command("ssh-keyscan", "-T", "5", "-t", "ed25519", "-p", strconv.Itoa(h.Port), "--", LoopbackHost).Output() + if err != nil { + t.Fatalf("ssh-keyscan %s: %v", h.Server, err) + } + fp := "" + for _, line := range strings.Split(string(scan), "\n") { + if got, err := remote.Fingerprint(line); err == nil { + fp = got + break + } + } + if fp == "" { + t.Fatalf("no ed25519 key from %s", h.Server) + } + if err := controlplane.TrustHostKey(context.Background(), h.Host(), fp); err != nil { + t.Fatalf("trust %s: %v", h.Server, err) + } + } +} diff --git a/test/e2e/agent/flow.go b/test/e2e/agent/flow.go new file mode 100644 index 000000000..1c7ffbef1 --- /dev/null +++ b/test/e2e/agent/flow.go @@ -0,0 +1,279 @@ +//go:build e2eagent + +package agent + +// Purpose: the replay client: follow testdata/replay.jsonl against `nself mcp` +// with the mcp-go client, one tools/call per agent step, confirmation tokens +// taken from the previous result, every request, result and harness action +// written to the transcript. +// Inputs: the replay steps, a started *client.Client, a Harness for the +// harness-role steps, a transcript Writer. +// Outputs: nil when every step met its expectation; else the first failure. +// Constraints: a failed step stops the run (never retried silently); the only +// repeat is a step's declared `until` loop over a server-side wait; no sleeps; +// the harness never forges a confirmation token. + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" +) + +// Step is one line of testdata/replay.jsonl. +type Step struct { + ID string `json:"id"` + Role string `json:"role"` // agent | harness + // Kind is tools/call (default) or tools/list for agent steps. + Kind string `json:"kind,omitempty"` + Tool string `json:"tool,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` + // Confirm: the first result must be a confirmation; the call is repeated + // once with its confirm token. + Confirm bool `json:"confirm,omitempty"` + // Expect: ok (default) | confirmation | unhealthy | state-ok | evidence-pass. + Expect string `json:"expect,omitempty"` + // Capture maps a name to a data field of the final result; later steps + // use it as ${name} in their arguments. + Capture map[string]string `json:"capture,omitempty"` + // Until "terminal" repeats the call while data.state is not terminal + // (the server waits; MaxRepeats bounds it). + Until string `json:"until,omitempty"` + MaxRepeats int `json:"max_repeats,omitempty"` + // Require lists tool names a tools/list step must see. + Require []string `json:"require,omitempty"` + // Action names a harness action (harness.go). + Action string `json:"action,omitempty"` + Note string `json:"note,omitempty"` +} + +// LoadReplay parses a replay JSONL file. +func LoadReplay(path string) ([]Step, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var steps []Step + sc := bufio.NewScanner(f) + for n := 1; sc.Scan(); n++ { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + var s Step + dec := json.NewDecoder(strings.NewReader(line)) + dec.DisallowUnknownFields() + if err := dec.Decode(&s); err != nil { + return nil, fmt.Errorf("replay line %d: %w", n, err) + } + if s.Role != RoleAgent && s.Role != RoleHarness { + return nil, fmt.Errorf("replay line %d: role %q", n, s.Role) + } + steps = append(steps, s) + } + return steps, sc.Err() +} + +// Harness runs the harness-role steps (sim fleet, docker, git). +type Harness interface { + Do(ctx context.Context, s Step, w *Writer) error +} + +// Runner drives one replay. +type Runner struct { + C *client.Client + H Harness + W *Writer + captured map[string]string +} + +// Run executes steps in order and stops at the first failure. +func (r *Runner) Run(ctx context.Context, steps []Step) error { + r.captured = map[string]string{} + for _, s := range steps { + var err error + switch { + case s.Role == RoleHarness: + err = r.H.Do(ctx, s, r.W) + case s.Kind == KindToolsList: + err = r.list(ctx, s) + default: + err = r.call(ctx, s) + } + if err != nil { + return fmt.Errorf("step %s: %w", s.ID, err) + } + } + return nil +} + +func (r *Runner) list(ctx context.Context, s Step) error { + if _, err := r.W.Write(Record{Role: RoleAgent, Kind: KindToolsList, Note: s.ID}); err != nil { + return err + } + res, err := r.C.ListTools(ctx, mcp.ListToolsRequest{}) + if err != nil { + return err + } + names := make([]string, 0, len(res.Tools)) + for _, t := range res.Tools { + names = append(names, t.Name) + } + sort.Strings(names) + body, _ := json.Marshal(map[string]any{"tools": names}) + if _, err := r.W.Write(Record{Role: RoleServer, Kind: KindResult, Envelope: body, Note: s.ID}); err != nil { + return err + } + if !hasAll(names, s.Require...) { + return fmt.Errorf("tools/list lacks one of %v (P7-SURF-03 / P7-CI-62 gap)", s.Require) + } + return nil +} + +// outcome is one tools/call result as the runner sees it. +type outcome struct { + isError bool + env json.RawMessage + data map[string]any +} + +func (r *Runner) invoke(ctx context.Context, tool string, args map[string]any, id string) (outcome, error) { + if _, err := r.W.Write(Record{Role: RoleAgent, Kind: KindToolsCall, Tool: tool, Arguments: args, Note: id}); err != nil { + return outcome{}, err + } + wire := map[string]any{} + for k, v := range args { + if k != "_secret" { + wire[k] = v + } + } + req := mcp.CallToolRequest{} + req.Params.Name, req.Params.Arguments = tool, wire + res, err := r.C.CallTool(ctx, req) + if err != nil { + return outcome{}, fmt.Errorf("tools/call %s: %w", tool, err) + } + o := outcome{isError: res.IsError} + for _, c := range res.Content { + if tc, ok := c.(mcp.TextContent); ok { + o.env = json.RawMessage(tc.Text) + break + } + } + var doc struct { + Data map[string]any `json:"data"` + } + _ = json.Unmarshal(o.env, &doc) + o.data = doc.Data + _, err = r.W.Write(Record{Role: RoleServer, Kind: KindResult, Tool: tool, Envelope: compact(o.env), IsError: o.isError, Note: id}) + return o, err +} + +// compact re-encodes b on one line; text that is not JSON becomes a JSON +// string (the verifier then reports it as a failed envelope). +func compact(b json.RawMessage) json.RawMessage { + if len(b) == 0 { + return nil + } + var v any + if json.Unmarshal(b, &v) != nil { + s, _ := json.Marshal(string(b)) + return s + } + out, _ := json.Marshal(v) + return out +} + +func (r *Runner) call(ctx context.Context, s Step) error { + args := r.expand(s.Arguments) + o, err := r.invoke(ctx, s.Tool, args, s.ID) + if err != nil { + return err + } + if s.Confirm { + if o.isError || o.data["confirmation_required"] != true { + return fmt.Errorf("%s: wanted a confirmation request, got %s", s.Tool, o.env) + } + token, _ := o.data["confirm"].(string) + args = withConfirm(args, token) + if o, err = r.invoke(ctx, s.Tool, args, s.ID+" (confirmed)"); err != nil { + return err + } + } + for n := 1; s.Until == "terminal" && !o.isError && !terminal(o.data); n++ { + if n >= max(s.MaxRepeats, 1) { + return fmt.Errorf("%s: still not terminal after %d calls", s.Tool, n) + } + if o, err = r.invoke(ctx, s.Tool, args, fmt.Sprintf("%s (repeat %d)", s.ID, n)); err != nil { + return err + } + } + if err := expect(s, o); err != nil { + return err + } + for name, field := range s.Capture { + v, ok := o.data[field].(string) + if !ok || v == "" { + return fmt.Errorf("%s: result has no data.%s to capture", s.Tool, field) + } + r.captured[name] = v + } + return nil +} + +func withConfirm(args map[string]any, token string) map[string]any { + out := map[string]any{} + for k, v := range args { + out[k] = v + } + out["confirm"] = token + return out +} + +func expect(s Step, o outcome) error { + state, _ := o.data["state"].(string) + switch s.Expect { + case "", "ok": + if o.isError { + return fmt.Errorf("%s returned an error: %s", s.Tool, o.env) + } + case "confirmation": + if o.isError || o.data["confirmation_required"] != true { + return fmt.Errorf("%s: wanted a confirmation request, got %s", s.Tool, o.env) + } + case "unhealthy": + if !o.isError && state == "ok" { + return fmt.Errorf("%s: wanted an unhealthy report, got %s", s.Tool, o.env) + } + case "state-ok": + if o.isError || state != "ok" { + return fmt.Errorf("%s: wanted data.state ok, got %s", s.Tool, o.env) + } + case "evidence-pass": + if o.isError || o.data["result"] != "pass" { + return fmt.Errorf("%s: wanted evidence result pass, got %s", s.Tool, o.env) + } + default: + return fmt.Errorf("unknown expect %q", s.Expect) + } + return nil +} + +// expand replaces ${name} in string values with captured values. +func (r *Runner) expand(v map[string]any) map[string]any { + b, _ := json.Marshal(v) + s := string(b) + for k, val := range r.captured { + s = strings.ReplaceAll(s, "${"+k+"}", val) + } + out := map[string]any{} + _ = json.Unmarshal([]byte(s), &out) + return out +} diff --git a/test/e2e/agent/harness.go b/test/e2e/agent/harness.go new file mode 100644 index 000000000..ea6c558c7 --- /dev/null +++ b/test/e2e/agent/harness.go @@ -0,0 +1,174 @@ +//go:build e2eagent + +package agent + +// Purpose: the harness side of the replay: the two loopback sim hosts (qa tier +// local-servers, prod tier prod) and every harness action (fixture, git +// commit, service stop, docker volume and image-digest probes). Each action is +// a `role: harness` transcript record; none of them is an agent step. +// Inputs: a *testing.T, the Sandbox, the replay's harness Steps. +// Outputs: transcript records; an error when an action or a probe fails. +// Constraints: docker and git run from argv slices (no host shell); the sim +// hosts get recording docker/nself stubs, as deploy-sim.yml's tests do; the +// harness never calls an nself command on the agent's behalf. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/nself-org/cli/internal/controlplane" + "github.com/nself-org/cli/sdk/go/v2/simharness" +) + +// SimRemotePath is the project path on each sim host. +const SimRemotePath = "/tmp/nself-agent-e2e/app" + +// SimHarness owns the fleet and the sandbox for one replay. +type SimHarness struct { + T *testing.T + Fleet *simharness.Fleet + Box *Sandbox + Hosts []SimHost + // before holds the image sets of the sim hosts captured by digest-before. + before map[string]string +} + +// StartSim starts qa-1 and prod-1, installs the stubs and returns the hosts. +func StartSim(t *testing.T) (*simharness.Fleet, []SimHost) { + t.Helper() + fleet := simharness.Start(t, simharness.Config{Nodes: []simharness.NodeSpec{{Name: "qa-1"}, {Name: "prod-1"}}}) + hosts := []SimHost{ + {Env: "qa", Server: "qa-1", Tier: controlplane.TierLocalServers}, + {Env: "prod", Server: "prod-1", Tier: controlplane.TierProd}, + } + for i := range hosts { + n := fleet.Node(t, hosts[i].Server) + hosts[i].User, hosts[i].Port, hosts[i].RemotePath = n.User, n.Port, SimRemotePath + fleet.Exec(t, n.Name, "sh", "-c", "command -v rsync >/dev/null || apk add --no-cache rsync >/dev/null") + for _, name := range []string{"docker", "nself"} { + fleet.CopyTo(t, n.Name, filepath.Join("testdata", "sim", name+".sh"), "/usr/local/bin/"+name) + fleet.Exec(t, n.Name, "chmod", "755", "/usr/local/bin/"+name) + } + fleet.Exec(t, n.Name, "sh", "-c", "mkdir -p "+SimRemotePath+" /tmp/agent-e2e && chown -R "+n.User+" /tmp/nself-agent-e2e /tmp/agent-e2e && chmod 1777 /tmp") + } + return fleet, hosts +} + +// Do runs one harness step and writes its record. +func (h *SimHarness) Do(ctx context.Context, s Step, w *Writer) error { + note := s.Action + if s.Note != "" { + note += ": " + s.Note + } + switch s.Action { + case "fixture-marker": + // Gap: `nself mcp` (v1.5) will not start outside a project, so an + // MCP-only agent cannot reach `init` without a project marker. + if err := os.WriteFile(filepath.Join(h.Box.Project, ".env"), nil, 0o600); err != nil { + return err + } + case "git-commit": + if _, err := run(ctx, h.Box.Project, "git", "add", "-A"); err != nil { + return err + } + if _, err := run(ctx, h.Box.Project, "git", "-c", "user.name=agent-e2e", "-c", "user.email=agent-e2e@localhost", + "commit", "-q", "--no-gpg-sign", "-m", "agent e2e: "+s.ID); err != nil { + return err + } + case NoteStopService: + name, err := h.serviceContainer(ctx) + if err != nil { + return err + } + if _, err := run(ctx, "", "docker", "stop", name); err != nil { + return err + } + note = NoteStopService + ": " + name + case "volumes-before", "volumes-after": + out, err := run(ctx, "", "docker", "volume", "ls", "--format", "{{.Name}}") + if err != nil { + return err + } + vols := strings.Fields(out) + sort.Strings(vols) + body, _ := json.Marshal(map[string]any{"volumes": vols}) + probe := map[string]string{"volumes-before": NoteVolumesBefore, "volumes-after": NoteVolumesAfter}[s.Action] + _, err = w.Write(Record{Role: RoleHarness, Kind: KindProbe, Envelope: body, Note: probe}) + return err + case "digest-before", "digest-compare": + return h.digests(s.Action, w) + default: + return fmt.Errorf("unknown harness action %q", s.Action) + } + _, err := w.Write(Record{Role: RoleHarness, Kind: KindAction, Note: note}) + return err +} + +// digests reads the image set each sim host runs (the docker stub's record of +// `compose up`) and, on compare, wants prod == qa as captured before the +// promote and qa unchanged (DEPL D16 compare). +func (h *SimHarness) digests(action string, w *Writer) error { + now := map[string]string{} + for _, host := range h.Hosts { + now[host.Env] = h.Fleet.Exec(h.T, host.Server, "sh", "-c", "cat /tmp/agent-e2e/running 2>/dev/null || true") + } + body, _ := json.Marshal(now) + if _, err := w.Write(Record{Role: RoleHarness, Kind: KindProbe, Envelope: body, Note: action}); err != nil { + return err + } + if action == "digest-before" { + h.before = now + return nil + } + switch { + case strings.TrimSpace(h.before["qa"]) == "": + return fmt.Errorf("qa ran no images before the promote") + case now["qa"] != h.before["qa"]: + return fmt.Errorf("the promote changed the qa host") + case now["prod"] != h.before["qa"]: + return fmt.Errorf("prod does not run the qa images by digest:\nqa: %s\nprod: %s", h.before["qa"], now["prod"]) + } + return nil +} + +// serviceContainer picks one running service container of the project +// (hasura when present, else the first by name). +func (h *SimHarness) serviceContainer(ctx context.Context) (string, error) { + dir, err := filepath.EvalSymlinks(h.Box.Project) + if err != nil { + return "", err + } + out, err := run(ctx, "", "docker", "ps", "--format", "{{.Names}}", + "--filter", "label=com.docker.compose.project.working_dir="+dir) + if err != nil { + return "", err + } + names := strings.Fields(out) + sort.Strings(names) + if len(names) == 0 { + return "", fmt.Errorf("no running container of the project %s", dir) + } + for _, n := range names { + if strings.Contains(n, "hasura") { + return n, nil + } + } + return names[0], nil +} + +func run(ctx context.Context, dir, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, out) + } + return string(out), nil +} diff --git a/test/e2e/agent/live_test.go b/test/e2e/agent/live_test.go new file mode 100644 index 000000000..040981966 --- /dev/null +++ b/test/e2e/agent/live_test.go @@ -0,0 +1,171 @@ +//go:build e2eagent + +package agent + +// Live mode (run.sh --live; the session itself is P7-SHIP-13): a headless +// Claude Code session whose only tools are the sandboxed nself MCP server. +// The stream-json output is converted to the transcript format and verified +// like a replay. Needs AGENT_E2E_LIVE=1, AGENT_E2E_BIN, INTEGRATION=1, Docker +// and a `claude` login on the operator machine (not a production credential). + +import ( + "bufio" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// builtinTools are disallowed so the MCP server is the agent's only surface. +var builtinTools = []string{"Bash", "Edit", "MultiEdit", "Write", "Read", "Glob", "Grep", "LS", + "WebFetch", "WebSearch", "NotebookEdit", "Task", "TodoWrite"} + +func TestAgentLive(t *testing.T) { + bin := os.Getenv("AGENT_E2E_BIN") + if os.Getenv("AGENT_E2E_LIVE") != "1" || bin == "" { + t.Skip("AGENT_E2E_LIVE=1 and AGENT_E2E_BIN are not set") + } + operatorHome := os.Getenv("HOME") + claude, err := exec.LookPath("claude") + if err != nil { + t.Fatal("claude is not on PATH") + } + fleet, hosts := StartSim(t) + box, err := NewSandbox(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := box.InstallKey(fleet.KeyPath()); err != nil { + t.Fatal(err) + } + if err := box.WriteSSHConfig(); err != nil { + t.Fatal(err) + } + if err := box.WriteInventory(hosts...); err != nil { + t.Fatal(err) + } + tpath := os.Getenv("AGENT_E2E_TRANSCRIPT_OUT") + if tpath == "" { + tpath = filepath.Join(t.TempDir(), "live.jsonl") + } + w, err := NewWriter(tpath) + if err != nil { + t.Fatal(err) + } + defer w.Close() + h := &SimHarness{T: t, Fleet: fleet, Box: box, Hosts: hosts} + ctx := context.Background() + if _, err := run(ctx, box.Project, "git", "init", "-q"); err != nil { + t.Fatal(err) + } + if err := h.Do(ctx, Step{ID: "fixture", Action: "fixture-marker"}, w); err != nil { + t.Fatal(err) + } + // The trust step needs the sandbox HOME; claude keeps the operator's. + t.Setenv("HOME", box.Home) + trustHosts(t, hosts) + t.Setenv("HOME", operatorHome) + t.Setenv("SSH_AUTH_SOCK", "") + + prog, args := box.ServerCommand(bin, operatorHome) + cfg, _ := json.Marshal(map[string]any{"mcpServers": map[string]any{"nself": map[string]any{"command": prog, "args": args}}}) + cfgPath := filepath.Join(t.TempDir(), "mcp.json") + if err := os.WriteFile(cfgPath, cfg, 0o600); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join("testdata", "live-prompt.md")) + if err != nil { + t.Fatal(err) + } + cmd := exec.CommandContext(ctx, claude, "-p", string(prompt), "--output-format", "stream-json", "--verbose", + "--strict-mcp-config", "--mcp-config", cfgPath, + "--disallowedTools", strings.Join(builtinTools, ","), "--allowedTools", "mcp__nself") + cmd.Dir = t.TempDir() + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + if err := convertStream(out, w); err != nil { + t.Fatal(err) + } + if err := cmd.Wait(); err != nil { + t.Fatalf("claude: %v", err) + } + if err := Verify(w.Records); err != nil { + t.Fatalf("live transcript %s does not verify:\n%v", tpath, err) + } +} + +// streamLine is the part of a stream-json line the converter reads. +type streamLine struct { + Type string `json:"type"` + Message struct { + Content []struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Input map[string]any `json:"input"` + ToolUseID string `json:"tool_use_id"` + Content json.RawMessage `json:"content"` + IsError bool `json:"is_error"` + } `json:"content"` + } `json:"message"` +} + +// convertStream writes one agent record per tool_use (an MCP tool of the +// nself server keeps its bare name; anything else keeps its own name, which +// the verifier rejects) and one server record per tool_result. +func convertStream(r interface{ Read([]byte) (int, error) }, w *Writer) error { + tools := map[string]string{} + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 1<<20), 64<<20) + for sc.Scan() { + var l streamLine + if json.Unmarshal(sc.Bytes(), &l) != nil { + continue + } + for _, c := range l.Message.Content { + switch c.Type { + case "tool_use": + name := strings.TrimPrefix(c.Name, "mcp__nself__") + tools[c.ID] = name + if _, err := w.Write(Record{Role: RoleAgent, Kind: KindToolsCall, Tool: name, Arguments: c.Input}); err != nil { + return err + } + case "tool_result": + if _, err := w.Write(Record{Role: RoleServer, Kind: KindResult, Tool: tools[c.ToolUseID], + Envelope: resultText(c.Content), IsError: c.IsError}); err != nil { + return err + } + } + } + } + return sc.Err() +} + +// resultText returns the envelope JSON of a tool_result content value. +func resultText(raw json.RawMessage) json.RawMessage { + var s string + if json.Unmarshal(raw, &s) == nil { + return compact(json.RawMessage(s)) + } + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if json.Unmarshal(raw, &parts) == nil { + for _, p := range parts { + if p.Type == "text" { + return compact(json.RawMessage(p.Text)) + } + } + } + return raw +} diff --git a/test/e2e/agent/sandbox.go b/test/e2e/agent/sandbox.go new file mode 100644 index 000000000..da57368a2 --- /dev/null +++ b/test/e2e/agent/sandbox.go @@ -0,0 +1,135 @@ +//go:build e2eagent + +package agent + +// Purpose: the agent sandbox (SHIP Epic D7): a temp HOME holding only the sim +// hosts' ssh config and key, a fixture project with the inventory v2 file, and +// the argv that starts `nself mcp` through scripts/e2e/agent/mcp-sandbox.sh. +// Inputs: a temp root, the sim fleet's generated key and host addresses. +// Outputs: a Sandbox with Home, Project and the server command. +// Constraints: no credential is ever written into the repo: the only key is +// the one simharness generates per fleet at run time, copied into the temp +// HOME. Hosts are 127.0.0.1 only (published sim ports); mcp-sandbox.sh +// refuses anything else, an SSH agent socket, and a HOME that reaches the +// operator's ~/.ssh. + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/nself-org/cli/internal/controlplane" +) + +// SimKeyEnv names the variable whose value is the sim key path; the inventory +// refers to it through ssh_key_ref (secrets are never stored inline). +const SimKeyEnv = "NSELF_E2E_SIM_KEY" + +// LoopbackHost is the only host the sandbox may name. +const LoopbackHost = "127.0.0.1" + +// SimHost is one sim host as the inventory sees it. +type SimHost struct { + Env, Server, User string + Port int + Tier controlplane.Tier + RemotePath string +} + +// Host returns the inventory host spec user@127.0.0.1:port. +func (h SimHost) Host() string { return fmt.Sprintf("%s@%s:%d", h.User, LoopbackHost, h.Port) } + +// Sandbox is the agent's whole world: HOME and the project directory. +type Sandbox struct { + Root, Home, Project string + // KeyPath is the copy of the fleet key inside Home (empty until InstallKey). + KeyPath string +} + +// NewSandbox makes root/home (with an empty .ssh) and root/project. +func NewSandbox(root string) (*Sandbox, error) { + s := &Sandbox{Root: root, Home: filepath.Join(root, "home"), Project: filepath.Join(root, "project")} + for _, d := range []string{filepath.Join(s.Home, ".ssh"), s.Project} { + if err := os.MkdirAll(d, 0o700); err != nil { + return nil, err + } + } + return s, nil +} + +// InstallKey copies the fleet's generated private key into HOME/.ssh (0600). +func (s *Sandbox) InstallKey(fleetKey string) error { + b, err := os.ReadFile(fleetKey) + if err != nil { + return err + } + s.KeyPath = filepath.Join(s.Home, ".ssh", "id_sim") + return os.WriteFile(s.KeyPath, b, 0o600) +} + +// WriteSSHConfig points every loopback connection at the sim key and the +// sandbox known_hosts file, and nothing else. +func (s *Sandbox) WriteSSHConfig() error { + if s.KeyPath == "" { + return fmt.Errorf("sandbox: InstallKey first") + } + known := filepath.Join(s.Home, ".ssh", "known_hosts") + cfg := strings.Join([]string{ + "Host " + LoopbackHost, + " IdentityFile " + s.KeyPath, + " IdentitiesOnly yes", + " IdentityAgent none", + " UserKnownHostsFile " + known, + "", + }, "\n") + return os.WriteFile(filepath.Join(s.Home, ".ssh", "config"), []byte(cfg), 0o600) +} + +// WriteInventory writes the inventory v2 file: local, plus one remote env per +// sim host (qa tier local-servers, prod tier prod). +func (s *Sandbox) WriteInventory(hosts ...SimHost) error { + inv := &controlplane.Inventory{SchemaVersion: 2, Environments: map[string]controlplane.Environment{ + "local": {Name: "local", Kind: "local", Tier: controlplane.TierLocal}, + }} + for _, h := range hosts { + inv.Environments[h.Env] = controlplane.Environment{Name: h.Env, Kind: "remote", Tier: h.Tier, + Servers: []controlplane.Server{{Name: h.Server, Role: controlplane.RoleApp, Host: h.Host(), + RemotePath: h.RemotePath, Primary: true, SSHKeyRef: SimKeyEnv}}} + } + return controlplane.Write(s.Project, inv) +} + +// InventoryPath is where controlplane.Write puts the inventory. +func (s *Sandbox) InventoryPath() string { + return filepath.Join(s.Project, ".nself", "control-plane.yaml") +} + +// SandboxScript returns the absolute path of scripts/e2e/agent/mcp-sandbox.sh. +func SandboxScript() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "e2e", "agent", "mcp-sandbox.sh") +} + +// ServerCommand returns the program and argv that start bin's `nself mcp` in +// the sandbox. operatorHome is the real HOME the script must keep out of +// reach; extraEnv entries are K=V and pass the script's own allowlist. +func (s *Sandbox) ServerCommand(bin, operatorHome string, extraEnv ...string) (string, []string) { + args := []string{SandboxScript(), "--home", s.Home, "--project", s.Project, + "--operator-home", operatorHome} + if _, err := os.Stat(s.InventoryPath()); err == nil { + args = append(args, "--inventory", s.InventoryPath()) + } + if s.KeyPath != "" { + args = append(args, "--env", SimKeyEnv+"="+s.KeyPath) + } + if dh := os.Getenv("DOCKER_HOST"); strings.HasPrefix(dh, "unix://") { + args = append(args, "--env", "DOCKER_HOST="+dh) + } + for _, e := range extraEnv { + args = append(args, "--env", e) + } + args = append(args, "--", bin, "mcp") + return "bash", args +} diff --git a/test/e2e/agent/testdata/bad/bad-envelope.jsonl b/test/e2e/agent/testdata/bad/bad-envelope.jsonl new file mode 100644 index 000000000..9e5341c34 --- /dev/null +++ b/test/e2e/agent/testdata/bad/bad-envelope.jsonl @@ -0,0 +1,52 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_db_migrate_up","arguments":{},"is_error":false,"note":"migrate"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_db_migrate_up","envelope":{"schema_version":"1","command":"db migrate up","data":{"applied":0}},"is_error":false,"note":"migrate"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":51,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":52,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/bad/evidence-before-wait.jsonl b/test/e2e/agent/testdata/bad/evidence-before-wait.jsonl new file mode 100644 index 000000000..828700fac --- /dev/null +++ b/test/e2e/agent/testdata/bad/evidence-before-wait.jsonl @@ -0,0 +1,52 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_db_migrate_up","arguments":{},"is_error":false,"note":"migrate"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_db_migrate_up","envelope":{"schema_version":"1","command":"db migrate up","data":{"applied":0}},"is_error":false,"note":"migrate"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":51,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":52,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/bad/missing-step.jsonl b/test/e2e/agent/testdata/bad/missing-step.jsonl new file mode 100644 index 000000000..77727cc59 --- /dev/null +++ b/test/e2e/agent/testdata/bad/missing-step.jsonl @@ -0,0 +1,50 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/bad/non-mcp.jsonl b/test/e2e/agent/testdata/bad/non-mcp.jsonl new file mode 100644 index 000000000..cbea9eb81 --- /dev/null +++ b/test/e2e/agent/testdata/bad/non-mcp.jsonl @@ -0,0 +1,53 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"Bash","arguments":{"command":"nself db migrate up"},"is_error":false,"note":"shell"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_db_migrate_up","arguments":{},"is_error":false,"note":"migrate"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_db_migrate_up","envelope":{"schema_version":"1","command":"db migrate up","data":{"applied":0}},"is_error":false,"note":"migrate"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":51,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":52,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":53,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/bad/promote-order.jsonl b/test/e2e/agent/testdata/bad/promote-order.jsonl new file mode 100644 index 000000000..ed0943e91 --- /dev/null +++ b/test/e2e/agent/testdata/bad/promote-order.jsonl @@ -0,0 +1,52 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_db_migrate_up","arguments":{},"is_error":false,"note":"migrate"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_db_migrate_up","envelope":{"schema_version":"1","command":"db migrate up","data":{"applied":0}},"is_error":false,"note":"migrate"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":51,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":52,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/good.jsonl b/test/e2e/agent/testdata/good.jsonl new file mode 100644 index 000000000..b771e6f9b --- /dev/null +++ b/test/e2e/agent/testdata/good.jsonl @@ -0,0 +1,52 @@ +{"seq":1,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}},"is_error":false,"note":"init"} +{"seq":2,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_init","envelope":{"schema_version":"1","command":"init","data":{"env":"dev"}},"is_error":false,"note":"init"} +{"seq":3,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"git-commit"} +{"seq":4,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{},"is_error":false,"note":"build"} +{"seq":5,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"confirmation_required":true,"kind":"plan","confirm":"[REDACTED]","side_effect":"write","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"build"} +{"seq":6,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_build","arguments":{"confirm":"[REDACTED]"},"is_error":false,"note":"build (confirmed)"} +{"seq":7,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_build","envelope":{"schema_version":"1","command":"build","data":{"plan_id":"p","applied":true}},"is_error":false,"note":"build (confirmed)"} +{"seq":8,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"start"} +{"seq":9,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"start"} +{"seq":10,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-healthy"} +{"seq":11,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-healthy"} +{"seq":12,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"action","is_error":false,"note":"stop-service: app-hasura-1"} +{"seq":13,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-unhealthy"} +{"seq":14,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"unhealthy"}},"is_error":false,"note":"status-unhealthy"} +{"seq":15,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_start","arguments":{},"is_error":false,"note":"recover"} +{"seq":16,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_start","envelope":{"schema_version":"1","command":"start","data":{"state":"ok"}},"is_error":false,"note":"recover"} +{"seq":17,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_status","arguments":{},"is_error":false,"note":"status-recovered"} +{"seq":18,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_status","envelope":{"schema_version":"1","command":"status","data":{"state":"ok"}},"is_error":false,"note":"status-recovered"} +{"seq":19,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes before"} +{"seq":20,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_reset","arguments":{},"is_error":false,"note":"reset-unconfirmed"} +{"seq":21,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_reset","envelope":{"schema_version":"1","command":"reset","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"destructive","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"reset-unconfirmed"} +{"seq":22,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"volumes":["app_db"]},"is_error":false,"note":"volumes after"} +{"seq":23,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_db_migrate_up","arguments":{},"is_error":false,"note":"migrate"} +{"seq":24,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_db_migrate_up","envelope":{"schema_version":"1","command":"db migrate up","data":{"applied":0}},"is_error":false,"note":"migrate"} +{"seq":25,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"]},"is_error":false,"note":"add-ci"} +{"seq":26,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"add-ci"} +{"seq":27,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_add","arguments":{"args":["ci"],"confirm":"[REDACTED]"},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":28,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_add","envelope":{"schema_version":"1","command":"add","data":{"installed":["ci"]}},"is_error":false,"note":"add-ci (confirmed)"} +{"seq":29,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/list","is_error":false,"note":"discover-ci"} +{"seq":30,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","envelope":{"tools":["nself_add","nself_ci","nself_ci_evidence","nself_ci_runs_wait"]},"is_error":false,"note":"discover-ci"} +{"seq":31,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true}},"is_error":false,"note":"ci-detach"} +{"seq":32,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"ci-detach"} +{"seq":33,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci","arguments":{"flags":{"detach":true},"confirm":"[REDACTED]"},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":34,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci","envelope":{"schema_version":"1","command":"ci","data":{"schema":"ci.run-handle/v1","run_id":"r1","state":"queued"}},"is_error":false,"note":"ci-detach (confirmed)"} +{"seq":35,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_runs_wait","arguments":{"args":["r1"]},"is_error":false,"note":"ci-wait"} +{"seq":36,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_runs_wait","envelope":{"schema_version":"1","command":"ci runs wait","data":{"run_id":"r1","state":"passed"}},"is_error":false,"note":"ci-wait"} +{"seq":37,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_ci_evidence","arguments":{"args":["r1"]},"is_error":false,"note":"ci-evidence"} +{"seq":38,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_ci_evidence","envelope":{"schema_version":"1","command":"ci evidence","data":{"schema":"ci.evidence/v1","run_id":"r1","result":"pass"}},"is_error":false,"note":"ci-evidence"} +{"seq":39,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"]},"is_error":false,"note":"deploy-qa"} +{"seq":40,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"deploy-qa"} +{"seq":41,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy","arguments":{"args":["qa"],"confirm":"[REDACTED]"},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":42,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy","envelope":{"schema_version":"1","command":"deploy","data":{"env":"qa","state":"ok"}},"is_error":false,"note":"deploy-qa (confirmed)"} +{"seq":43,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":""},"is_error":false,"note":"digest-before"} +{"seq":44,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"is_error":false,"note":"promote-dry-run"} +{"seq":45,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote-dry-run"} +{"seq":46,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true},"confirm":"[REDACTED]"},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":47,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":true}},"is_error":false,"note":"promote-dry-run (confirmed)"} +{"seq":48,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"is_error":false,"note":"promote"} +{"seq":49,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"confirmation_required":true,"kind":"request","confirm":"[REDACTED]","side_effect":"remote","reason":"repeat the identical request with confirm","plan":null}},"is_error":false,"note":"promote"} +{"seq":50,"time":"2026-10-29T12:00:00Z","role":"agent","kind":"tools/call","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"confirm":"[REDACTED]"},"is_error":false,"note":"promote (confirmed)"} +{"seq":51,"time":"2026-10-29T12:00:00Z","role":"server","kind":"result","tool":"nself_deploy_promote","envelope":{"schema_version":"1","command":"deploy promote","data":{"dry_run":false}},"is_error":false,"note":"promote (confirmed)"} +{"seq":52,"time":"2026-10-29T12:00:00Z","role":"harness","kind":"probe","envelope":{"qa":"img@sha256:1","prod":"img@sha256:1"},"is_error":false,"note":"digest-compare"} diff --git a/test/e2e/agent/testdata/live-prompt.md b/test/e2e/agent/testdata/live-prompt.md new file mode 100644 index 000000000..c3d952f81 --- /dev/null +++ b/test/e2e/agent/testdata/live-prompt.md @@ -0,0 +1,29 @@ +# Agent E2E live session (P7-SHIP-13) + +You operate an nSelf project only through the `nself` MCP server. You have no +shell, file or web tools. The project directory and its inventory are already +prepared: environment `qa` (tier local-servers) and `prod` (tier prod) are two +loopback sim hosts. + +Do these steps, in order, using MCP tools only: + +1. Initialise the project (`nself_init`). +2. Build it: ask for the plan first, read it, then confirm it with the plan id + the server returned. +3. Start the stack and check `nself_status` until `data.state` is `ok`. +4. If `nself_status` reports a service unhealthy, recover it with + `nself_start` and check status again. +5. Call `nself_reset` once without a confirmation, read the confirmation + request it returns, and do NOT confirm it. +6. Apply database migrations (`nself_db_migrate_up`). +7. Install the ci plugin (`nself_add` with `ci`), list the tools again, then + run CI the async way: `nself_ci` with `detach: true`, then + `nself_ci_runs_wait` with the returned run id until it is terminal, then + `nself_ci_evidence`. The evidence result must be `pass`. +8. Deploy to `qa`. +9. Promote `qa` to `prod` with `dry-run` first, read the plan, then run the + confirmed promote. + +Rules: a remote or destructive call returns a confirmation request; repeat the +identical call with its `confirm` value only when the step above says so. +Never invent a confirmation value. Stop and report if a step fails. diff --git a/test/e2e/agent/testdata/replay.jsonl b/test/e2e/agent/testdata/replay.jsonl new file mode 100644 index 000000000..919040fc4 --- /dev/null +++ b/test/e2e/agent/testdata/replay.jsonl @@ -0,0 +1,30 @@ +// P7-SHIP-02 replay (SHIP Epic D7). One step per line; `//` lines are comments. +// Agent steps are MCP tools/call (or tools/list) to the sandboxed `nself mcp` +// with v1.5 tool names. Harness steps are `role: harness` records, never agent +// actions. A step with "confirm": true expects a confirmation request first and +// repeats the identical call with the server's token (plan id or nonce). +{"id":"init","role":"agent","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}}} +{"id":"commit-after-init","role":"harness","action":"git-commit"} +{"id":"build","role":"agent","tool":"nself_build","confirm":true} +{"id":"start","role":"agent","tool":"nself_start"} +{"id":"status-healthy","role":"agent","tool":"nself_status","expect":"state-ok"} +// Leg: failure recovery. +{"id":"stop-one-service","role":"harness","action":"stop-service"} +{"id":"status-unhealthy","role":"agent","tool":"nself_status","expect":"unhealthy"} +{"id":"recover","role":"agent","tool":"nself_start"} +{"id":"status-recovered","role":"agent","tool":"nself_status","expect":"state-ok"} +// Leg: destructive gate (reset is never confirmed). +{"id":"gate-volumes-before","role":"harness","action":"volumes-before"} +{"id":"reset-unconfirmed","role":"agent","tool":"nself_reset","expect":"confirmation"} +{"id":"gate-volumes-after","role":"harness","action":"volumes-after"} +{"id":"migrate","role":"agent","tool":"nself_db_migrate_up"} +{"id":"add-ci","role":"agent","tool":"nself_add","arguments":{"args":["ci"]},"confirm":true} +{"id":"discover-ci","role":"agent","kind":"tools/list","require":["nself_ci","nself_ci_runs_wait","nself_ci_evidence"]} +{"id":"ci-detach","role":"agent","tool":"nself_ci","arguments":{"flags":{"detach":true}},"confirm":true,"capture":{"run_id":"run_id"}} +{"id":"ci-wait","role":"agent","tool":"nself_ci_runs_wait","arguments":{"args":["${run_id}"]},"until":"terminal","max_repeats":30} +{"id":"ci-evidence","role":"agent","tool":"nself_ci_evidence","arguments":{"args":["${run_id}"]},"expect":"evidence-pass"} +{"id":"deploy-qa","role":"agent","tool":"nself_deploy","arguments":{"args":["qa"]},"confirm":true} +{"id":"digest-before","role":"harness","action":"digest-before"} +{"id":"promote-dry-run","role":"agent","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"confirm":true} +{"id":"promote","role":"agent","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"]},"confirm":true} +{"id":"digest-compare","role":"harness","action":"digest-compare"} diff --git a/test/e2e/agent/testdata/sim/docker.sh b/test/e2e/agent/testdata/sim/docker.sh new file mode 100755 index 000000000..c43e41283 --- /dev/null +++ b/test/e2e/agent/testdata/sim/docker.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Recording docker stub for the agent E2E sim hosts (P7-SHIP-02). It mirrors +# the stubs of internal/controlplane/sim: the sim hosts are sshd containers +# without a Docker engine, so `compose up` records the image set of the +# shipped docker-compose.yml in /tmp/agent-e2e/running and the read commands +# answer from that record. Image digests are derived from the reference, so +# two hosts running the same references report the same digests. +S=/tmp/agent-e2e +mkdir -p "$S" +echo "$*" >> "$S/docker.log" +images() { awk '/^ image:/{print $2}' docker-compose.yml 2>/dev/null | sort -u; } +digest() { printf '%s' "$1" | sha256sum | cut -c1-64; } +case "$*" in + *"config --services"*) awk '/^services:/{s=1;next} s&&/^ [A-Za-z0-9_.-]+:/{sub(":","",$1);print $1} s&&/^[^ ]/{s=0}' docker-compose.yml ;; + *"config --format json"*) printf '{"services":{}}' ;; + *"up -d --dry-run"*) : ;; + *" up "*|*" up") for i in $(images); do printf '%s@sha256:%s\n' "$i" "$(digest "$i")"; done > "$S/running" ;; + *" images --format json"*) + printf '['; sep='' + while IFS= read -r line; do + [ -n "$line" ] || continue + ref=${line%@*}; d=${line#*@} + printf '%s{"ContainerName":"app-%s-1","Repository":"%s","Tag":"%s","ID":"%s"}' "$sep" "$(digest "$ref" | cut -c1-8)" "${ref%:*}" "${ref##*:}" "$d"; sep=',' + done < "$S/running" 2>/dev/null + printf ']' ;; + *"ps -a --format json"*|*"ps --all --format json"*) printf '[]' ;; + *"image inspect --format"*) + for last; do :; done + ref=$(grep -F "$last" "$S/running" 2>/dev/null | head -1); ref=${ref%@*} + printf '["%s@%s"]|linux/amd64' "${ref%:*}" "$last" ;; + "network inspect"*) exit 1 ;; +esac +exit 0 diff --git a/test/e2e/agent/testdata/sim/nself.sh b/test/e2e/agent/testdata/sim/nself.sh new file mode 100755 index 000000000..518424293 --- /dev/null +++ b/test/e2e/agent/testdata/sim/nself.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Recording nself stub for the agent E2E sim hosts (P7-SHIP-02); the same +# shape as the promote stub of internal/controlplane/sim: health passes, +# migrations and backups are recorded under /tmp/agent-e2e. +S=/tmp/agent-e2e +mkdir -p "$S" +echo "$*" >> "$S/nself.log" +case "$*" in + "doctor"*) exit 0 ;; + "db migrate up"*) printf migrated > "$S/db-fp" ;; + "backup create --tag "*) cp "$S/db-fp" "$S/db-backup" 2>/dev/null; echo "$4" > "$S/tag" ;; + "backup list"*) t=$(cat "$S/tag" 2>/dev/null); printf '[{"id":"sim_%s","tag":"%s","date":"2026-10-29T12:00:00Z"}]' "$t" "$t" ;; + "backup verify "*) exit 0 ;; + "backup restore "*) cp "$S/db-backup" "$S/db-fp" 2>/dev/null ;; +esac +exit 0 diff --git a/test/e2e/agent/transcript.go b/test/e2e/agent/transcript.go new file mode 100644 index 000000000..27f610216 --- /dev/null +++ b/test/e2e/agent/transcript.go @@ -0,0 +1,186 @@ +//go:build e2eagent + +package agent + +// Purpose: the agent E2E transcript: one JSON object per line recording every +// agent MCP request, every server result and every harness action. +// Inputs: Records from the replay client (flow.go) or a live session. +// Outputs: a JSONL file; ReadTranscript parses one back for verify.go. +// Constraints: the field set is the ticket's transcript format +// {seq, time, role, kind, tool, arguments, envelope, is_error, note}; secret +// argument values and confirmation tokens are never written (they become +// Redacted), so a transcript is safe to upload as a CI artifact. + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "sync" + "time" +) + +// Roles and kinds of a transcript record. +const ( + RoleAgent = "agent" + RoleHarness = "harness" + RoleServer = "server" + + KindToolsList = "tools/list" + KindToolsCall = "tools/call" + KindResult = "result" + KindAction = "action" + KindProbe = "probe" +) + +// Redacted replaces secret values and confirmation tokens in a transcript. +const Redacted = "[REDACTED]" + +// Record is one transcript line. +type Record struct { + Seq int `json:"seq"` + Time string `json:"time"` + Role string `json:"role"` + Kind string `json:"kind"` + Tool string `json:"tool,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` + Envelope json.RawMessage `json:"envelope,omitempty"` + IsError bool `json:"is_error"` + Note string `json:"note,omitempty"` +} + +// Writer appends records to a transcript file; safe for concurrent use. +type Writer struct { + mu sync.Mutex + f *os.File + seq int + // Records keeps every written record so the test can verify in process. + Records []Record +} + +// NewWriter creates (truncates) path with mode 0600. +func NewWriter(path string) (*Writer, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + return &Writer{f: f}, nil +} + +// Write stamps seq and time, redacts r and appends it as one line. +func (w *Writer) Write(r Record) (Record, error) { + w.mu.Lock() + defer w.mu.Unlock() + w.seq++ + r.Seq = w.seq + r.Time = time.Now().UTC().Format(time.RFC3339Nano) + r.Arguments = redactArgs(r.Arguments) + r.Envelope = redactEnvelope(r.Envelope) + b, err := json.Marshal(r) + if err != nil { + return r, err + } + if _, err := w.f.Write(append(b, '\n')); err != nil { + return r, err + } + w.Records = append(w.Records, r) + return r, nil +} + +// Close closes the file. +func (w *Writer) Close() error { return w.f.Close() } + +// redactArgs copies args with the confirm token and every flag or arg named +// in the private "_secret" list replaced by Redacted. The list itself is a +// replay-file annotation and is dropped. +func redactArgs(args map[string]any) map[string]any { + if args == nil { + return nil + } + out := map[string]any{} + secret := map[string]bool{} + if list, ok := args["_secret"].([]any); ok { + for _, s := range list { + if name, ok := s.(string); ok { + secret[name] = true + } + } + } + for k, v := range args { + switch { + case k == "_secret": + case k == "confirm": + out[k] = Redacted + case k == "flags": + out[k] = redactFlags(v, secret) + default: + out[k] = v + } + } + return out +} + +func redactFlags(v any, secret map[string]bool) any { + flags, ok := v.(map[string]any) + if !ok || len(secret) == 0 { + return v + } + out := map[string]any{} + for k, fv := range flags { + if secret[k] { + out[k] = Redacted + } else { + out[k] = fv + } + } + return out +} + +// redactEnvelope replaces data.confirm (a nonce or plan id) with Redacted. +func redactEnvelope(env json.RawMessage) json.RawMessage { + if len(env) == 0 { + return env + } + var doc map[string]json.RawMessage + if json.Unmarshal(env, &doc) != nil { + return env + } + var data map[string]any + if json.Unmarshal(doc["data"], &data) != nil || data["confirm"] == nil { + return env + } + data["confirm"] = Redacted + b, err := json.Marshal(data) + if err != nil { + return env + } + doc["data"] = b + out, err := json.Marshal(doc) + if err != nil { + return env + } + return out +} + +// ReadTranscript parses a JSONL transcript; a malformed line is an error. +func ReadTranscript(path string) ([]Record, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var out []Record + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 1<<20), 64<<20) + for n := 1; sc.Scan(); n++ { + if len(sc.Bytes()) == 0 { + continue + } + var r Record + if err := json.Unmarshal(sc.Bytes(), &r); err != nil { + return nil, fmt.Errorf("transcript line %d: %w", n, err) + } + out = append(out, r) + } + return out, sc.Err() +} diff --git a/test/e2e/agent/verify.go b/test/e2e/agent/verify.go new file mode 100644 index 000000000..03c5730a6 --- /dev/null +++ b/test/e2e/agent/verify.go @@ -0,0 +1,267 @@ +//go:build e2eagent + +package agent + +// Purpose: verify an agent E2E transcript against SHIP Epic D7: every agent +// record is an MCP request to the nself server, every tools/call result is a +// v1 envelope, the D7 steps all happen in order, and the two legs hold. +// Inputs: transcript Records (ReadTranscript or Writer.Records). +// Outputs: nil, or an error listing every violation found. +// Constraints: pure function of the records; no retries, no network. + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/nself-org/cli/schemas" +) + +// call is an agent tools/call paired with the server result that follows it. +type call struct { + seq int + tool string + args map[string]any + result *Record +} + +func (c call) flags() map[string]any { m, _ := c.args["flags"].(map[string]any); return m } + +func (c call) arg0() string { + if list, ok := c.args["args"].([]any); ok && len(list) > 0 { + s, _ := list[0].(string) + return s + } + return "" +} + +func (c call) confirmed() bool { _, ok := c.args["confirm"]; return ok } + +func (c call) data() map[string]any { + if c.result == nil { + return nil + } + var doc struct { + Data map[string]any `json:"data"` + } + _ = json.Unmarshal(c.result.Envelope, &doc) + return doc.Data +} + +func (c call) ok() bool { return c.result != nil && !c.result.IsError } + +func (c call) confirmation() bool { + d := c.data() + return c.ok() && d != nil && d["confirmation_required"] == true +} + +// milestone is one D7 step; first is the seq of its first matching call. +type milestone struct { + name string + match func(call) bool +} + +func toolIs(name string) func(call) bool { return func(c call) bool { return c.tool == name } } + +// Milestones are the D7 flow steps in their required order. +var Milestones = []milestone{ + {"init", func(c call) bool { return toolIs("nself_init")(c) && c.ok() }}, + {"build plan", func(c call) bool { return toolIs("nself_build")(c) && !c.confirmed() && c.confirmation() }}, + {"build confirm", func(c call) bool { return toolIs("nself_build")(c) && c.confirmed() && c.ok() }}, + {"start", func(c call) bool { return toolIs("nself_start")(c) && c.ok() }}, + {"db migrate up", func(c call) bool { return toolIs("nself_db_migrate_up")(c) && c.ok() }}, + {"add ci", func(c call) bool { return toolIs("nself_add")(c) && c.arg0() == "ci" && c.confirmed() && c.ok() }}, + {"ci detach", func(c call) bool { + return toolIs("nself_ci")(c) && c.flags()["detach"] == true && c.confirmed() && c.ok() + }}, + {"ci runs wait", func(c call) bool { return toolIs("nself_ci_runs_wait")(c) && c.ok() && terminal(c.data()) }}, + {"ci evidence", func(c call) bool { return toolIs("nself_ci_evidence")(c) && c.ok() && c.data()["result"] == "pass" }}, + {"deploy qa", func(c call) bool { return toolIs("nself_deploy")(c) && c.arg0() == "qa" && c.confirmed() && c.ok() }}, + {"promote dry-run", func(c call) bool { return isPromote(c) && c.flags()["dry-run"] == true && c.ok() }}, + {"promote", func(c call) bool { return isPromote(c) && c.flags()["dry-run"] != true && c.confirmed() && c.ok() }}, +} + +func isPromote(c call) bool { return c.tool == "nself_deploy_promote" } + +func terminal(d map[string]any) bool { + s, _ := d["state"].(string) + return s == "passed" || s == "failed" || s == "cancelled" +} + +// Verify returns every D7 violation in recs, joined; nil when the transcript holds. +func Verify(recs []Record) error { + var errs []error + calls := pair(recs) + errs = append(errs, checkMCPOnly(recs)...) + errs = append(errs, checkEnvelopes(recs)...) + first := map[string]int{} + for _, m := range Milestones { + for _, c := range calls { + if m.match(c) { + first[m.name] = c.seq + break + } + } + } + prev, prevName := 0, "" + for _, m := range Milestones { + seq, ok := first[m.name] + if !ok { + errs = append(errs, fmt.Errorf("missing step %q", m.name)) + continue + } + if seq < prev { + errs = append(errs, fmt.Errorf("step %q (seq %d) runs before %q (seq %d)", m.name, seq, prevName, prev)) + } + prev, prevName = seq, m.name + } + errs = append(errs, checkPromoteOrder(calls)...) + errs = append(errs, checkEvidenceOrder(calls)...) + errs = append(errs, checkCIDiscovery(recs, first)...) + errs = append(errs, checkRecoveryLeg(recs, calls)...) + errs = append(errs, checkGateLeg(recs, calls)...) + return errors.Join(errs...) +} + +// pair links each agent tools/call with the next server result for its tool. +func pair(recs []Record) []call { + var out []call + for i, r := range recs { + if r.Role != RoleAgent || r.Kind != KindToolsCall { + continue + } + c := call{seq: r.Seq, tool: r.Tool, args: r.Arguments} + for j := i + 1; j < len(recs); j++ { + if recs[j].Role == RoleServer && recs[j].Kind == KindResult && recs[j].Tool == r.Tool { + c.result = &recs[j] + break + } + } + out = append(out, c) + } + return out +} + +func checkMCPOnly(recs []Record) []error { + var errs []error + for _, r := range recs { + if r.Role != RoleAgent { + continue + } + if r.Kind != KindToolsCall && r.Kind != KindToolsList { + errs = append(errs, fmt.Errorf("seq %d: non-MCP tool use: agent record kind %q", r.Seq, r.Kind)) + } else if r.Kind == KindToolsCall && !strings.HasPrefix(r.Tool, "nself_") { + errs = append(errs, fmt.Errorf("seq %d: non-MCP tool use: %q is not a tool of the nself server", r.Seq, r.Tool)) + } + } + return errs +} + +var envelopeSchema = func() *jsonschema.Schema { + c := jsonschema.NewCompiler() + for _, name := range schemas.Names() { + b, _ := schemas.Lookup(name) + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(b)) + if err != nil { + continue + } + if m, ok := doc.(map[string]any); ok { + if id, ok := m["$id"].(string); ok { + _ = c.AddResource(id, doc) + } + } + } + return c.MustCompile("urn:nself:cli:schema:envelope:v1") +}() + +func checkEnvelopes(recs []Record) []error { + var errs []error + for i, r := range recs { + if r.Role != RoleServer || r.Kind != KindResult || i == 0 || !callBefore(recs, i) { + continue + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(r.Envelope)) + if err == nil { + err = envelopeSchema.Validate(doc) + } + if err != nil { + errs = append(errs, fmt.Errorf("seq %d: %s envelope fails the v1 schema: %v", r.Seq, r.Tool, err)) + } + } + return errs +} + +// callBefore reports whether the result at i answers a tools/call (tools/list +// results carry a tool-name list, not an envelope). +func callBefore(recs []Record, i int) bool { + for j := i - 1; j >= 0; j-- { + if recs[j].Role == RoleAgent { + return recs[j].Kind == KindToolsCall + } + } + return false +} + +func checkPromoteOrder(calls []call) []error { + for _, c := range calls { + if !isPromote(c) { + continue + } + if c.flags()["dry-run"] == true { + return nil + } + if c.confirmed() { + return []error{fmt.Errorf("seq %d: promote apply before its dry-run", c.seq)} + } + } + return nil +} + +func checkEvidenceOrder(calls []call) []error { + for _, c := range calls { + switch c.tool { + case "nself_ci_runs_wait": + return nil + case "nself_ci_evidence": + return []error{fmt.Errorf("seq %d: ci evidence before ci runs wait", c.seq)} + } + } + return nil +} + +// checkCIDiscovery wants a tools/list result after `add ci` naming the three ci tools. +func checkCIDiscovery(recs []Record, first map[string]int) []error { + after, ok := first["add ci"] + if !ok { + return nil + } + for _, r := range recs { + if r.Seq <= after || r.Role != RoleServer || r.Kind != KindResult || r.Tool != "" { + continue + } + var list struct { + Tools []string `json:"tools"` + } + if json.Unmarshal(r.Envelope, &list) == nil && hasAll(list.Tools, "nself_ci", "nself_ci_runs_wait", "nself_ci_evidence") { + return nil + } + } + return []error{errors.New("no tools/list after add ci shows nself_ci, nself_ci_runs_wait and nself_ci_evidence")} +} + +func hasAll(have []string, want ...string) bool { + set := map[string]bool{} + for _, h := range have { + set[h] = true + } + for _, w := range want { + if !set[w] { + return false + } + } + return true +} diff --git a/test/e2e/agent/verify_legs.go b/test/e2e/agent/verify_legs.go new file mode 100644 index 000000000..717915b8d --- /dev/null +++ b/test/e2e/agent/verify_legs.go @@ -0,0 +1,103 @@ +//go:build e2eagent + +package agent + +// Purpose: transcript checks for the two D7 legs: failure recovery and the +// destructive gate. +// Inputs: the transcript records and their paired calls (verify.go). +// Outputs: one error per violated rule. +// Constraints: harness records are matched by their note prefix, which +// flow.go writes from the constants below. + +import ( + "bytes" + "fmt" +) + +// Harness notes the leg checks look for. +const ( + NoteStopService = "stop-service" + NoteVolumesBefore = "volumes before" + NoteVolumesAfter = "volumes after" +) + +func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p } + +func stateOK(c call) bool { d := c.data(); return c.ok() && d != nil && d["state"] == "ok" } + +// checkRecoveryLeg wants, after the harness stops a service: a status call +// that reports the unhealthy state, then a successful start, then a status +// call whose data.state is ok. +func checkRecoveryLeg(recs []Record, calls []call) []error { + stopSeq := 0 + for _, r := range recs { + if r.Role == RoleHarness && hasPrefix(r.Note, NoteStopService) { + stopSeq = r.Seq + break + } + } + if stopSeq == 0 { + return []error{fmt.Errorf("missing step %q: no harness %s record", "failure recovery", NoteStopService)} + } + phase := 0 + for _, c := range calls { + if c.seq < stopSeq { + continue + } + switch { + case phase == 0 && c.tool == "nself_status": + if stateOK(c) { + return []error{fmt.Errorf("seq %d: status after the stopped service reports ok", c.seq)} + } + phase = 1 + case phase == 1 && c.tool == "nself_start" && c.ok(): + phase = 2 + case phase == 2 && c.tool == "nself_status" && stateOK(c): + return nil + } + } + return []error{fmt.Errorf("missing step %q: status, start, status ok after %s (reached phase %d)", "failure recovery", NoteStopService, phase)} +} + +// checkGateLeg wants a reset without confirm answered by a confirmation (not +// an error), never confirmed later, between two equal volume probes. +func checkGateLeg(recs []Record, calls []call) []error { + var resetSeq int + for _, c := range calls { + if c.tool != "nself_reset" { + continue + } + if c.confirmed() { + return []error{fmt.Errorf("seq %d: the destructive-gate leg confirmed reset", c.seq)} + } + if !c.confirmation() { + return []error{fmt.Errorf("seq %d: reset without confirm did not return a confirmation request", c.seq)} + } + if resetSeq == 0 { + resetSeq = c.seq + } + } + if resetSeq == 0 { + return []error{fmt.Errorf("missing step %q", "destructive gate")} + } + var before, after *Record + for i := range recs { + r := &recs[i] + if r.Role != RoleHarness || r.Kind != KindProbe { + continue + } + if r.Note == NoteVolumesBefore && r.Seq < resetSeq { + before = r + } + if r.Note == NoteVolumesAfter && r.Seq > resetSeq && after == nil { + after = r + } + } + if before == nil || after == nil { + return []error{fmt.Errorf("missing step %q: volume probes around reset", "destructive gate")} + } + if !bytes.Equal(before.Envelope, after.Envelope) { + return []error{fmt.Errorf("seq %d: docker volumes changed across the unconfirmed reset", after.Seq)} + } + return nil +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/client.go b/vendor/github.com/mark3labs/mcp-go/client/client.go new file mode 100644 index 000000000..0a415ac89 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/client.go @@ -0,0 +1,814 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "sync" + "sync/atomic" + + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/tracing" +) + +// Client implements the MCP client. +type Client struct { + transport transport.Interface + + initialized atomic.Bool + notifications []func(mcp.JSONRPCNotification) + notifyMu sync.RWMutex + requestID atomic.Int64 + clientCapabilities mcp.ClientCapabilities + serverCapabilities mcp.ServerCapabilities + protocolVersion string + samplingHandler SamplingHandler + rootsHandler RootsHandler + elicitationHandler ElicitationHandler + tracer tracing.Tracer + propagator tracing.Propagator + metaPropagator tracing.MetaPropagator +} + +// ClientOption configures a Client during construction. +type ClientOption func(*Client) + +// WithClientCapabilities sets the client capabilities for the client. +func WithClientCapabilities(capabilities mcp.ClientCapabilities) ClientOption { + return func(c *Client) { + c.clientCapabilities = capabilities + } +} + +// WithSamplingHandler sets the sampling handler for the client. +// When set, the client will declare sampling capability during initialization. +func WithSamplingHandler(handler SamplingHandler) ClientOption { + return func(c *Client) { + c.samplingHandler = handler + } +} + +// WithRootsHandler sets the roots handler for the client. +// WithRootsHandler returns a ClientOption that sets the client's RootsHandler. +// When provided, the client will declare the roots capability (ListChanged) during initialization. +func WithRootsHandler(handler RootsHandler) ClientOption { + return func(c *Client) { + c.rootsHandler = handler + } +} + +// WithElicitationHandler sets the elicitation handler for the client. +// When set, the client will declare elicitation capability during initialization. +func WithElicitationHandler(handler ElicitationHandler) ClientOption { + return func(c *Client) { + c.elicitationHandler = handler + } +} + +// WithSession assumes a MCP Session has already been initialized +func WithSession() ClientOption { + return func(c *Client) { + c.initialized.Store(true) + } +} + +// NewClient creates a new MCP client with the given transport. +// Usage: +// +// stdio := transport.NewStdio("./mcp_server", nil, "xxx") +// client, err := NewClient(stdio) +// if err != nil { +// log.Fatalf("Failed to create client: %v", err) +// } +func NewClient(transport transport.Interface, options ...ClientOption) *Client { + client := &Client{ + transport: transport, + tracer: tracing.NoopTracer(), + propagator: tracing.NoopPropagator(), + } + + for _, opt := range options { + opt(client) + } + + return client +} + +// Start initiates the connection to the server. +// Must be called before using the client. +func (c *Client) Start(ctx context.Context) error { + if c.transport == nil { + return fmt.Errorf("transport is nil") + } + + // Start is idempotent - transports handle being called multiple times + err := c.transport.Start(ctx) + if err != nil { + return err + } + + c.transport.SetNotificationHandler(func(notification mcp.JSONRPCNotification) { + c.notifyMu.RLock() + defer c.notifyMu.RUnlock() + for _, handler := range c.notifications { + handler(notification) + } + }) + + // Set up request handler for bidirectional communication (e.g., sampling) + if bidirectional, ok := c.transport.(transport.BidirectionalInterface); ok { + bidirectional.SetRequestHandler(c.handleIncomingRequest) + } + + return nil +} + +// Close shuts down the client and closes the transport. +func (c *Client) Close() error { + return c.transport.Close() +} + +// OnNotification registers a handler function to be called when notifications are received. +// Multiple handlers can be registered and will be called in the order they were added. +func (c *Client) OnNotification( + handler func(notification mcp.JSONRPCNotification), +) { + c.notifyMu.Lock() + defer c.notifyMu.Unlock() + c.notifications = append(c.notifications, handler) +} + +// OnConnectionLost registers a handler function to be called when the connection is lost. +// This is useful for handling HTTP2 idle timeout disconnections that should not be treated as errors. +func (c *Client) OnConnectionLost(handler func(error)) { + type connectionLostSetter interface { + SetConnectionLostHandler(func(error)) + } + if setter, ok := c.transport.(connectionLostSetter); ok { + setter.SetConnectionLostHandler(handler) + } +} + +// sendRequest sends a JSON-RPC request to the server and waits for a response. +// Returns the raw JSON response message or an error if the request fails. +func (c *Client) sendRequest( + ctx context.Context, + method string, + params any, + header http.Header, +) (*json.RawMessage, error) { + if !c.initialized.Load() && method != "initialize" { + return nil, fmt.Errorf("client not initialized") + } + + id := c.requestID.Add(1) + + ctx, header, span := c.startSendSpan(ctx, method, header) + + request := transport.JSONRPCRequest{ + JSONRPC: mcp.JSONRPC_VERSION, + ID: mcp.NewRequestId(id), + Method: method, + Params: params, + Header: header, + } + + response, err := c.transport.SendRequest(ctx, request) + if err != nil { + err = transport.NewError(err) + endSendSpan(span, err) + return nil, err + } + + if response.Error != nil { + err := response.Error.AsError() + endSendSpan(span, err) + return nil, err + } + + endSendSpan(span, nil) + return &response.Result, nil +} + +func outboundHeader(header http.Header, requestMethod string) http.Header { + // A typed request with Method set was decoded from an inbound JSON-RPC + // message. Its Header contains received HTTP metadata, not opt-in outbound + // headers for a new client call. + if requestMethod != "" { + return nil + } + return header +} + +// Initialize negotiates with the server. +// Must be called after Start, and before any request methods. +func (c *Client) Initialize( + ctx context.Context, + request mcp.InitializeRequest, +) (*mcp.InitializeResult, error) { + // Merge client capabilities with sampling capability if handler is configured + capabilities := request.Params.Capabilities + if c.samplingHandler != nil { + capabilities.Sampling = &mcp.SamplingCapability{} + } + if c.rootsHandler != nil { + capabilities.Roots = &struct { + ListChanged bool `json:"listChanged,omitempty"` + }{ + ListChanged: true, + } + } + // Add elicitation capability if handler is configured + if c.elicitationHandler != nil { + capabilities.Elicitation = &mcp.ElicitationCapability{} + } + + // Ensure we send a params object with all required fields + params := struct { + ProtocolVersion string `json:"protocolVersion"` + ClientInfo mcp.Implementation `json:"clientInfo"` + Capabilities mcp.ClientCapabilities `json:"capabilities"` + }{ + ProtocolVersion: request.Params.ProtocolVersion, + ClientInfo: request.Params.ClientInfo, + Capabilities: capabilities, + } + + // By default, use client supported latest protocol version if version not specified + if params.ProtocolVersion == "" { + params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + } + + response, err := c.sendRequest(ctx, "initialize", params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + var result mcp.InitializeResult + if err := json.Unmarshal(*response, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + // Validate protocol version + if !slices.Contains(mcp.ValidProtocolVersions, result.ProtocolVersion) { + return nil, mcp.UnsupportedProtocolVersionError{Version: result.ProtocolVersion} + } + + // Store serverCapabilities and protocol version + c.serverCapabilities = result.Capabilities + c.protocolVersion = result.ProtocolVersion + + // Set protocol version on HTTP transports + if httpConn, ok := c.transport.(transport.HTTPConnection); ok { + httpConn.SetProtocolVersion(result.ProtocolVersion) + } + + // Send initialized notification + notification := mcp.JSONRPCNotification{ + JSONRPC: mcp.JSONRPC_VERSION, + Notification: mcp.Notification{ + Method: string(mcp.MethodNotificationInitialized), + }, + } + + err = c.transport.SendNotification(ctx, notification) + if err != nil { + return nil, fmt.Errorf( + "failed to send initialized notification: %w", + err, + ) + } + + c.initialized.Store(true) + return &result, nil +} + +// Ping sends a ping request to verify the server is responsive. +func (c *Client) Ping(ctx context.Context) error { + _, err := c.sendRequest(ctx, string(mcp.MethodPing), nil, nil) + return err +} + +// ListResourcesByPage manually list resources by page. +func (c *Client) ListResourcesByPage( + ctx context.Context, + request mcp.ListResourcesRequest, +) (*mcp.ListResourcesResult, error) { + result, err := listByPage[mcp.ListResourcesResult](ctx, c, request.PaginatedRequest, request.Header, string(mcp.MethodResourcesList)) + if err != nil { + return nil, err + } + return result, nil +} + +// ListResources lists all resources by following paginated responses. +func (c *Client) ListResources( + ctx context.Context, + request mcp.ListResourcesRequest, +) (*mcp.ListResourcesResult, error) { + result, err := c.ListResourcesByPage(ctx, request) + if err != nil { + return nil, err + } + for result.NextCursor != "" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + request.Params.Cursor = result.NextCursor + newPageRes, err := c.ListResourcesByPage(ctx, request) + if err != nil { + return nil, err + } + result.Resources = append(result.Resources, newPageRes.Resources...) + result.NextCursor = newPageRes.NextCursor + } + } + return result, nil +} + +// ListResourceTemplatesByPage manually lists resource templates by page. +func (c *Client) ListResourceTemplatesByPage( + ctx context.Context, + request mcp.ListResourceTemplatesRequest, +) (*mcp.ListResourceTemplatesResult, error) { + result, err := listByPage[mcp.ListResourceTemplatesResult](ctx, c, request.PaginatedRequest, request.Header, string(mcp.MethodResourcesTemplatesList)) + if err != nil { + return nil, err + } + return result, nil +} + +// ListResourceTemplates lists all resource templates by following paginated responses. +func (c *Client) ListResourceTemplates( + ctx context.Context, + request mcp.ListResourceTemplatesRequest, +) (*mcp.ListResourceTemplatesResult, error) { + result, err := c.ListResourceTemplatesByPage(ctx, request) + if err != nil { + return nil, err + } + for result.NextCursor != "" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + request.Params.Cursor = result.NextCursor + newPageRes, err := c.ListResourceTemplatesByPage(ctx, request) + if err != nil { + return nil, err + } + result.ResourceTemplates = append(result.ResourceTemplates, newPageRes.ResourceTemplates...) + result.NextCursor = newPageRes.NextCursor + } + } + return result, nil +} + +// ReadResource reads the contents of a resource from the server. +func (c *Client) ReadResource( + ctx context.Context, + request mcp.ReadResourceRequest, +) (*mcp.ReadResourceResult, error) { + request.Params.Meta = c.injectMeta(ctx, request.Params.Meta) + response, err := c.sendRequest(ctx, string(mcp.MethodResourcesRead), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseReadResourceResult(response) +} + +// Subscribe subscribes to updates for a resource. +func (c *Client) Subscribe( + ctx context.Context, + request mcp.SubscribeRequest, +) error { + _, err := c.sendRequest(ctx, string(mcp.MethodResourcesSubscribe), request.Params, outboundHeader(request.Header, request.Method)) + return err +} + +// Unsubscribe removes a resource update subscription. +func (c *Client) Unsubscribe( + ctx context.Context, + request mcp.UnsubscribeRequest, +) error { + _, err := c.sendRequest(ctx, string(mcp.MethodResourcesUnsubscribe), request.Params, outboundHeader(request.Header, request.Method)) + return err +} + +// ListPromptsByPage manually lists prompts by page. +func (c *Client) ListPromptsByPage( + ctx context.Context, + request mcp.ListPromptsRequest, +) (*mcp.ListPromptsResult, error) { + result, err := listByPage[mcp.ListPromptsResult](ctx, c, request.PaginatedRequest, request.Header, string(mcp.MethodPromptsList)) + if err != nil { + return nil, err + } + return result, nil +} + +// ListPrompts lists all prompts by following paginated responses. +func (c *Client) ListPrompts( + ctx context.Context, + request mcp.ListPromptsRequest, +) (*mcp.ListPromptsResult, error) { + result, err := c.ListPromptsByPage(ctx, request) + if err != nil { + return nil, err + } + for result.NextCursor != "" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + request.Params.Cursor = result.NextCursor + newPageRes, err := c.ListPromptsByPage(ctx, request) + if err != nil { + return nil, err + } + result.Prompts = append(result.Prompts, newPageRes.Prompts...) + result.NextCursor = newPageRes.NextCursor + } + } + return result, nil +} + +// GetPrompt gets a prompt and renders it with the provided arguments. +func (c *Client) GetPrompt( + ctx context.Context, + request mcp.GetPromptRequest, +) (*mcp.GetPromptResult, error) { + request.Params.Meta = c.injectMeta(ctx, request.Params.Meta) + response, err := c.sendRequest(ctx, string(mcp.MethodPromptsGet), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseGetPromptResult(response) +} + +// ListToolsByPage manually lists tools by page. +func (c *Client) ListToolsByPage( + ctx context.Context, + request mcp.ListToolsRequest, +) (*mcp.ListToolsResult, error) { + result, err := listByPage[mcp.ListToolsResult](ctx, c, request.PaginatedRequest, request.Header, string(mcp.MethodToolsList)) + if err != nil { + return nil, err + } + return result, nil +} + +// ListTools lists all tools by following paginated responses. +func (c *Client) ListTools( + ctx context.Context, + request mcp.ListToolsRequest, +) (*mcp.ListToolsResult, error) { + result, err := c.ListToolsByPage(ctx, request) + if err != nil { + return nil, err + } + for result.NextCursor != "" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + request.Params.Cursor = result.NextCursor + newPageRes, err := c.ListToolsByPage(ctx, request) + if err != nil { + return nil, err + } + result.Tools = append(result.Tools, newPageRes.Tools...) + result.NextCursor = newPageRes.NextCursor + } + } + return result, nil +} + +// CallTool invokes a tool on the server. +func (c *Client) CallTool( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + request.Params.Meta = c.injectMeta(ctx, request.Params.Meta) + response, err := c.sendRequest(ctx, string(mcp.MethodToolsCall), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseCallToolResult(response) +} + +// SetLevel sets the server logging level. +func (c *Client) SetLevel( + ctx context.Context, + request mcp.SetLevelRequest, +) error { + _, err := c.sendRequest(ctx, "logging/setLevel", request.Params, outboundHeader(request.Header, request.Method)) + return err +} + +// Complete requests completion suggestions from the server. +func (c *Client) Complete( + ctx context.Context, + request mcp.CompleteRequest, +) (*mcp.CompleteResult, error) { + response, err := c.sendRequest(ctx, "completion/complete", request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + var result mcp.CompleteResult + if err := json.Unmarshal(*response, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &result, nil +} + +// RootListChanges sends a roots list-changed notification to the server. +func (c *Client) RootListChanges( + ctx context.Context, +) error { + // Send root list changes notification + notification := mcp.JSONRPCNotification{ + JSONRPC: mcp.JSONRPC_VERSION, + Notification: mcp.Notification{ + Method: mcp.MethodNotificationRootsListChanged, + }, + } + + err := c.transport.SendNotification(ctx, notification) + if err != nil { + return fmt.Errorf( + "failed to send root list change notification: %w", + err, + ) + } + return nil +} + +// handleIncomingRequest processes incoming requests from the server. +// This is the main entry point for server-to-client requests like sampling and elicitation. +func (c *Client) handleIncomingRequest(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) { + switch request.Method { + case string(mcp.MethodSamplingCreateMessage): + return c.handleSamplingRequestTransport(ctx, request) + case string(mcp.MethodElicitationCreate): + return c.handleElicitationRequestTransport(ctx, request) + case string(mcp.MethodPing): + return c.handlePingRequestTransport(ctx, request) + case string(mcp.MethodListRoots): + return c.handleListRootsRequestTransport(ctx, request) + default: + return nil, fmt.Errorf("unsupported request method: %s", request.Method) + } +} + +// handleSamplingRequestTransport handles sampling requests at the transport level. +func (c *Client) handleSamplingRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) { + if c.samplingHandler == nil { + return nil, fmt.Errorf("no sampling handler configured") + } + + // Parse the request parameters + var params mcp.CreateMessageParams + if request.Params != nil { + paramsBytes, err := json.Marshal(request.Params) + if err != nil { + return nil, fmt.Errorf("failed to marshal params: %w", err) + } + if err := json.Unmarshal(paramsBytes, ¶ms); err != nil { + return nil, fmt.Errorf("failed to unmarshal params: %w", err) + } + } + + // Fix content parsing - HTTP transport unmarshals TextContent as map[string]any + // Use the helper function to properly handle content from different transports + for i := range params.Messages { + if contentMap, ok := params.Messages[i].Content.(map[string]any); ok { + // Parse the content map into a proper Content type + content, err := mcp.ParseContent(contentMap) + if err != nil { + return nil, fmt.Errorf("failed to parse content for message %d: %w", i, err) + } + params.Messages[i].Content = content + } + } + + // Create the MCP request + mcpRequest := mcp.CreateMessageRequest{ + Request: mcp.Request{ + Method: string(mcp.MethodSamplingCreateMessage), + }, + CreateMessageParams: params, + } + + // Call the sampling handler + result, err := c.samplingHandler.CreateMessage(ctx, mcpRequest) + if err != nil { + return nil, err + } + + // Marshal the result + resultBytes, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + // Create the transport response + response := transport.NewJSONRPCResultResponse(request.ID, json.RawMessage(resultBytes)) + + return response, nil +} + +// handleListRootsRequestTransport handles list roots requests at the transport level. +func (c *Client) handleListRootsRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) { + if c.rootsHandler == nil { + return nil, fmt.Errorf("no roots handler configured") + } + + // Create the MCP request + mcpRequest := mcp.ListRootsRequest{ + Request: mcp.Request{ + Method: string(mcp.MethodListRoots), + }, + } + + // Call the list roots handler + result, err := c.rootsHandler.ListRoots(ctx, mcpRequest) + if err != nil { + return nil, err + } + + // Marshal the result + resultBytes, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + // Create the transport response + response := transport.NewJSONRPCResultResponse(request.ID, json.RawMessage(resultBytes)) + + return response, nil +} + +// handleElicitationRequestTransport handles elicitation requests at the transport level. +func (c *Client) handleElicitationRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) { + if c.elicitationHandler == nil { + return nil, fmt.Errorf("no elicitation handler configured") + } + + // Parse the request parameters + var params mcp.ElicitationParams + if request.Params != nil { + paramsBytes, err := json.Marshal(request.Params) + if err != nil { + return nil, fmt.Errorf("failed to marshal params: %w", err) + } + if err := json.Unmarshal(paramsBytes, ¶ms); err != nil { + return nil, fmt.Errorf("failed to unmarshal params: %w", err) + } + } + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("invalid elicitation params: %w", err) + } + + // Create the MCP request + mcpRequest := mcp.ElicitationRequest{ + Request: mcp.Request{ + Method: string(mcp.MethodElicitationCreate), + }, + Params: params, + } + + // Call the elicitation handler + result, err := c.elicitationHandler.Elicit(ctx, mcpRequest) + if err != nil { + return nil, err + } + + // Marshal the result + resultBytes, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + // Create the transport response + response := transport.NewJSONRPCResultResponse(request.ID, resultBytes) + + return response, nil +} + +func (c *Client) handlePingRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) { + b, _ := json.Marshal(&mcp.EmptyResult{}) + return transport.NewJSONRPCResultResponse(request.ID, b), nil +} + +func listByPage[T any]( + ctx context.Context, + client *Client, + request mcp.PaginatedRequest, + header http.Header, + method string, +) (*T, error) { + response, err := client.sendRequest(ctx, method, request.Params, outboundHeader(header, request.Method)) + if err != nil { + return nil, err + } + var result T + if err := json.Unmarshal(*response, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + return &result, nil +} + +// Helper methods + +// GetTransport gives access to the underlying transport layer. +// Cast it to the specific transport type and obtain the other helper methods. +func (c *Client) GetTransport() transport.Interface { + return c.transport +} + +// GetServerCapabilities returns the server capabilities. +func (c *Client) GetServerCapabilities() mcp.ServerCapabilities { + return c.serverCapabilities +} + +// GetClientCapabilities returns the client capabilities. +func (c *Client) GetClientCapabilities() mcp.ClientCapabilities { + return c.clientCapabilities +} + +// GetSessionId returns the session ID of the transport. +// If the transport does not support sessions, it returns an empty string. +func (c *Client) GetSessionId() string { + if c.transport == nil { + return "" + } + return c.transport.GetSessionId() +} + +// IsInitialized returns true if the client has been initialized. +func (c *Client) IsInitialized() bool { + return c.initialized.Load() +} + +// CancelTask returns canceled task result +func (c *Client) CancelTask( + ctx context.Context, + request mcp.CancelTaskRequest, +) (*mcp.CancelTaskResult, error) { + response, err := c.sendRequest(ctx, string(mcp.MethodTasksCancel), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseCancelTaskResult(response) +} + +// ListTasks returns the list of tasks +func (c *Client) ListTasks( + ctx context.Context, + request mcp.ListTasksRequest, +) (*mcp.ListTasksResult, error) { + response, err := c.sendRequest(ctx, string(mcp.MethodTasksList), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseListTasksResult(response) +} + +// TaskResult returns finished task result +func (c *Client) TaskResult( + ctx context.Context, + request mcp.TaskResultRequest, +) (*mcp.TaskResultResult, error) { + response, err := c.sendRequest(ctx, string(mcp.MethodTasksResult), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseTaskResultResult(response) +} + +// GetTask returns task with current status +func (c *Client) GetTask( + ctx context.Context, + request mcp.GetTaskRequest, +) (*mcp.GetTaskResult, error) { + response, err := c.sendRequest(ctx, string(mcp.MethodTasksGet), request.Params, outboundHeader(request.Header, request.Method)) + if err != nil { + return nil, err + } + + return mcp.ParseGetTaskResult(response) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/elicitation.go b/vendor/github.com/mark3labs/mcp-go/client/elicitation.go new file mode 100644 index 000000000..514878728 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/elicitation.go @@ -0,0 +1,19 @@ +package client + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" +) + +// ElicitationHandler defines the interface for handling elicitation requests from servers. +// Clients can implement this interface to request additional information from users. +type ElicitationHandler interface { + // Elicit handles an elicitation request from the server and returns the user's response. + // The implementation should: + // 1. Present the request message to the user (and URL if in URL mode) + // 2. Validate input against the requested schema (for form mode) + // 3. Allow the user to accept, decline, or cancel + // 4. Return the appropriate response + Elicit(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/http.go b/vendor/github.com/mark3labs/mcp-go/client/http.go new file mode 100644 index 000000000..d001a1e63 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/http.go @@ -0,0 +1,22 @@ +package client + +import ( + "fmt" + + "github.com/mark3labs/mcp-go/client/transport" +) + +// NewStreamableHttpClient is a convenience method that creates a new streamable-http-based MCP client +// with the given base URL. Returns an error if the URL is invalid. +func NewStreamableHttpClient(baseURL string, options ...transport.StreamableHTTPCOption) (*Client, error) { + trans, err := transport.NewStreamableHTTP(baseURL, options...) + if err != nil { + return nil, fmt.Errorf("failed to create SSE transport: %w", err) + } + clientOptions := make([]ClientOption, 0) + sessionID := trans.GetSessionId() + if sessionID != "" { + clientOptions = append(clientOptions, WithSession()) + } + return NewClient(trans, clientOptions...), nil +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/inprocess.go b/vendor/github.com/mark3labs/mcp-go/client/inprocess.go new file mode 100644 index 000000000..62d28794d --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/inprocess.go @@ -0,0 +1,38 @@ +package client + +import ( + "context" + + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// NewInProcessClient connect directly to a mcp server object in the same process +func NewInProcessClient(server *server.MCPServer) (*Client, error) { + inProcessTransport := transport.NewInProcessTransport(server) + return NewClient(inProcessTransport), nil +} + +// NewInProcessClientWithSamplingHandler creates an in-process client with sampling support +func NewInProcessClientWithSamplingHandler(server *server.MCPServer, handler SamplingHandler) (*Client, error) { + // Create a wrapper that implements server.SamplingHandler + serverHandler := &inProcessSamplingHandlerWrapper{handler: handler} + + inProcessTransport := transport.NewInProcessTransportWithOptions(server, + transport.WithSamplingHandler(serverHandler)) + + client := NewClient(inProcessTransport) + client.samplingHandler = handler + + return client, nil +} + +// inProcessSamplingHandlerWrapper wraps client.SamplingHandler to implement server.SamplingHandler +type inProcessSamplingHandlerWrapper struct { + handler SamplingHandler +} + +func (w *inProcessSamplingHandlerWrapper) CreateMessage(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) { + return w.handler.CreateMessage(ctx, request) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/interface.go b/vendor/github.com/mark3labs/mcp-go/client/interface.go new file mode 100644 index 000000000..233ca495a --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/interface.go @@ -0,0 +1,109 @@ +// Package client provides MCP (Model Context Protocol) client implementations. +package client + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" +) + +// MCPClient represents an MCP client interface +type MCPClient interface { + // Initialize sends the initial connection request to the server + Initialize( + ctx context.Context, + request mcp.InitializeRequest, + ) (*mcp.InitializeResult, error) + + // Ping checks if the server is alive + Ping(ctx context.Context) error + + // ListResourcesByPage manually list resources by page. + ListResourcesByPage( + ctx context.Context, + request mcp.ListResourcesRequest, + ) (*mcp.ListResourcesResult, error) + + // ListResources requests a list of available resources from the server + ListResources( + ctx context.Context, + request mcp.ListResourcesRequest, + ) (*mcp.ListResourcesResult, error) + + // ListResourceTemplatesByPage manually list resource templates by page. + ListResourceTemplatesByPage( + ctx context.Context, + request mcp.ListResourceTemplatesRequest, + ) (*mcp.ListResourceTemplatesResult, + error) + + // ListResourceTemplates requests a list of available resource templates from the server + ListResourceTemplates( + ctx context.Context, + request mcp.ListResourceTemplatesRequest, + ) (*mcp.ListResourceTemplatesResult, + error) + + // ReadResource reads a specific resource from the server + ReadResource( + ctx context.Context, + request mcp.ReadResourceRequest, + ) (*mcp.ReadResourceResult, error) + + // Subscribe requests notifications for changes to a specific resource + Subscribe(ctx context.Context, request mcp.SubscribeRequest) error + + // Unsubscribe cancels notifications for a specific resource + Unsubscribe(ctx context.Context, request mcp.UnsubscribeRequest) error + + // ListPromptsByPage manually list prompts by page. + ListPromptsByPage( + ctx context.Context, + request mcp.ListPromptsRequest, + ) (*mcp.ListPromptsResult, error) + + // ListPrompts requests a list of available prompts from the server + ListPrompts( + ctx context.Context, + request mcp.ListPromptsRequest, + ) (*mcp.ListPromptsResult, error) + + // GetPrompt retrieves a specific prompt from the server + GetPrompt( + ctx context.Context, + request mcp.GetPromptRequest, + ) (*mcp.GetPromptResult, error) + + // ListToolsByPage manually list tools by page. + ListToolsByPage( + ctx context.Context, + request mcp.ListToolsRequest, + ) (*mcp.ListToolsResult, error) + + // ListTools requests a list of available tools from the server + ListTools( + ctx context.Context, + request mcp.ListToolsRequest, + ) (*mcp.ListToolsResult, error) + + // CallTool invokes a specific tool on the server + CallTool( + ctx context.Context, + request mcp.CallToolRequest, + ) (*mcp.CallToolResult, error) + + // SetLevel sets the logging level for the server + SetLevel(ctx context.Context, request mcp.SetLevelRequest) error + + // Complete requests completion options for a given argument + Complete( + ctx context.Context, + request mcp.CompleteRequest, + ) (*mcp.CompleteResult, error) + + // Close client connection and cleanup resources + Close() error + + // OnNotification registers a handler for notifications + OnNotification(handler func(notification mcp.JSONRPCNotification)) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/iter.go b/vendor/github.com/mark3labs/mcp-go/client/iter.go new file mode 100644 index 000000000..f819fa076 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/iter.go @@ -0,0 +1,153 @@ +package client + +import ( + "context" + "iter" + + "github.com/mark3labs/mcp-go/mcp" +) + +// IterTools returns an iterator that lazily fetches pages of tools from the +// server. Pages are fetched on demand as the iterator is consumed; breaking +// out of the loop stops further requests. +// +// If an error occurs while fetching a page, the iterator yields a single +// (zero-value, error) pair and then stops. +// +// Example: +// +// for tool, err := range client.IterTools(ctx, mcp.ListToolsRequest{}) { +// if err != nil { +// return err +// } +// fmt.Println(tool.Name) +// } +func (c *Client) IterTools( + ctx context.Context, + request mcp.ListToolsRequest, +) iter.Seq2[mcp.Tool, error] { + return func(yield func(mcp.Tool, error) bool) { + for { + if err := ctx.Err(); err != nil { + yield(mcp.Tool{}, err) + return + } + result, err := c.ListToolsByPage(ctx, request) + if err != nil { + yield(mcp.Tool{}, err) + return + } + for _, tool := range result.Tools { + if !yield(tool, nil) { + return + } + } + if result.NextCursor == "" { + return + } + request.Params.Cursor = result.NextCursor + } + } +} + +// IterResources returns an iterator that lazily fetches pages of resources +// from the server. Pages are fetched on demand as the iterator is consumed; +// breaking out of the loop stops further requests. +// +// If an error occurs while fetching a page, the iterator yields a single +// (zero-value, error) pair and then stops. +func (c *Client) IterResources( + ctx context.Context, + request mcp.ListResourcesRequest, +) iter.Seq2[mcp.Resource, error] { + return func(yield func(mcp.Resource, error) bool) { + for { + if err := ctx.Err(); err != nil { + yield(mcp.Resource{}, err) + return + } + result, err := c.ListResourcesByPage(ctx, request) + if err != nil { + yield(mcp.Resource{}, err) + return + } + for _, resource := range result.Resources { + if !yield(resource, nil) { + return + } + } + if result.NextCursor == "" { + return + } + request.Params.Cursor = result.NextCursor + } + } +} + +// IterResourceTemplates returns an iterator that lazily fetches pages of +// resource templates from the server. Pages are fetched on demand as the +// iterator is consumed; breaking out of the loop stops further requests. +// +// If an error occurs while fetching a page, the iterator yields a single +// (zero-value, error) pair and then stops. +func (c *Client) IterResourceTemplates( + ctx context.Context, + request mcp.ListResourceTemplatesRequest, +) iter.Seq2[mcp.ResourceTemplate, error] { + return func(yield func(mcp.ResourceTemplate, error) bool) { + for { + if err := ctx.Err(); err != nil { + yield(mcp.ResourceTemplate{}, err) + return + } + result, err := c.ListResourceTemplatesByPage(ctx, request) + if err != nil { + yield(mcp.ResourceTemplate{}, err) + return + } + for _, template := range result.ResourceTemplates { + if !yield(template, nil) { + return + } + } + if result.NextCursor == "" { + return + } + request.Params.Cursor = result.NextCursor + } + } +} + +// IterPrompts returns an iterator that lazily fetches pages of prompts from +// the server. Pages are fetched on demand as the iterator is consumed; +// breaking out of the loop stops further requests. +// +// If an error occurs while fetching a page, the iterator yields a single +// (zero-value, error) pair and then stops. +func (c *Client) IterPrompts( + ctx context.Context, + request mcp.ListPromptsRequest, +) iter.Seq2[mcp.Prompt, error] { + return func(yield func(mcp.Prompt, error) bool) { + for { + if err := ctx.Err(); err != nil { + yield(mcp.Prompt{}, err) + return + } + result, err := c.ListPromptsByPage(ctx, request) + if err != nil { + yield(mcp.Prompt{}, err) + return + } + for _, prompt := range result.Prompts { + if !yield(prompt, nil) { + return + } + } + if result.NextCursor == "" { + return + } + request.Params.Cursor = result.NextCursor + } + } +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/oauth.go b/vendor/github.com/mark3labs/mcp-go/client/oauth.go new file mode 100644 index 000000000..1e07897ce --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/oauth.go @@ -0,0 +1,105 @@ +package client + +import ( + "errors" + "fmt" + + "github.com/mark3labs/mcp-go/client/transport" +) + +// OAuthConfig is a convenience type that wraps transport.OAuthConfig +type OAuthConfig = transport.OAuthConfig + +// Token is a convenience type that wraps transport.Token +type Token = transport.Token + +// TokenStore is a convenience type that wraps transport.TokenStore +type TokenStore = transport.TokenStore + +// MemoryTokenStore is a convenience type that wraps transport.MemoryTokenStore +type MemoryTokenStore = transport.MemoryTokenStore + +// NewMemoryTokenStore is a convenience function that wraps transport.NewMemoryTokenStore +var NewMemoryTokenStore = transport.NewMemoryTokenStore + +// NewOAuthStreamableHttpClient creates a new streamable-http-based MCP client with OAuth support. +// Returns an error if the URL is invalid. +func NewOAuthStreamableHttpClient(baseURL string, oauthConfig OAuthConfig, options ...transport.StreamableHTTPCOption) (*Client, error) { + // Add OAuth option to the list of options + options = append(options, transport.WithHTTPOAuth(oauthConfig)) + + trans, err := transport.NewStreamableHTTP(baseURL, options...) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP transport: %w", err) + } + return NewClient(trans), nil +} + +// NewOAuthStreamableHttpClient creates a new streamable-http-based MCP client with OAuth support. +// Returns an error if the URL is invalid. +func NewOAuthSSEClient(baseURL string, oauthConfig OAuthConfig, options ...transport.ClientOption) (*Client, error) { + // Add OAuth option to the list of options + options = append(options, transport.WithOAuth(oauthConfig)) + + trans, err := transport.NewSSE(baseURL, options...) + if err != nil { + return nil, fmt.Errorf("failed to create SSE transport: %w", err) + } + return NewClient(trans), nil +} + +// GenerateCodeVerifier generates a code verifier for PKCE +var GenerateCodeVerifier = transport.GenerateCodeVerifier + +// GenerateCodeChallenge generates a code challenge from a code verifier +var GenerateCodeChallenge = transport.GenerateCodeChallenge + +// GenerateState generates a state parameter for OAuth +var GenerateState = transport.GenerateState + +// AuthorizationRequiredError is returned when a 401 Unauthorized response is received +type AuthorizationRequiredError = transport.AuthorizationRequiredError + +// OAuthAuthorizationRequiredError is returned when OAuth authorization is required +type OAuthAuthorizationRequiredError = transport.OAuthAuthorizationRequiredError + +// IsAuthorizationRequiredError checks if an error is an AuthorizationRequiredError +func IsAuthorizationRequiredError(err error) bool { + var target *AuthorizationRequiredError + return errors.As(err, &target) +} + +// IsOAuthAuthorizationRequiredError checks if an error is an OAuthAuthorizationRequiredError +func IsOAuthAuthorizationRequiredError(err error) bool { + var target *OAuthAuthorizationRequiredError + return errors.As(err, &target) +} + +// GetOAuthHandler extracts the OAuthHandler from an OAuthAuthorizationRequiredError +func GetOAuthHandler(err error) *transport.OAuthHandler { + var oauthErr *OAuthAuthorizationRequiredError + if errors.As(err, &oauthErr) { + return oauthErr.Handler + } + return nil +} + +// GetResourceMetadataURL extracts the protected resource metadata URL from an authorization error. +// This URL is extracted from the WWW-Authenticate header per RFC9728 Section 5.1. +// Works with both AuthorizationRequiredError and OAuthAuthorizationRequiredError. +// Returns empty string if no metadata URL was discovered. +func GetResourceMetadataURL(err error) string { + // Try OAuthAuthorizationRequiredError first (contains AuthorizationRequiredError) + var oauthErr *OAuthAuthorizationRequiredError + if errors.As(err, &oauthErr) { + return oauthErr.ResourceMetadataURL + } + + // Try base AuthorizationRequiredError + var authErr *AuthorizationRequiredError + if errors.As(err, &authErr) { + return authErr.ResourceMetadataURL + } + + return "" +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/roots.go b/vendor/github.com/mark3labs/mcp-go/client/roots.go new file mode 100644 index 000000000..0a17aaf7a --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/roots.go @@ -0,0 +1,17 @@ +package client + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" +) + +// RootsHandler defines the interface for handling roots requests from servers. +// Clients can implement this interface to provide roots list to servers. +type RootsHandler interface { + // ListRoots handles a list root request from the server and returns the roots list. + // The implementation should: + // 1. Validate input against the requested schema + // 2. Return the appropriate response + ListRoots(ctx context.Context, request mcp.ListRootsRequest) (*mcp.ListRootsResult, error) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/sampling.go b/vendor/github.com/mark3labs/mcp-go/client/sampling.go new file mode 100644 index 000000000..245e2c1f7 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/sampling.go @@ -0,0 +1,20 @@ +package client + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" +) + +// SamplingHandler defines the interface for handling sampling requests from servers. +// Clients can implement this interface to provide LLM sampling capabilities to servers. +type SamplingHandler interface { + // CreateMessage handles a sampling request from the server and returns the generated message. + // The implementation should: + // 1. Validate the request parameters + // 2. Optionally prompt the user for approval (human-in-the-loop) + // 3. Select an appropriate model based on preferences + // 4. Generate the response using the selected model + // 5. Return the result with model information and stop reason + CreateMessage(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/sse.go b/vendor/github.com/mark3labs/mcp-go/client/sse.go new file mode 100644 index 000000000..390d90922 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/sse.go @@ -0,0 +1,61 @@ +package client + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/mark3labs/mcp-go/client/transport" +) + +// WithHeaders sets static headers for the SSE client. +func WithHeaders(headers map[string]string) transport.ClientOption { + return transport.WithHeaders(headers) +} + +// WithHeaderFunc sets a function that returns headers for each SSE request. +func WithHeaderFunc(headerFunc transport.HTTPHeaderFunc) transport.ClientOption { + return transport.WithHeaderFunc(headerFunc) +} + +// WithHTTPClient sets a custom HTTP client for the SSE transport. +func WithHTTPClient(httpClient *http.Client) transport.ClientOption { + return transport.WithHTTPClient(httpClient) +} + +// WithHTTPHost sets a custom Host header for the SSE client, enabling manual DNS resolution. +// This allows connecting to an IP address while sending a specific Host header to the server. +// For example, connecting to "http://192.168.1.100:8080/sse" but sending Host: "api.example.com" +func WithHTTPHost(host string) transport.ClientOption { + return transport.WithHTTPHost(host) +} + +// NewSSEMCPClient creates a new SSE-based MCP client with the given base URL. +// Returns an error if the URL is invalid. +// +// Example: +// +// c, err := client.NewSSEMCPClient("http://localhost:8080/mcp/sse") +// if err != nil { +// log.Fatalf("Failed to create client: %v", err) +// } +// defer c.Close() +// +// Note: Use a variable name other than "client" (e.g., "c", "mcpClient") to avoid +// shadowing the package name. +func NewSSEMCPClient(baseURL string, options ...transport.ClientOption) (*Client, error) { + sseTransport, err := transport.NewSSE(baseURL, options...) + if err != nil { + return nil, fmt.Errorf("failed to create SSE transport: %w", err) + } + return NewClient(sseTransport), nil +} + +// GetEndpoint returns the current endpoint URL for the SSE connection. +// +// Note: This method only works with SSE transport, or it will panic. +func GetEndpoint(c *Client) *url.URL { + t := c.GetTransport() + sse := t.(*transport.SSE) + return sse.GetEndpoint() +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/stdio.go b/vendor/github.com/mark3labs/mcp-go/client/stdio.go new file mode 100644 index 000000000..6a2d89bcf --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/stdio.go @@ -0,0 +1,62 @@ +package client + +import ( + "context" + "fmt" + "io" + + "github.com/mark3labs/mcp-go/client/transport" +) + +// NewStdioMCPClient creates a new stdio-based MCP client that communicates with a subprocess. +// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication. +// Returns an error if the subprocess cannot be started or the pipes cannot be created. +// +// NOTICE: NewStdioMCPClient will start the connection automatically. +// This is for backward compatibility. +func NewStdioMCPClient( + command string, + env []string, + args ...string, +) (*Client, error) { + return NewStdioMCPClientWithOptions(command, env, args) +} + +// NewStdioMCPClientWithOptions creates a new stdio-based MCP client that communicates with a subprocess. +// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication. +// Optional configuration functions can be provided to customize the transport before it starts, +// such as setting a custom command function. +// +// NOTICE: NewStdioMCPClientWithOptions automatically starts the underlying transport. +// This is for backward compatibility. +func NewStdioMCPClientWithOptions( + command string, + env []string, + args []string, + opts ...transport.StdioOption, +) (*Client, error) { + stdioTransport := transport.NewStdioWithOptions(command, env, args, opts...) + + if err := stdioTransport.Start(context.Background()); err != nil { + return nil, fmt.Errorf("failed to start stdio transport: %w", err) + } + + return NewClient(stdioTransport), nil +} + +// GetStderr returns a reader for the stderr output of the subprocess. +// This can be used to capture error messages or logs from the subprocess. +// +// It works with any transport that exposes a Stderr() io.Reader method, +// including *transport.Stdio and *transport.CommandTransport. +func GetStderr(c *Client) (io.Reader, bool) { + t := c.GetTransport() + + type stderrer interface { + Stderr() io.Reader + } + if s, ok := t.(stderrer); ok { + return s.Stderr(), true + } + return nil, false +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/tracing.go b/vendor/github.com/mark3labs/mcp-go/client/tracing.go new file mode 100644 index 000000000..c81329e27 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/tracing.go @@ -0,0 +1,90 @@ +package client + +import ( + "context" + "net/http" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/tracing" +) + +const attrMethod = "mcp.method" + +// WithTracer installs a tracer on the client. The tracer starts a client-kind +// span around every outgoing JSON-RPC method ("mcp."). For end-to-end +// propagation install a Propagator with WithPropagator as well. A nil tracer +// is treated as a no-op. +func WithTracer(tracer tracing.Tracer) ClientOption { + if tracer == nil { + tracer = tracing.NoopTracer() + } + return func(c *Client) { + c.tracer = tracer + } +} + +// WithPropagator installs a propagator that injects trace context into +// outgoing request headers. A nil propagator is treated as a no-op. +func WithPropagator(p tracing.Propagator) ClientOption { + if p == nil { + p = tracing.NoopPropagator() + } + return func(c *Client) { + c.propagator = p + } +} + +// WithMetaPropagator installs a propagator that injects trace context into +// the MCP _meta property bag of outgoing requests (per SEP-414). It covers +// all transports including stdio, where HTTP headers are unavailable. +// A nil propagator is treated as a no-op. +func WithMetaPropagator(p tracing.MetaPropagator) ClientOption { + if p == nil { + p = tracing.NoopMetaPropagator() + } + return func(c *Client) { + c.metaPropagator = p + } +} + +func (c *Client) injectMeta(ctx context.Context, meta *mcp.Meta) *mcp.Meta { + p := c.metaPropagator + if p == nil { + return meta + } + return p.InjectMeta(ctx, meta) +} + +func (c *Client) startSendSpan( + ctx context.Context, + method string, + header http.Header, +) (context.Context, http.Header, tracing.Span) { + tracer := c.tracer + if tracer == nil { + tracer = tracing.NoopTracer() + } + propagator := c.propagator + if propagator == nil { + propagator = tracing.NoopPropagator() + } + + ctx, span := tracer.Start(ctx, "mcp."+method, tracing.SpanKindClient, + tracing.String(attrMethod, method), + ) + + if header == nil { + header = make(http.Header) + } + propagator.Inject(ctx, header) + + return ctx, header, span +} + +func endSendSpan(span tracing.Span, err error) { + if err != nil { + span.SetStatus(tracing.StatusError, err.Error()) + span.RecordError(err) + } + span.End() +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/command.go b/vendor/github.com/mark3labs/mcp-go/client/transport/command.go new file mode 100644 index 000000000..716941a83 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/command.go @@ -0,0 +1,44 @@ +package transport + +// CommandTransport spawns a subprocess and communicates with it over its +// stdin/stdout streams using JSON-RPC messages. +// +// CommandTransport is a thin, convenience wrapper around the Stdio transport +// that makes the subprocess use case explicit and self-documenting. The +// underlying behavior is identical to NewStdio: any options, methods, or +// guarantees provided by Stdio are available here via embedding. +// +// Use CommandTransport when you want to launch an MCP server as a child +// process. Use Stdio (e.g. via NewIO) when you want to attach to existing +// streams. +type CommandTransport struct { + *Stdio +} + +// NewCommand creates a transport that spawns the given command as a subprocess +// and communicates with it over stdin/stdout. The current process's +// environment is inherited by the child. +// +// This is equivalent to calling NewStdio(command, nil, args...) but expresses +// the subprocess intent more clearly. +func NewCommand(command string, args ...string) *CommandTransport { + return &CommandTransport{Stdio: NewStdio(command, nil, args...)} +} + +// NewCommandWithEnv creates a transport that spawns the given command as a +// subprocess with additional environment variables appended to the parent +// process's environment. Each entry in env must be of the form "KEY=VALUE". +// +// This is equivalent to calling NewStdio(command, env, args...). +func NewCommandWithEnv(command string, env []string, args ...string) *CommandTransport { + return &CommandTransport{Stdio: NewStdio(command, env, args...)} +} + +// NewCommandWithOptions creates a transport that spawns the given command as a +// subprocess and applies the provided StdioOptions (for example +// WithCommandFunc or WithCommandLogger) before the subprocess is started. +// +// This is equivalent to calling NewStdioWithOptions(command, env, args, opts...). +func NewCommandWithOptions(command string, env []string, args []string, opts ...StdioOption) *CommandTransport { + return &CommandTransport{Stdio: NewStdioWithOptions(command, env, args, opts...)} +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/constants.go b/vendor/github.com/mark3labs/mcp-go/client/transport/constants.go new file mode 100644 index 000000000..2fb503084 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/constants.go @@ -0,0 +1,7 @@ +package transport + +// Common HTTP header constants used across transports +const ( + HeaderKeySessionID = "Mcp-Session-Id" + HeaderKeyProtocolVersion = "Mcp-Protocol-Version" +) diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/error.go b/vendor/github.com/mark3labs/mcp-go/client/transport/error.go new file mode 100644 index 000000000..1f029944a --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/error.go @@ -0,0 +1,22 @@ +package transport + +import "fmt" + +// Error wraps a low-level transport error in a concrete type. +type Error struct { + Err error +} + +func (e *Error) Error() string { + return fmt.Sprintf("transport error: %v", e.Err) +} + +func (e *Error) Unwrap() error { + return e.Err +} + +func NewError(err error) *Error { + return &Error{ + Err: err, + } +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go b/vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go new file mode 100644 index 000000000..c20e20863 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/inprocess.go @@ -0,0 +1,199 @@ +package transport + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type InProcessTransport struct { + server *server.MCPServer + samplingHandler server.SamplingHandler + elicitationHandler server.ElicitationHandler + rootsHandler server.RootsHandler + session *server.InProcessSession + sessionID string + + onNotification func(mcp.JSONRPCNotification) + notifyMu sync.RWMutex + started bool + closed bool + startedMu sync.Mutex + + done chan struct{} + closeOnce sync.Once +} + +type InProcessOption func(*InProcessTransport) + +func WithSamplingHandler(handler server.SamplingHandler) InProcessOption { + return func(t *InProcessTransport) { + t.samplingHandler = handler + } +} + +func WithElicitationHandler(handler server.ElicitationHandler) InProcessOption { + return func(t *InProcessTransport) { + t.elicitationHandler = handler + } +} + +func WithRootsHandler(handler server.RootsHandler) InProcessOption { + return func(t *InProcessTransport) { + t.rootsHandler = handler + } +} + +func NewInProcessTransport(server *server.MCPServer) *InProcessTransport { + return &InProcessTransport{ + server: server, + sessionID: server.GenerateInProcessSessionID(), + done: make(chan struct{}), + } +} + +func NewInProcessTransportWithOptions(server *server.MCPServer, opts ...InProcessOption) *InProcessTransport { + t := &InProcessTransport{ + server: server, + sessionID: server.GenerateInProcessSessionID(), + done: make(chan struct{}), + } + + for _, opt := range opts { + opt(t) + } + + return t +} + +func (c *InProcessTransport) Start(ctx context.Context) error { + c.startedMu.Lock() + if c.closed { + c.startedMu.Unlock() + return ErrTransportClosed + } + if c.started { + c.startedMu.Unlock() + return nil + } + + // Always create and register a session so server-to-client notifications + // (progress, list-changed, resource updates, etc.) have somewhere to land, + // in addition to any sampling/elicitation/roots handlers. + // + // Registration and the c.session/c.started assignments all happen under a + // single startedMu hold so that Start and Close are mutually exclusive: + // a concurrent Close either runs entirely before this section (observed + // via c.closed above, so Start bails out before registering anything) or + // entirely after (Close will see c.started and c.session set, and will + // unregister the session). There is no window where a session is + // registered but Close skips unregistering it. RegisterSession does a + // sync.Map store plus runs synchronous OnRegisterSession hooks; neither + // re-enters this transport's lock (safe from deadlock); slow user hooks + // only extend the lock hold. + session := server.NewInProcessSessionWithHandlers(c.sessionID, c.samplingHandler, c.elicitationHandler, c.rootsHandler) + if err := c.server.RegisterSession(ctx, session); err != nil { + c.startedMu.Unlock() + return fmt.Errorf("failed to register session: %w", err) + } + + c.session = session + c.started = true + c.startedMu.Unlock() + + go c.forwardNotifications() + + return nil +} + +// forwardNotifications drains the session's notification channel and +// forwards each notification to the registered client handler, mirroring +// the equivalent readResponses notification path in the stdio transport. +// Runs until Close() closes the done channel. +func (c *InProcessTransport) forwardNotifications() { + notifications := c.session.ClientNotifications() + for { + select { + case <-c.done: + return + case notification, ok := <-notifications: + if !ok { + return + } + c.notifyMu.RLock() + handler := c.onNotification + c.notifyMu.RUnlock() + if handler != nil { + handler(notification) + } + } + } +} + +func (c *InProcessTransport) SendRequest(ctx context.Context, request JSONRPCRequest) (*JSONRPCResponse, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + requestBytes = append(requestBytes, '\n') + + // Add session to context if available + if c.session != nil { + ctx = c.server.WithContext(ctx, c.session) + } + + respMessage := c.server.HandleMessage(ctx, requestBytes) + respByte, err := json.Marshal(respMessage) + if err != nil { + return nil, fmt.Errorf("failed to marshal response message: %w", err) + } + var rpcResp JSONRPCResponse + err = json.Unmarshal(respByte, &rpcResp) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response message: %w", err) + } + + return &rpcResp, nil +} + +func (c *InProcessTransport) SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error { + notificationBytes, err := json.Marshal(notification) + if err != nil { + return fmt.Errorf("failed to marshal notification: %w", err) + } + notificationBytes = append(notificationBytes, '\n') + c.server.HandleMessage(ctx, notificationBytes) + + return nil +} + +func (c *InProcessTransport) SetNotificationHandler(handler func(notification mcp.JSONRPCNotification)) { + c.notifyMu.Lock() + defer c.notifyMu.Unlock() + c.onNotification = handler +} + +func (c *InProcessTransport) Close() error { + c.startedMu.Lock() + c.closed = true + session := c.session + sessionID := c.sessionID + c.startedMu.Unlock() + + c.closeOnce.Do(func() { + close(c.done) + }) + + if session != nil { + c.server.UnregisterSession(context.Background(), sessionID) + } + return nil +} + +func (c *InProcessTransport) GetSessionId() string { + return "" +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/interface.go b/vendor/github.com/mark3labs/mcp-go/client/transport/interface.go new file mode 100644 index 000000000..e35a5f318 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/interface.go @@ -0,0 +1,74 @@ +package transport + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/mark3labs/mcp-go/mcp" +) + +// HTTPHeaderFunc is a function that extracts header entries from the given context +// and returns them as key-value pairs. This is typically used to add context values +// as HTTP headers in outgoing requests. +type HTTPHeaderFunc func(context.Context) map[string]string + +// Interface for the transport layer. +type Interface interface { + // Start the connection. Start should only be called once. + Start(ctx context.Context) error + + // SendRequest sends a json RPC request and returns the response synchronously. + SendRequest(ctx context.Context, request JSONRPCRequest) (*JSONRPCResponse, error) + + // SendNotification sends a json RPC Notification to the server. + SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error + + // SetNotificationHandler sets the handler for notifications. + // Any notification before the handler is set will be discarded. + SetNotificationHandler(handler func(notification mcp.JSONRPCNotification)) + + // Close the connection. + Close() error + + // GetSessionId returns the session ID of the transport. + GetSessionId() string +} + +// RequestHandler defines a function that handles incoming requests from the server. +type RequestHandler func(ctx context.Context, request JSONRPCRequest) (*JSONRPCResponse, error) + +// BidirectionalInterface extends Interface to support incoming requests from the server. +// This is used for features like sampling where the server can send requests to the client. +type BidirectionalInterface interface { + Interface + + // SetRequestHandler sets the handler for incoming requests from the server. + // The handler should process the request and return a response. + SetRequestHandler(handler RequestHandler) +} + +// HTTPConnection is a Transport that runs over HTTP and supports +// protocol version headers. +type HTTPConnection interface { + Interface + SetProtocolVersion(version string) +} + +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID mcp.RequestId `json:"id"` + Method string `json:"method"` + Params any `json:"params,omitempty"` + Header http.Header `json:"-"` +} + +// JSONRPCResponse represents a JSON-RPC 2.0 response message. +// Use NewJSONRPCResultResponse to create a JSONRPCResponse with a result. +// Use NewJSONRPCErrorResponse to create a JSONRPCResponse with an error. +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID mcp.RequestId `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *mcp.JSONRPCErrorDetails `json:"error,omitempty"` +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/logging.go b/vendor/github.com/mark3labs/mcp-go/client/transport/logging.go new file mode 100644 index 000000000..080baf5ff --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/logging.go @@ -0,0 +1,304 @@ +package transport + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +// LoggingOption configures a LoggingTransport. +type LoggingOption func(*loggingConfig) + +type loggingConfig struct { + level slog.Level + payloads bool +} + +// WithLoggingLevel sets the slog level used for the log records emitted by +// the LoggingTransport. The default is slog.LevelDebug. +func WithLoggingLevel(level slog.Level) LoggingOption { + return func(c *loggingConfig) { + c.level = level + } +} + +// WithLoggingPayloads controls whether full JSON-RPC payloads (params, +// result and JSON-RPC error code/message) are included in the structured +// log records. When disabled, only message metadata (direction, method, +// id, duration and the bare "← response error" / "→ response error" +// marker for failed calls) is logged. The default is true. +func WithLoggingPayloads(enabled bool) LoggingOption { + return func(c *loggingConfig) { + c.payloads = enabled + } +} + +// LoggingTransport wraps a transport.Interface and logs every JSON-RPC +// message that flows through it using a slog.Logger. It is a transparent +// decorator: NewLogging returns a wrapper whose interface set mirrors the +// inner transport, so type assertions against BidirectionalInterface or +// HTTPConnection continue to behave as expected. +type LoggingTransport struct { + inner Interface + logger *slog.Logger + level slog.Level + logRaw bool + + notifyMu sync.RWMutex + onNotification func(notification mcp.JSONRPCNotification) +} + +// NewLogging returns an Interface that logs all JSON-RPC traffic passing +// through inner using the provided logger. When logger is nil, slog.Default +// is used. If inner additionally implements BidirectionalInterface and/or +// HTTPConnection, the returned wrapper also implements those interfaces, so +// it can be used as a drop-in replacement. +// +// NewLogging installs its own notification handler on the inner transport +// so that incoming notifications can be logged. Because [Interface] does +// not expose a way to read the previously-registered handler, NewLogging +// must be applied before any handler is registered on the inner transport; +// otherwise the previously-registered handler will be displaced. Callers +// that need to receive notifications should register their handler on the +// returned wrapper, which will both log the notification and forward it. +func NewLogging(inner Interface, logger *slog.Logger, opts ...LoggingOption) Interface { + base := newLoggingTransport(inner, logger, opts...) + + bidir, isBidir := inner.(BidirectionalInterface) + httpConn, isHTTP := inner.(HTTPConnection) + + switch { + case isBidir && isHTTP: + return &loggingBidirHTTPTransport{ + loggingBidirTransport: loggingBidirTransport{LoggingTransport: base, bidir: bidir}, + http: httpConn, + } + case isBidir: + return &loggingBidirTransport{LoggingTransport: base, bidir: bidir} + case isHTTP: + return &loggingHTTPTransport{LoggingTransport: base, http: httpConn} + default: + return base + } +} + +func newLoggingTransport(inner Interface, logger *slog.Logger, opts ...LoggingOption) *LoggingTransport { + cfg := loggingConfig{level: slog.LevelDebug, payloads: true} + for _, opt := range opts { + opt(&cfg) + } + if logger == nil { + logger = slog.Default() + } + lt := &LoggingTransport{ + inner: inner, + logger: logger, + level: cfg.level, + logRaw: cfg.payloads, + } + // Pre-register a notification handler on the inner transport so that + // notifications are still logged even when the user never registers one + // on the wrapper. + inner.SetNotificationHandler(lt.handleNotification) + return lt +} + +// Start delegates to the wrapped transport. +func (l *LoggingTransport) Start(ctx context.Context) error { + return l.inner.Start(ctx) +} + +// SendRequest forwards the request to the wrapped transport while logging +// both the outgoing request and the incoming response (or error). +func (l *LoggingTransport) SendRequest(ctx context.Context, request JSONRPCRequest) (*JSONRPCResponse, error) { + attrs := []slog.Attr{ + slog.String("method", request.Method), + slog.Any("id", request.ID), + } + if l.logRaw && request.Params != nil { + attrs = append(attrs, slog.Any("params", request.Params)) + } + l.log(ctx, "→ request", attrs...) + + start := time.Now() + resp, err := l.inner.SendRequest(ctx, request) + duration := time.Since(start) + + respAttrs := []slog.Attr{ + slog.Any("id", request.ID), + slog.String("method", request.Method), + slog.Duration("duration", duration), + } + if err != nil { + respAttrs = append(respAttrs, slog.String("error", err.Error())) + l.log(ctx, "← response error", respAttrs...) + return nil, err + } + if resp != nil && resp.Error != nil { + if l.logRaw { + respAttrs = append(respAttrs, + slog.Int("code", resp.Error.Code), + slog.String("error", resp.Error.Message), + ) + } + l.log(ctx, "← response error", respAttrs...) + return resp, nil + } + if l.logRaw && resp != nil && len(resp.Result) > 0 { + respAttrs = append(respAttrs, slog.String("result", string(resp.Result))) + } + l.log(ctx, "← response", respAttrs...) + return resp, nil +} + +// SendNotification forwards a notification to the wrapped transport and logs +// the outgoing message. +func (l *LoggingTransport) SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error { + attrs := []slog.Attr{slog.String("method", notification.Method)} + if l.logRaw { + if data, err := json.Marshal(notification.Params); err == nil && len(data) > 0 && string(data) != "null" { + attrs = append(attrs, slog.String("params", string(data))) + } + } + l.log(ctx, "→ notification", attrs...) + + if err := l.inner.SendNotification(ctx, notification); err != nil { + l.log(ctx, "→ notification error", + slog.String("method", notification.Method), + slog.String("error", err.Error()), + ) + return err + } + return nil +} + +// SetNotificationHandler registers handler to receive notifications from the +// server. The wrapper logs every incoming notification before forwarding it +// to handler. +func (l *LoggingTransport) SetNotificationHandler(handler func(notification mcp.JSONRPCNotification)) { + l.notifyMu.Lock() + defer l.notifyMu.Unlock() + l.onNotification = handler +} + +// Close shuts down the wrapped transport. +func (l *LoggingTransport) Close() error { + return l.inner.Close() +} + +// GetSessionId returns the session id reported by the wrapped transport. +func (l *LoggingTransport) GetSessionId() string { + return l.inner.GetSessionId() +} + +func (l *LoggingTransport) handleNotification(notification mcp.JSONRPCNotification) { + attrs := []slog.Attr{slog.String("method", notification.Method)} + if l.logRaw { + if data, err := json.Marshal(notification.Params); err == nil && len(data) > 0 && string(data) != "null" { + attrs = append(attrs, slog.String("params", string(data))) + } + } + l.log(context.Background(), "← notification", attrs...) + + l.notifyMu.RLock() + h := l.onNotification + l.notifyMu.RUnlock() + if h != nil { + h(notification) + } +} + +func (l *LoggingTransport) log(ctx context.Context, msg string, attrs ...slog.Attr) { + if !l.logger.Enabled(ctx, l.level) { + return + } + l.logger.LogAttrs(ctx, l.level, msg, attrs...) +} + +// loggingBidirTransport adds BidirectionalInterface support when the inner +// transport implements it. +type loggingBidirTransport struct { + *LoggingTransport + bidir BidirectionalInterface +} + +// SetRequestHandler registers a handler for incoming server-to-client +// requests. The wrapper logs each incoming request and the corresponding +// outgoing response before delegating to handler. +func (l *loggingBidirTransport) SetRequestHandler(handler RequestHandler) { + if handler == nil { + l.bidir.SetRequestHandler(nil) + return + } + wrapped := func(ctx context.Context, req JSONRPCRequest) (*JSONRPCResponse, error) { + attrs := []slog.Attr{ + slog.String("method", req.Method), + slog.Any("id", req.ID), + } + if l.logRaw && req.Params != nil { + attrs = append(attrs, slog.Any("params", req.Params)) + } + l.log(ctx, "← request", attrs...) + + start := time.Now() + resp, err := handler(ctx, req) + duration := time.Since(start) + + respAttrs := []slog.Attr{ + slog.Any("id", req.ID), + slog.String("method", req.Method), + slog.Duration("duration", duration), + } + if err != nil { + respAttrs = append(respAttrs, slog.String("error", err.Error())) + l.log(ctx, "→ response error", respAttrs...) + return nil, err + } + if resp != nil && resp.Error != nil { + if l.logRaw { + respAttrs = append(respAttrs, + slog.Int("code", resp.Error.Code), + slog.String("error", resp.Error.Message), + ) + } + l.log(ctx, "→ response error", respAttrs...) + return resp, nil + } + if l.logRaw && resp != nil && len(resp.Result) > 0 { + respAttrs = append(respAttrs, slog.String("result", string(resp.Result))) + } + l.log(ctx, "→ response", respAttrs...) + return resp, nil + } + l.bidir.SetRequestHandler(wrapped) +} + +// loggingHTTPTransport adds HTTPConnection support when the inner transport +// implements it. +type loggingHTTPTransport struct { + *LoggingTransport + http HTTPConnection +} + +// SetProtocolVersion forwards the negotiated protocol version to the wrapped +// HTTP transport. +func (l *loggingHTTPTransport) SetProtocolVersion(version string) { + l.http.SetProtocolVersion(version) +} + +// loggingBidirHTTPTransport supports both BidirectionalInterface and +// HTTPConnection. +type loggingBidirHTTPTransport struct { + loggingBidirTransport + http HTTPConnection +} + +// SetProtocolVersion forwards the negotiated protocol version to the wrapped +// HTTP transport. +func (l *loggingBidirHTTPTransport) SetProtocolVersion(version string) { + l.http.SetProtocolVersion(version) +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/oauth.go b/vendor/github.com/mark3labs/mcp-go/client/transport/oauth.go new file mode 100644 index 000000000..7ba1725a4 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/oauth.go @@ -0,0 +1,1202 @@ +package transport + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +// maxMetadataBodyBytes caps the size of an OAuth metadata document we are +// willing to read. +const maxMetadataBodyBytes = 1 << 20 // 1 MiB + +// ErrNoToken is returned when no token is available in the token store +var ErrNoToken = errors.New("no token available") + +// OAuthConfig holds the OAuth configuration for the client +type OAuthConfig struct { + // ClientID is the OAuth client ID + ClientID string + // ClientURI is the URI of the client + ClientURI string + // ClientSecret is the OAuth client secret (for confidential clients) + ClientSecret string + // RedirectURI is the redirect URI for the OAuth flow + RedirectURI string + // Scopes is the list of OAuth scopes to request + Scopes []string + // TokenStore is the storage for OAuth tokens + TokenStore TokenStore + // AuthServerMetadataURL is the URL to the OAuth server metadata + // If empty, the client will attempt to discover it from the base URL + AuthServerMetadataURL string + // ProtectedResourceMetadataURL is the URL to the OAuth protected resource metadata + // per RFC9728. If set, this URL will be used to discover the authorization server. + // This is typically extracted from the WWW-Authenticate header's resource_metadata parameter. + ProtectedResourceMetadataURL string + // PKCEEnabled enables PKCE for the OAuth flow (recommended for public clients) + PKCEEnabled bool + // HTTPClient is an optional HTTP client to use for requests. + // If nil, a default HTTP client with a 30 second timeout will be used. + HTTPClient *http.Client +} + +// TokenStore is an interface for storing and retrieving OAuth tokens. +// +// Implementations must: +// - Honor context cancellation and deadlines, returning context.Canceled +// or context.DeadlineExceeded as appropriate +// - Return ErrNoToken (or a sentinel error that wraps it) when no token +// is available, rather than conflating this with other operational errors +// - Properly propagate all other errors (database failures, I/O errors, etc.) +// - Check ctx.Done() before performing operations and return ctx.Err() if cancelled +type TokenStore interface { + // GetToken returns the current token. + // Returns ErrNoToken if no token is available. + // Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled. + // Returns other errors for operational failures (I/O, database, etc.). + GetToken(ctx context.Context) (*Token, error) + + // SaveToken saves a token. + // Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled. + // Returns other errors for operational failures (I/O, database, etc.). + SaveToken(ctx context.Context, token *Token) error +} + +// Token represents an OAuth token +type Token struct { + // AccessToken is the OAuth access token + AccessToken string `json:"access_token"` + // TokenType is the type of token (usually "Bearer") + TokenType string `json:"token_type"` + // RefreshToken is the OAuth refresh token + RefreshToken string `json:"refresh_token,omitempty"` + // ExpiresIn is the number of seconds until the token expires + ExpiresIn int64 `json:"expires_in,omitempty"` + // Scope is the scope of the token + Scope string `json:"scope,omitempty"` + // ExpiresAt is the time when the token expires + ExpiresAt time.Time `json:"expires_at,omitzero"` +} + +// IsExpired returns true if the token is expired +func (t *Token) IsExpired() bool { + if t.ExpiresAt.IsZero() { + return false + } + return time.Now().After(t.ExpiresAt) +} + +// MemoryTokenStore is a simple in-memory token store +type MemoryTokenStore struct { + token *Token + mu sync.RWMutex +} + +// NewMemoryTokenStore creates a new in-memory token store +func NewMemoryTokenStore() *MemoryTokenStore { + return &MemoryTokenStore{} +} + +// GetToken returns the current token. +// Returns ErrNoToken if no token is available. +// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled. +func (s *MemoryTokenStore) GetToken(ctx context.Context) (*Token, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.token == nil { + return nil, ErrNoToken + } + return s.token, nil +} + +// SaveToken saves a token. +// Returns context.Canceled or context.DeadlineExceeded if ctx is cancelled. +func (s *MemoryTokenStore) SaveToken(ctx context.Context, token *Token) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + s.token = token + return nil +} + +// AuthServerMetadata represents the OAuth 2.0 Authorization Server Metadata +// as defined in RFC 8414 (https://www.rfc-editor.org/rfc/rfc8414.html). +// +// URL-bearing fields are validated by validateAuthServerMetadataURLs to +// reject non-http(s) schemes (e.g. javascript:, data:, file:) that could +// otherwise be injected by a hostile authorization server. +// +// Signed metadata (the "signed_metadata" JWT field) is not supported. +type AuthServerMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JwksURI string `json:"jwks_uri,omitempty"` + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + ScopesSupported []string `json:"scopes_supported,omitempty"` + ResponseTypesSupported []string `json:"response_types_supported"` + ResponseModesSupported []string `json:"response_modes_supported,omitempty"` + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` + ServiceDocumentation string `json:"service_documentation,omitempty"` + UILocalesSupported []string `json:"ui_locales_supported,omitempty"` + OpPolicyURI string `json:"op_policy_uri,omitempty"` + OpTOSURI string `json:"op_tos_uri,omitempty"` + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` + RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"` + IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` + IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` + IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` +} + +// OAuthHandler handles OAuth authentication for HTTP requests +type OAuthHandler struct { + config OAuthConfig + httpClient *http.Client + serverMetadata *AuthServerMetadata + metadataFetchErr error + // metadataOnce gates the discovery RPCs so they run exactly once per + // OAuthHandler. It is a pointer (rather than an embedded value) so + // SetProtectedResourceMetadataURL can swap in a fresh sync.Once + // without racing against a concurrently running Do on the previous + // instance. The pointer itself is protected by metadataMu; callers + // snapshot the pointer under the lock and then invoke Do without + // holding the lock. + metadataOnce *sync.Once + baseURL string + // metadataMu protects baseURL, serverMetadata, metadataFetchErr, + // metadataOnce, config.ProtectedResourceMetadataURL, and resourceURL. + // It MUST NOT be held across HTTP I/O — see getServerMetadata for the + // snapshot/publish discipline that keeps network round trips out of + // the critical section (#871). + metadataMu sync.Mutex + resourceURL string // RFC 8707 resource indicator; set from protected resource metadata + + mu sync.RWMutex // Protects expectedState + expectedState string // Expected state value for CSRF protection +} + +// NewOAuthHandler creates a new OAuth handler +func NewOAuthHandler(config OAuthConfig) *OAuthHandler { + if config.TokenStore == nil { + config.TokenStore = NewMemoryTokenStore() + } + if config.HTTPClient == nil { + config.HTTPClient = &http.Client{Timeout: 30 * time.Second} + } + + return &OAuthHandler{ + config: config, + httpClient: config.HTTPClient, + } +} + +// GetAuthorizationHeader returns the Authorization header value for a request +func (h *OAuthHandler) GetAuthorizationHeader(ctx context.Context) (string, error) { + token, err := h.getValidToken(ctx) + if err != nil { + return "", err + } + + // Per RFC 6749 §5.1, token_type is case-insensitive. + // Normalize to "Bearer" for strict implementations. + tokenType := token.TokenType + if strings.EqualFold(tokenType, "bearer") { + tokenType = "Bearer" + } + + return fmt.Sprintf("%s %s", tokenType, token.AccessToken), nil +} + +// getValidToken returns a valid token, refreshing if necessary +func (h *OAuthHandler) getValidToken(ctx context.Context) (*Token, error) { + token, err := h.config.TokenStore.GetToken(ctx) + if err != nil && !errors.Is(err, ErrNoToken) { + return nil, err + } + if err == nil && !token.IsExpired() && token.AccessToken != "" { + return token, nil + } + + // If we have a refresh token, try to use it + if err == nil && token.RefreshToken != "" { + newToken, err := h.refreshToken(ctx, token.RefreshToken) + if err == nil { + return newToken, nil + } + // If refresh fails, continue to authorization flow + } + + // We need to get a new token through the authorization flow + return nil, ErrOAuthAuthorizationRequired +} + +// refreshToken refreshes an OAuth token +func (h *OAuthHandler) refreshToken(ctx context.Context, refreshToken string) (*Token, error) { + metadata, err := h.getServerMetadata(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get server metadata: %w", err) + } + + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("refresh_token", refreshToken) + data.Set("client_id", h.config.ClientID) + if h.config.ClientSecret != "" { + data.Set("client_secret", h.config.ClientSecret) + } + // RFC 8707: Include resource parameter on refresh requests + if resourceURL := h.getResourceURL(); resourceURL != "" { + data.Set("resource", resourceURL) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + metadata.TokenEndpoint, + strings.NewReader(data.Encode()), + ) + if err != nil { + return nil, fmt.Errorf("failed to create refresh token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := h.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send refresh token request: %w", err) + } + defer resp.Body.Close() + + // Accept any 2xx status; some authorization servers (e.g. Supabase) return + // 201 Created for successful token responses. + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, extractOAuthError(body, resp.StatusCode, "refresh token request failed") + } + + // Read the response body for parsing + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read token response body: %w", err) + } + + // GitHub returns HTTP 200 even for errors, with error details in the JSON body + // Check if the response contains an error field before parsing as Token + var oauthErr OAuthError + if err := json.Unmarshal(body, &oauthErr); err == nil && oauthErr.ErrorCode != "" { + return nil, fmt.Errorf("refresh token request failed: %w", oauthErr) + } + + var tokenResp Token + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to decode token response: %w", err) + } + + // Set expiration time + if tokenResp.ExpiresIn > 0 { + tokenResp.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + } + + // If no new refresh token is provided, keep the old one + if tokenResp.RefreshToken == "" { + tokenResp.RefreshToken = refreshToken + } + + // Save the token + if err := h.config.TokenStore.SaveToken(ctx, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to save token: %w", err) + } + + return &tokenResp, nil +} + +// RefreshToken is a public wrapper for refreshToken +func (h *OAuthHandler) RefreshToken(ctx context.Context, refreshToken string) (*Token, error) { + return h.refreshToken(ctx, refreshToken) +} + +// GetClientID returns the client ID +func (h *OAuthHandler) GetClientID() string { + return h.config.ClientID +} + +// extractOAuthError attempts to parse an OAuth error response from the response body +func extractOAuthError(body []byte, statusCode int, context string) error { + // Try to parse the error as an OAuth error response + var oauthErr OAuthError + if err := json.Unmarshal(body, &oauthErr); err == nil && oauthErr.ErrorCode != "" { + return fmt.Errorf("%s: %w", context, oauthErr) + } + + // If not a valid OAuth error, return the raw response + return fmt.Errorf("%s with status %d: %s", context, statusCode, body) +} + +// SetProtectedResourceMetadataURL updates the protected resource metadata URL +// and resets the cached server metadata so it will be re-discovered on the next call. +// +// This setter does not validate the URL; callers that pass values obtained +// out of band are trusted. For values parsed from a 401 WWW-Authenticate +// header, prefer HandleUnauthorizedResponse, which applies origin +// validation before storing. +func (h *OAuthHandler) SetProtectedResourceMetadataURL(u string) { + h.metadataMu.Lock() + defer h.metadataMu.Unlock() + h.config.ProtectedResourceMetadataURL = u + h.serverMetadata = nil + h.metadataFetchErr = nil + // Install a fresh sync.Once so the next getServerMetadata call + // triggers re-discovery. Replacing the pointer (rather than mutating + // the pointed-to value) keeps this safe against concurrent Do calls + // that captured the previous instance. + h.metadataOnce = &sync.Once{} + h.resourceURL = "" +} + +// HandleUnauthorizedResponse inspects a 401 response for RFC 9728 §5.1 +// WWW-Authenticate challenges and, when one carries a resource_metadata +// parameter whose URL shares the protected resource's origin, stores it +// so subsequent metadata discovery can use it. It iterates every +// WWW-Authenticate header line (a response can carry multiple challenges +// — Basic, Bearer, etc. — each on its own line) and every +// resource_metadata parameter within each line, takes the first +// candidate that validates, and silently ignores headers that are +// absent, malformed, or advertise an unrelated origin. +// +// Origin validation rejects URLs whose scheme or host differs from the +// OAuth handler's configured base URL. This prevents a compromised or +// misconfigured resource from redirecting clients to an attacker's +// metadata endpoint. +// +// It is safe to call with a nil response. +func (h *OAuthHandler) HandleUnauthorizedResponse(resp *http.Response) { + if resp == nil { + return + } + for _, header := range resp.Header.Values("WWW-Authenticate") { + for _, candidate := range extractResourceMetadataURLs(header) { + if err := h.validateAdvertisedPRMURL(candidate); err != nil { + continue + } + h.SetProtectedResourceMetadataURL(candidate) + return + } + } +} + +// validateAdvertisedPRMURL enforces that a PRM URL advertised by the +// resource (i.e. parsed from an untrusted WWW-Authenticate header) shares +// the configured base URL's scheme and host. Returns a non-nil error when +// the candidate is unparseable, carries a different scheme or host, or +// when no base URL has been configured to validate against. +// +// RFC 9728 §3.2 requires the protected resource to serve its own metadata; +// this check rejects attempts to redirect discovery to an unrelated +// origin, which would otherwise let the resource point the client at an +// attacker-controlled OAuth metadata endpoint. +func (h *OAuthHandler) validateAdvertisedPRMURL(candidate string) error { + h.metadataMu.Lock() + baseURL := h.baseURL + h.metadataMu.Unlock() + if baseURL == "" { + return errors.New("no base URL configured for origin validation") + } + base, err := url.Parse(baseURL) + if err != nil { + return fmt.Errorf("invalid base URL %q: %w", baseURL, err) + } + // url.Parse accepts relative references (empty Scheme and Host) + // without error. Reject those explicitly so two empty values do not + // EqualFold-match each other and bypass origin validation. + if base.Scheme == "" || base.Host == "" { + return fmt.Errorf("base URL %q is not absolute (missing scheme or host)", baseURL) + } + parsed, err := url.Parse(candidate) + if err != nil { + return fmt.Errorf("invalid advertised PRM URL %q: %w", candidate, err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("advertised PRM URL %q is not absolute (missing scheme or host)", candidate) + } + if !strings.EqualFold(parsed.Scheme, base.Scheme) { + return fmt.Errorf("advertised PRM URL scheme %q does not match base %q", parsed.Scheme, base.Scheme) + } + if !strings.EqualFold(parsed.Host, base.Host) { + return fmt.Errorf("advertised PRM URL host %q does not match base %q", parsed.Host, base.Host) + } + return nil +} + +// getResourceURL returns the RFC 8707 resource indicator under metadataMu. +func (h *OAuthHandler) getResourceURL() string { + h.metadataMu.Lock() + defer h.metadataMu.Unlock() + return h.resourceURL +} + +// GetClientSecret returns the client secret +func (h *OAuthHandler) GetClientSecret() string { + return h.config.ClientSecret +} + +// SetBaseURL sets the base URL for the API server. +// Must be called before any calls to getServerMetadata (i.e., during initialization). +func (h *OAuthHandler) SetBaseURL(baseURL string) { + h.metadataMu.Lock() + defer h.metadataMu.Unlock() + h.baseURL = baseURL +} + +// GetExpectedState returns the expected state value (for testing purposes) +func (h *OAuthHandler) GetExpectedState() string { + h.mu.RLock() + defer h.mu.RUnlock() + return h.expectedState +} + +// SetExpectedState sets the expected state value. +// +// This can be useful if you cannot maintain an OAuthHandler +// instance throughout the authentication flow; for example, if +// the initialization and callback steps are handled in different +// requests. +// +// In such cases, this should be called with the state value generated +// during the initial authentication request (e.g. by GenerateState) +// and included in the authorization URL. +func (h *OAuthHandler) SetExpectedState(expectedState string) { + h.mu.Lock() + defer h.mu.Unlock() + h.expectedState = expectedState +} + +// OAuthError represents a standard OAuth 2.0 error response +type OAuthError struct { + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + ErrorURI string `json:"error_uri,omitempty"` +} + +// Error implements the error interface +func (e OAuthError) Error() string { + if e.ErrorDescription != "" { + return fmt.Sprintf("OAuth error: %s - %s", e.ErrorCode, e.ErrorDescription) + } + return fmt.Sprintf("OAuth error: %s", e.ErrorCode) +} + +// OAuthProtectedResource represents the response from /.well-known/oauth-protected-resource +type OAuthProtectedResource struct { + AuthorizationServers []string `json:"authorization_servers"` + Resource string `json:"resource"` + ResourceName string `json:"resource_name,omitempty"` +} + +// metadataDiscoveryResult bundles the outputs of a single OAuth server +// metadata discovery attempt so that fetchServerMetadata can return them +// to getServerMetadata for publication under metadataMu, instead of +// mutating shared state directly while holding the lock across HTTP I/O. +type metadataDiscoveryResult struct { + metadata *AuthServerMetadata + resourceURL string +} + +// getServerMetadata fetches the OAuth server metadata. +// +// Discovery is gated by metadataOnce so the network round trips happen +// exactly once per OAuthHandler. metadataMu is taken only to snapshot +// configuration inputs before the fetch and to publish the result +// afterwards; it is intentionally NOT held while HTTP requests are in +// flight so that concurrent callers of other metadataMu-guarded methods +// (e.g. SetProtectedResourceMetadataURL, getResourceURL, +// validateAdvertisedPRMURL) are not blocked on network I/O. See #871. +func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetadata, error) { + // Snapshot (and lazily initialize) the current sync.Once under the + // lock. Using a pointer means SetProtectedResourceMetadataURL can + // install a fresh once without racing with a Do already in flight on + // the previous instance. + h.metadataMu.Lock() + if h.metadataOnce == nil { + h.metadataOnce = &sync.Once{} + } + once := h.metadataOnce + h.metadataMu.Unlock() + + once.Do(func() { + result, err := h.fetchServerMetadata(ctx) + h.metadataMu.Lock() + defer h.metadataMu.Unlock() + // If SetProtectedResourceMetadataURL swapped in a fresh sync.Once + // while the fetch was in flight, the configuration that produced + // `result` is now stale. Discard the result instead of letting it + // clobber the newer state; the next getServerMetadata call will + // drive re-discovery through the new once. + if h.metadataOnce != once { + return + } + if err != nil { + h.metadataFetchErr = err + return + } + // Only overwrite serverMetadata on a positive result. Preserving the + // existing value on a no-op fetch (nil metadata, nil error) matches + // the prior behavior where fetchMetadataFromURL silently returned on + // non-200 responses without touching shared state. + if result.metadata != nil { + h.serverMetadata = result.metadata + h.metadataFetchErr = nil + } + if result.resourceURL != "" { + h.resourceURL = result.resourceURL + } + // Discovery completed without producing metadata or an error (e.g. a + // non-2xx response handled by fetchMetadataFromURL). Record an explicit + // error — including the discovery target — so callers receive a + // consistent cached failure instead of a nil *AuthServerMetadata, which + // they would dereference and panic on. Skip when serverMetadata is + // already populated: a no-op re-discovery preserves the prior value. + if h.serverMetadata == nil && h.metadataFetchErr == nil { + if h.config.AuthServerMetadataURL != "" { + h.metadataFetchErr = fmt.Errorf("authorization server metadata unavailable: discovery at %q returned no metadata", h.config.AuthServerMetadataURL) + } else { + h.metadataFetchErr = errors.New("authorization server metadata unavailable: auto-discovery from the server base URL returned no metadata") + } + } + }) + + h.metadataMu.Lock() + defer h.metadataMu.Unlock() + if h.metadataFetchErr != nil { + return nil, h.metadataFetchErr + } + // Defensive backstop: a concurrent SetProtectedResourceMetadataURL may have + // reset state after our once ran. Never return (nil, nil) — every caller + // dereferences the returned metadata. + if h.serverMetadata == nil { + return nil, errors.New("authorization server metadata unavailable") + } + return h.serverMetadata, nil +} + +// fetchServerMetadata performs OAuth server metadata discovery without +// holding metadataMu, so concurrent callers reading other state guarded +// by the mutex are not blocked on network I/O. Configuration inputs are +// snapshotted under the lock before the first request is issued; the +// caller (getServerMetadata) is responsible for publishing the returned +// result back into the handler under metadataMu. +func (h *OAuthHandler) fetchServerMetadata(ctx context.Context) (metadataDiscoveryResult, error) { + // Snapshot config and base URL under the lock; release before any HTTP I/O. + h.metadataMu.Lock() + authServerMetadataURL := h.config.AuthServerMetadataURL + protectedResourceMetadataURL := h.config.ProtectedResourceMetadataURL + baseURL, baseURLErr := h.extractBaseURL() + h.metadataMu.Unlock() + + // If AuthServerMetadataURL is explicitly provided, use it directly + if authServerMetadataURL != "" { + metadata, err := h.fetchMetadataFromURL(ctx, authServerMetadataURL) + if err != nil { + return metadataDiscoveryResult{}, err + } + return metadataDiscoveryResult{metadata: metadata}, nil + } + + // Always extract base URL for fallback scenarios + if baseURLErr != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to extract base URL: %w", baseURLErr) + } + + // Determine the protected resource metadata URL with priority: + // 1. Explicit config (ProtectedResourceMetadataURL from RFC9728 WWW-Authenticate header) + // 2. Constructed from base URL + var protectedResourceURL string + explicitMetadataURL := protectedResourceMetadataURL != "" + if explicitMetadataURL { + protectedResourceURL = protectedResourceMetadataURL + } else { + var err error + protectedResourceURL, err = buildWellKnownURL(baseURL, "oauth-protected-resource") + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to build protected resource URL: %w", err) + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, protectedResourceURL, nil) + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to create protected resource request: %w", err) + } + + req.Header.Set("Accept", "application/json") + // Advertise the latest protocol version this client supports. Metadata + // discovery happens before the MCP initialize handshake, so no negotiated + // version is available yet; servers that don't recognise it can fall back + // per the MCP spec. + req.Header.Set("MCP-Protocol-Version", mcp.LATEST_PROTOCOL_VERSION) + + resp, err := h.httpClient.Do(req) + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to send protected resource request: %w", err) + } + defer resp.Body.Close() + + // If we can't get the protected resource metadata, try OAuth Authorization Server discovery. + // However, if the resource_metadata URL was explicitly provided (via RFC 9728), don't + // fall back to baseURL-derived discovery — the server specifically indicated where to + // find metadata, so falling back would mask the signal. + if resp.StatusCode != http.StatusOK { + if explicitMetadataURL { + return metadataDiscoveryResult{}, fmt.Errorf("protected resource metadata discovery failed for explicit URL %q: status %d", protectedResourceURL, resp.StatusCode) + } + for _, u := range authorizationServerMetadataURLs(baseURL) { + // Intermediate fetch errors are intentionally discarded so the caller + // can fall through to the next candidate URL, mirroring the prior + // behavior where metadataFetchErr was cleared on the first success. + if metadata, _ := h.fetchMetadataFromURL(ctx, u); metadata != nil { + return metadataDiscoveryResult{metadata: metadata}, nil + } + } + // If that also fails, fall back to default endpoints + metadata, err := h.getDefaultEndpoints(baseURL) + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to get default endpoints: %w", err) + } + return metadataDiscoveryResult{metadata: metadata}, nil + } + + // Parse the protected resource metadata + var protectedResource OAuthProtectedResource + if err := json.NewDecoder(resp.Body).Decode(&protectedResource); err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to decode protected resource response: %w", err) + } + + // RFC 9728 §3.3/§7.3: when metadata is fetched from a PRM URL the + // server advertised via WWW-Authenticate (an untrusted network + // input), the declared resource identifier MUST match the + // protected resource the client addressed — otherwise the + // response MUST NOT be used. An advertised PRM response that + // omits the resource field is also rejected: since the PRM + // endpoint may not share an origin with the protected resource, + // the response cannot be implicitly trusted without an explicit + // binding. + // + // The check is scoped to the advertised path because the + // well-known origin-constructed path is already bound to the + // protected resource by same-origin URL construction. + if explicitMetadataURL { + if protectedResource.Resource == "" { + return metadataDiscoveryResult{}, fmt.Errorf( + "advertised protected resource metadata from %q omits required resource field", + protectedResourceURL, + ) + } + if !resourceIdentifiersEqual(protectedResource.Resource, baseURL) { + return metadataDiscoveryResult{}, fmt.Errorf( + "advertised protected resource metadata declares resource %q which does not match base URL %q", + protectedResource.Resource, baseURL, + ) + } + } + + // RFC 8707: Capture the resource identifier for use in authorization requests. + // If not provided in metadata, fall back to base URL per RFC 8707 Section 2: + // "The client SHOULD use the base URI of the API as the resource parameter value + // unless specific knowledge of the resource dictates otherwise." + resourceURL := protectedResource.Resource + if resourceURL == "" { + resourceURL = baseURL + } + + // If no authorization servers are specified, fall back to default endpoints + if len(protectedResource.AuthorizationServers) == 0 { + metadata, err := h.getDefaultEndpoints(baseURL) + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to get default endpoints: %w", err) + } + return metadataDiscoveryResult{metadata: metadata, resourceURL: resourceURL}, nil + } + + // Use the first authorization server + authServerURL := protectedResource.AuthorizationServers[0] + + // Try the MCP-specified discovery URLs in order (RFC 8414 with path + // insertion, plus OpenID Connect Discovery variants). + for _, u := range authorizationServerMetadataURLs(authServerURL) { + // Intermediate fetch errors are intentionally discarded so the caller + // can fall through to the next candidate URL. + if metadata, _ := h.fetchMetadataFromURL(ctx, u); metadata != nil { + return metadataDiscoveryResult{metadata: metadata, resourceURL: resourceURL}, nil + } + } + + // If both discovery methods fail, use default endpoints based on the authorization server URL + metadata, err := h.getDefaultEndpoints(authServerURL) + if err != nil { + return metadataDiscoveryResult{}, fmt.Errorf("failed to get default endpoints: %w", err) + } + return metadataDiscoveryResult{metadata: metadata, resourceURL: resourceURL}, nil +} + +// buildWellKnownURL constructs a well-known discovery URL by inserting the +// given suffix between the authority and path of baseURL (RFC 8414 §3 / +// RFC 9728 path-insertion semantics). +func buildWellKnownURL(baseURL string, suffix string) (string, error) { + parsedURL, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("failed to parse base URL: %w", err) + } + + if parsedURL.Scheme == "" || parsedURL.Host == "" { + return "", fmt.Errorf("invalid base URL: missing scheme or host in %q", baseURL) + } + + path := strings.TrimSuffix(parsedURL.EscapedPath(), "/") + root := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) + if path == "" || path == "/" { + return root + "/.well-known/" + suffix, nil + } + + return root + "/.well-known/" + suffix + path, nil +} + +// resourceIdentifiersEqual reports whether two OAuth protected resource +// identifiers refer to the same resource for the purposes of RFC 9728 §3.3 +// equality checks. Scheme and host are compared case-insensitively per +// RFC 3986 §3.1 / §3.2.2, and a single trailing slash on either path is +// ignored because real-world OAuth deployments routinely emit the +// resource with or without it for the same URL; rejecting that variant +// would produce false positives on legitimate servers. Query, fragment, +// and userinfo components are significant. Unparseable inputs fall back +// to exact string equality. +func resourceIdentifiersEqual(a, b string) bool { + ua, errA := url.Parse(a) + ub, errB := url.Parse(b) + if errA != nil || errB != nil { + return a == b + } + if !strings.EqualFold(ua.Scheme, ub.Scheme) { + return false + } + if !strings.EqualFold(ua.Host, ub.Host) { + return false + } + // Use EscapedPath rather than Path so percent-encoded reserved + // characters stay distinct from their decoded forms (e.g. "a%2Fb" + // must not compare equal to "a/b"), preserving RFC 3986 segment + // semantics. + if strings.TrimSuffix(ua.EscapedPath(), "/") != strings.TrimSuffix(ub.EscapedPath(), "/") { + return false + } + if ua.RawQuery != ub.RawQuery { + return false + } + if ua.Fragment != ub.Fragment { + return false + } + return ua.User.String() == ub.User.String() +} + +// fetchMetadataFromURL fetches and parses OAuth server metadata from a URL. +// Returns (nil, nil) when the server responds with a non-200 status so the +// caller can fall through to the next candidate URL. Network, decode, and +// validation failures are returned to the caller. +// +// This function performs HTTP I/O and must not be called while holding +// metadataMu (see #871). +func (h *OAuthHandler) fetchMetadataFromURL(ctx context.Context, metadataURL string) (*AuthServerMetadata, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create metadata request: %w", err) + } + + req.Header.Set("Accept", "application/json") + // Advertise the latest protocol version this client supports. Metadata + // discovery happens before the MCP initialize handshake, so no negotiated + // version is available yet; servers that don't recognise it can fall back + // per the MCP spec. + req.Header.Set("MCP-Protocol-Version", mcp.LATEST_PROTOCOL_VERSION) + + resp, err := h.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send metadata request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, nil + } + + var metadata AuthServerMetadata + dec := json.NewDecoder(io.LimitReader(resp.Body, maxMetadataBodyBytes)) + if err := dec.Decode(&metadata); err != nil { + return nil, fmt.Errorf("failed to decode metadata response: %w", err) + } + + if err := validateAuthServerMetadataURLs(&metadata); err != nil { + return nil, fmt.Errorf("invalid authorization server metadata from %s: %w", metadataURL, err) + } + + return &metadata, nil +} + +// validateAuthServerMetadataURLs ensures every URL-bearing field in m uses an +// http or https scheme. This prevents a hostile authorization server from +// advertising values like "javascript:..." or "file:..." that could be +// reflected into a browser or the local file system by downstream consumers. +// Empty optional fields are permitted. +func validateAuthServerMetadataURLs(m *AuthServerMetadata) error { + fields := []struct { + name string + value string + }{ + {"issuer", m.Issuer}, + {"authorization_endpoint", m.AuthorizationEndpoint}, + {"token_endpoint", m.TokenEndpoint}, + {"jwks_uri", m.JwksURI}, + {"registration_endpoint", m.RegistrationEndpoint}, + {"service_documentation", m.ServiceDocumentation}, + {"op_policy_uri", m.OpPolicyURI}, + {"op_tos_uri", m.OpTOSURI}, + {"revocation_endpoint", m.RevocationEndpoint}, + {"introspection_endpoint", m.IntrospectionEndpoint}, + } + for _, f := range fields { + if f.value == "" { + continue + } + u, err := url.Parse(f.value) + if err != nil { + return fmt.Errorf("%s: %w", f.name, err) + } + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + return fmt.Errorf("%s has disallowed scheme %q", f.name, u.Scheme) + } + if u.Host == "" { + return fmt.Errorf("%s is missing host", f.name) + } + } + return nil +} + +// authorizationServerMetadataURLs returns the ordered list of discovery URLs to +// try for a given issuer URL, following the MCP authorization spec. This +// implements RFC 8414 §3 path-insertion semantics (the well-known segment is +// inserted between the authority and the issuer path, not appended), plus +// OpenID Connect Discovery 1.0 fallbacks. +func authorizationServerMetadataURLs(issuerURL string) []string { + u, err := url.Parse(issuerURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil + } + u.RawQuery = "" + u.Fragment = "" + + originalPath := strings.Trim(u.Path, "/") + + var urls []string + if originalPath == "" { + u.Path = "/.well-known/oauth-authorization-server" + urls = append(urls, u.String()) + u.Path = "/.well-known/openid-configuration" + urls = append(urls, u.String()) + return urls + } + u.Path = "/.well-known/oauth-authorization-server/" + originalPath + urls = append(urls, u.String()) + u.Path = "/.well-known/openid-configuration/" + originalPath + urls = append(urls, u.String()) + u.Path = "/" + originalPath + "/.well-known/openid-configuration" + urls = append(urls, u.String()) + return urls +} + +// extractBaseURL extracts the base URL from the first request +func (h *OAuthHandler) extractBaseURL() (string, error) { + // If we have a base URL from a previous request, use it + if h.baseURL != "" { + return h.baseURL, nil + } + + // Otherwise, we need to infer it from the redirect URI + if h.config.RedirectURI == "" { + return "", fmt.Errorf("no base URL available and no redirect URI provided") + } + + // Parse the redirect URI to extract the authority + parsedURL, err := url.Parse(h.config.RedirectURI) + if err != nil { + return "", fmt.Errorf("failed to parse redirect URI: %w", err) + } + + // Use the scheme and host from the redirect URI + baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) + return baseURL, nil +} + +// GetServerMetadata is a public wrapper for getServerMetadata +func (h *OAuthHandler) GetServerMetadata(ctx context.Context) (*AuthServerMetadata, error) { + return h.getServerMetadata(ctx) +} + +// getDefaultEndpoints returns default OAuth endpoints based on the base URL +func (h *OAuthHandler) getDefaultEndpoints(baseURL string) (*AuthServerMetadata, error) { + // Parse the base URL to extract the authority + parsedURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("failed to parse base URL: %w", err) + } + + // Discard any path component to get the authorization base URL + parsedURL.Path = "" + authBaseURL := parsedURL.String() + + // Validate that the URL has a scheme and host + if parsedURL.Scheme == "" || parsedURL.Host == "" { + return nil, fmt.Errorf("invalid base URL: missing scheme or host in %q", baseURL) + } + + return &AuthServerMetadata{ + Issuer: authBaseURL, + AuthorizationEndpoint: authBaseURL + "/authorize", + TokenEndpoint: authBaseURL + "/token", + RegistrationEndpoint: authBaseURL + "/register", + }, nil +} + +// RegisterClient performs dynamic client registration +func (h *OAuthHandler) RegisterClient(ctx context.Context, clientName string) error { + metadata, err := h.getServerMetadata(ctx) + if err != nil { + return fmt.Errorf("failed to get server metadata: %w", err) + } + + if metadata.RegistrationEndpoint == "" { + return errors.New("server does not support dynamic client registration") + } + + // Prepare registration request + regRequest := map[string]any{ + "client_name": clientName, + "redirect_uris": []string{h.config.RedirectURI}, + "token_endpoint_auth_method": "none", // For public clients + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "scope": strings.Join(h.config.Scopes, " "), + } + + if h.config.ClientURI != "" { + regRequest["client_uri"] = h.config.ClientURI + } + + // Add client_secret if this is a confidential client + if h.config.ClientSecret != "" { + regRequest["token_endpoint_auth_method"] = "client_secret_post" + } + + // RFC 8707: Include resource parameter in client registration + if resourceURL := h.getResourceURL(); resourceURL != "" { + regRequest["resource"] = resourceURL + } + + reqBody, err := json.Marshal(regRequest) + if err != nil { + return fmt.Errorf("failed to marshal registration request: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + metadata.RegistrationEndpoint, + bytes.NewReader(reqBody), + ) + if err != nil { + return fmt.Errorf("failed to create registration request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := h.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send registration request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return extractOAuthError(body, resp.StatusCode, "registration request failed") + } + + var regResponse struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + } + + if err := json.NewDecoder(resp.Body).Decode(®Response); err != nil { + return fmt.Errorf("failed to decode registration response: %w", err) + } + + // Update the client configuration + h.config.ClientID = regResponse.ClientID + if regResponse.ClientSecret != "" { + h.config.ClientSecret = regResponse.ClientSecret + } + + return nil +} + +// ErrInvalidState is returned when the state parameter doesn't match the expected value +var ErrInvalidState = errors.New("invalid state parameter, possible CSRF attack") + +// ProcessAuthorizationResponse processes the authorization response and exchanges the code for a token +func (h *OAuthHandler) ProcessAuthorizationResponse(ctx context.Context, code, state, codeVerifier string) error { + // Validate the state parameter to prevent CSRF attacks + h.mu.Lock() + expectedState := h.expectedState + if expectedState == "" { + h.mu.Unlock() + return errors.New("no expected state found, authorization flow may not have been initiated properly") + } + + if state != expectedState { + h.mu.Unlock() + return ErrInvalidState + } + + // Clear the expected state after validation + h.expectedState = "" + h.mu.Unlock() + + metadata, err := h.getServerMetadata(ctx) + if err != nil { + return fmt.Errorf("failed to get server metadata: %w", err) + } + + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("client_id", h.config.ClientID) + data.Set("redirect_uri", h.config.RedirectURI) + + if h.config.ClientSecret != "" { + data.Set("client_secret", h.config.ClientSecret) + } + + if h.config.PKCEEnabled && codeVerifier != "" { + data.Set("code_verifier", codeVerifier) + } + + // RFC 8707: Include resource parameter in token exchange + if resourceURL := h.getResourceURL(); resourceURL != "" { + data.Set("resource", resourceURL) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + metadata.TokenEndpoint, + strings.NewReader(data.Encode()), + ) + if err != nil { + return fmt.Errorf("failed to create token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := h.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send token request: %w", err) + } + defer resp.Body.Close() + + // Accept any 2xx status; some authorization servers (e.g. Supabase) return + // 201 Created for successful token responses. + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return extractOAuthError(body, resp.StatusCode, "token request failed") + } + + // Read the response body for parsing + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read token response body: %w", err) + } + + // GitHub returns HTTP 200 even for errors, with error details in the JSON body + // Check if the response contains an error field before parsing as Token + var oauthErr OAuthError + if err := json.Unmarshal(body, &oauthErr); err == nil && oauthErr.ErrorCode != "" { + return fmt.Errorf("token request failed: %w", oauthErr) + } + + var tokenResp Token + if err := json.Unmarshal(body, &tokenResp); err != nil { + return fmt.Errorf("failed to decode token response: %w", err) + } + + // Set expiration time + if tokenResp.ExpiresIn > 0 { + tokenResp.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + } + + // Save the token + if err := h.config.TokenStore.SaveToken(ctx, &tokenResp); err != nil { + return fmt.Errorf("failed to save token: %w", err) + } + + return nil +} + +// GetAuthorizationURL returns the URL for the authorization endpoint +func (h *OAuthHandler) GetAuthorizationURL(ctx context.Context, state, codeChallenge string) (string, error) { + metadata, err := h.getServerMetadata(ctx) + if err != nil { + return "", fmt.Errorf("failed to get server metadata: %w", err) + } + + // Store the state for later validation + h.SetExpectedState(state) + + params := url.Values{} + params.Set("response_type", "code") + params.Set("client_id", h.config.ClientID) + params.Set("redirect_uri", h.config.RedirectURI) + params.Set("state", state) + + if len(h.config.Scopes) > 0 { + params.Set("scope", strings.Join(h.config.Scopes, " ")) + } + + if h.config.PKCEEnabled && codeChallenge != "" { + params.Set("code_challenge", codeChallenge) + params.Set("code_challenge_method", "S256") + } + + // RFC 8707: Include resource parameter in authorization URL + if resourceURL := h.getResourceURL(); resourceURL != "" { + params.Set("resource", resourceURL) + } + + return metadata.AuthorizationEndpoint + "?" + params.Encode(), nil +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/oauth_utils.go b/vendor/github.com/mark3labs/mcp-go/client/transport/oauth_utils.go new file mode 100644 index 000000000..d87525a65 --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/oauth_utils.go @@ -0,0 +1,68 @@ +package transport + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/url" +) + +// GenerateRandomString generates a random string of the specified length +func GenerateRandomString(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes)[:length], nil +} + +// GenerateCodeVerifier generates a code verifier for PKCE +func GenerateCodeVerifier() (string, error) { + // According to RFC 7636, the code verifier should be between 43 and 128 characters + return GenerateRandomString(64) +} + +// GenerateCodeChallenge generates a code challenge from a code verifier +func GenerateCodeChallenge(codeVerifier string) string { + // SHA256 hash the code verifier + hash := sha256.Sum256([]byte(codeVerifier)) + // Base64url encode the hash + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +// GenerateState generates a state parameter for OAuth +func GenerateState() (string, error) { + return GenerateRandomString(32) +} + +// ValidateRedirectURI validates that a redirect URI is secure +func ValidateRedirectURI(redirectURI string) error { + // According to the spec, redirect URIs must be either localhost URLs or HTTPS URLs + if redirectURI == "" { + return fmt.Errorf("redirect URI cannot be empty") + } + + // Parse the URL + parsedURL, err := url.Parse(redirectURI) + if err != nil { + return fmt.Errorf("invalid redirect URI: %w", err) + } + + // Check if it's a localhost URL + if parsedURL.Scheme == "http" { + hostname := parsedURL.Hostname() + // Check for various forms of localhost + if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" || hostname == "[::1]" { + return nil + } + return fmt.Errorf("HTTP redirect URI must use localhost or 127.0.0.1") + } + + // Check if it's an HTTPS URL + if parsedURL.Scheme == "https" { + return nil + } + + return fmt.Errorf("redirect URI must use either HTTP with localhost or HTTPS") +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/sse.go b/vendor/github.com/mark3labs/mcp-go/client/transport/sse.go new file mode 100644 index 000000000..ec30fe23e --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/sse.go @@ -0,0 +1,767 @@ +package transport + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +// SSE implements the transport layer of the MCP protocol using Server-Sent Events (SSE). +// It maintains a persistent HTTP connection to receive server-pushed events +// while sending requests over regular HTTP POST calls. The client handles +// automatic reconnection and message routing between requests and responses. +type SSE struct { + baseURL *url.URL + endpoint *url.URL + httpClient *http.Client + responses map[string]chan *JSONRPCResponse + mu sync.RWMutex + onNotification func(mcp.JSONRPCNotification) + notifyMu sync.RWMutex + endpointChan chan struct{} + headers map[string]string + headerFunc HTTPHeaderFunc + host string + logger *slog.Logger + + started atomic.Bool + closed atomic.Bool + cancelSSEStream context.CancelFunc + protocolVersion atomic.Value // string + onConnectionLost func(error) + connectionLostMu sync.RWMutex + + endpointTimeout time.Duration + responseTimeout time.Duration + + // OAuth support + oauthHandler *OAuthHandler +} + +// ClientOption configures an SSE transport client. +type ClientOption func(*SSE) + +// WithSSELogger sets a custom structured logger for the SSE client. +// A nil logger falls back to slog.Default(). +func WithSSELogger(logger *slog.Logger) ClientOption { + return func(sc *SSE) { + if logger == nil { + sc.logger = slog.Default() + return + } + sc.logger = logger + } +} + +// WithHeaders sets static headers for SSE HTTP requests. +func WithHeaders(headers map[string]string) ClientOption { + return func(sc *SSE) { + sc.headers = headers + } +} + +// WithHeaderFunc sets a function that returns headers for SSE HTTP requests. +func WithHeaderFunc(headerFunc HTTPHeaderFunc) ClientOption { + return func(sc *SSE) { + sc.headerFunc = headerFunc + } +} + +// WithHTTPClient sets a custom HTTP client for the SSE transport. +func WithHTTPClient(httpClient *http.Client) ClientOption { + return func(sc *SSE) { + sc.httpClient = httpClient + } +} + +// WithOAuth enables OAuth authentication for the SSE transport. +func WithOAuth(config OAuthConfig) ClientOption { + return func(sc *SSE) { + sc.oauthHandler = NewOAuthHandler(config) + } +} + +// WithEndpointTimeout sets the maximum time to wait for the SSE endpoint to be +// received during Start(). Defaults to 30 seconds. If the context has a shorter +// deadline, the shorter value is used. +func WithEndpointTimeout(d time.Duration) ClientOption { + return func(sc *SSE) { + if d > 0 { + sc.endpointTimeout = d + } + } +} + +// WithResponseTimeout sets the maximum time to wait for an SSE response after +// sending a request. Defaults to 60 seconds. If the context has a shorter +// deadline, the shorter value is used. +func WithResponseTimeout(d time.Duration) ClientOption { + return func(sc *SSE) { + if d > 0 { + sc.responseTimeout = d + } + } +} + +// WithHTTPHost sets a custom Host header for the SSE client, enabling manual DNS resolution. +// This allows connecting to an IP address while sending a specific Host header to the server. +// For example, connecting to "http://192.168.1.100:8080/sse" but sending Host: "api.example.com" +func WithHTTPHost(host string) ClientOption { + return func(sc *SSE) { + sc.host = host + } +} + +// NewSSE creates a new SSE-based MCP client with the given base URL. +// Returns an error if the URL is invalid. +func NewSSE(baseURL string, options ...ClientOption) (*SSE, error) { + parsedURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + smc := &SSE{ + baseURL: parsedURL, + httpClient: &http.Client{}, + responses: make(map[string]chan *JSONRPCResponse), + endpointChan: make(chan struct{}), + headers: make(map[string]string), + logger: slog.Default(), + endpointTimeout: 30 * time.Second, + responseTimeout: 60 * time.Second, + } + + for _, opt := range options { + opt(smc) + } + + // If OAuth is configured, set the base URL for metadata discovery + if smc.oauthHandler != nil { + discoveryURL := *parsedURL + discoveryURL.RawQuery = "" + discoveryURL.Fragment = "" + baseURL := discoveryURL.String() + smc.oauthHandler.SetBaseURL(baseURL) + } + + return smc, nil +} + +// Start initiates the SSE connection to the server and waits for the endpoint information. +// Returns an error if the connection fails or times out waiting for the endpoint. +func (c *SSE) Start(ctx context.Context) error { + if c.started.Load() { + return nil + } + + ctx, cancel := context.WithCancel(ctx) + c.cancelSSEStream = cancel + + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL.String(), nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Set custom Host header if provided + if c.host != "" { + req.Host = c.host + } + + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Connection", "keep-alive") + + // set custom http headers + for k, v := range c.headers { + req.Header.Set(k, v) + } + if c.headerFunc != nil { + for k, v := range c.headerFunc(ctx) { + req.Header.Set(k, v) + } + } + + // Add OAuth authorization if configured + if c.oauthHandler != nil { + authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx) + if err != nil { + // If we get an authorization error, return a specific error that can be handled by the client + if errors.Is(err, ErrOAuthAuthorizationRequired) { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, + } + } + return fmt.Errorf("failed to get authorization header: %w", err) + } + req.Header.Set("Authorization", authHeader) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to connect to SSE stream: %w", err) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Values("WWW-Authenticate")) + + // Feed discovered URL back to OAuthHandler so next auth attempt uses it. + // HandleUnauthorizedResponse applies RFC 9728 origin validation — a + // compromised resource advertising a cross-origin PRM URL is ignored. + if c.oauthHandler != nil { + c.oauthHandler.HandleUnauthorizedResponse(resp) + } + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + } + } + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + go c.readSSE(resp.Body) + + // Wait for the endpoint to be received + endpointTimeout := c.endpointTimeout + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + // If context deadline has already passed, return immediately + if remaining <= 0 { + cancel() + return ctx.Err() + } + // Use the shorter of remaining time or default timeout + if remaining < endpointTimeout { + endpointTimeout = remaining + } + } + + timer := time.NewTimer(endpointTimeout) + defer timer.Stop() + + select { + case <-c.endpointChan: + // Endpoint received, proceed + case <-ctx.Done(): + return fmt.Errorf("context cancelled while waiting for endpoint: %w", ctx.Err()) + case <-timer.C: + cancel() + return fmt.Errorf("timeout waiting for endpoint after %v", endpointTimeout) + } + + c.started.Store(true) + return nil +} + +// readSSE continuously reads the SSE stream and processes events. +// It runs until the connection is closed or an error occurs. +func (c *SSE) readSSE(reader io.ReadCloser) { + defer reader.Close() + + br := bufio.NewReader(reader) + var event, data string + + for { + // when close or start's ctx cancel, the reader will be closed + // and the for loop will break. + line, err := br.ReadString('\n') + if err != nil { + if err == io.EOF { + // Process any pending event before exit + if data != "" { + // If no event type is specified, use empty string (default event type) + if event == "" { + event = "message" + } + c.handleSSEEvent(event, data) + } + } + c.connectionLostMu.RLock() + handler := c.onConnectionLost + c.connectionLostMu.RUnlock() + if handler != nil { + // Notify that the connection will be closed due to an error + handler(err) + } else if err == io.EOF && !c.closed.Load() { + c.logger.Error("SSE stream error", "err", err) + } + return + } + + // Remove only newline markers + line = strings.TrimRight(line, "\r\n") + if line == "" { + // Empty line means end of event + if data != "" { + // If no event type is specified, use empty string (default event type) + if event == "" { + event = "message" + } + c.handleSSEEvent(event, data) + event = "" + data = "" + } + continue + } + + if after, ok := strings.CutPrefix(line, "event:"); ok { + event = strings.TrimSpace(after) + } else if after, ok := strings.CutPrefix(line, "data:"); ok { + data = appendSSEData(data, after) + } + } +} + +// appendSSEData joins the value of an SSE "data:" field onto any data already +// accumulated for the current event. Per the Server-Sent Events specification, +// a single event may carry multiple "data:" lines and they must be +// concatenated with a newline. The previous implementation overwrote the +// buffer on every line, silently truncating multi-line payloads to their final +// line and corrupting JSON-RPC messages that span more than one data line. +// +// Only a single optional leading space after "data:" is removed, as the spec +// requires; any other leading or trailing whitespace in the value is +// significant and preserved. +func appendSSEData(existing, line string) string { + value := strings.TrimPrefix(line, " ") + if existing == "" { + return value + } + return existing + "\n" + value +} + +// handleSSEEvent processes SSE events based on their type. +// Handles 'endpoint' events for connection setup and 'message' events for JSON-RPC communication. +func (c *SSE) handleSSEEvent(event, data string) { + switch event { + case "endpoint": + endpoint, err := c.baseURL.Parse(data) + if err != nil { + c.logger.Error("Error parsing endpoint URL", "err", err) + return + } + if endpoint.Host != c.baseURL.Host { + c.logger.Error("Endpoint origin does not match connection origin") + return + } + c.endpoint = endpoint + close(c.endpointChan) + + case "message": + var baseMessage JSONRPCResponse + if err := json.Unmarshal([]byte(data), &baseMessage); err != nil { + c.logger.Error("Error unmarshaling message", "err", err) + return + } + + // Handle notification + if baseMessage.ID.IsNil() { + var notification mcp.JSONRPCNotification + if err := json.Unmarshal([]byte(data), ¬ification); err != nil { + return + } + c.notifyMu.RLock() + if c.onNotification != nil { + c.onNotification(notification) + } + c.notifyMu.RUnlock() + return + } + + // Create string key for map lookup + idKey := baseMessage.ID.String() + + c.mu.RLock() + ch, exists := c.responses[idKey] + c.mu.RUnlock() + + if exists { + ch <- &baseMessage + c.mu.Lock() + delete(c.responses, idKey) + c.mu.Unlock() + } + } +} + +// SetNotificationHandler sets the handler for incoming JSON-RPC notifications. +func (c *SSE) SetNotificationHandler(handler func(notification mcp.JSONRPCNotification)) { + c.notifyMu.Lock() + defer c.notifyMu.Unlock() + c.onNotification = handler +} + +// SetConnectionLostHandler sets the handler called when the SSE connection is lost. +func (c *SSE) SetConnectionLostHandler(handler func(error)) { + c.connectionLostMu.Lock() + defer c.connectionLostMu.Unlock() + c.onConnectionLost = handler +} + +// SendRequest sends a JSON-RPC request to the server and waits for a response. +// Returns the raw JSON response message or an error if the request fails. +func (c *SSE) SendRequest( + ctx context.Context, + request JSONRPCRequest, +) (*JSONRPCResponse, error) { + if !c.started.Load() { + return nil, fmt.Errorf("transport not started yet") + } + if c.closed.Load() { + return nil, fmt.Errorf("transport has been closed") + } + if c.endpoint == nil { + return nil, fmt.Errorf("endpoint not received") + } + + // Marshal request + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint.String(), bytes.NewReader(requestBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + // Set protocol version header if negotiated + if v := c.protocolVersion.Load(); v != nil { + if version, ok := v.(string); ok && version != "" { + req.Header.Set(HeaderKeyProtocolVersion, version) + } + } + for k, v := range c.headers { + req.Header.Set(k, v) + } + + for k, v := range request.Header { + if _, ok := req.Header[k]; !ok { + req.Header[k] = v + } + } + + // Set custom Host header if provided + if c.host != "" { + req.Host = c.host + } + + // Add OAuth authorization if configured + if c.oauthHandler != nil { + authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx) + if err != nil { + // If we get an authorization error, return a specific error that can be handled by the client + if errors.Is(err, ErrOAuthAuthorizationRequired) { + return nil, &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, + } + } + return nil, fmt.Errorf("failed to get authorization header: %w", err) + } + req.Header.Set("Authorization", authHeader) + } + + if c.headerFunc != nil { + for k, v := range c.headerFunc(ctx) { + req.Header.Set(k, v) + } + } + + // Create string key for map lookup + idKey := request.ID.String() + + // Register response channel + responseChan := make(chan *JSONRPCResponse, 1) + c.mu.Lock() + c.responses[idKey] = responseChan + c.mu.Unlock() + deleteResponseChan := func() { + c.mu.Lock() + delete(c.responses, idKey) + c.mu.Unlock() + } + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + deleteResponseChan() + return nil, fmt.Errorf("failed to send request: %w", err) + } + + // Drain any outstanding io + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + deleteResponseChan() + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Check if we got an error response + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + deleteResponseChan() + + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Values("WWW-Authenticate")) + + // Feed discovered URL back to OAuthHandler so next auth attempt uses it. + // HandleUnauthorizedResponse applies RFC 9728 origin validation — a + // compromised resource advertising a cross-origin PRM URL is ignored. + if c.oauthHandler != nil { + c.oauthHandler.HandleUnauthorizedResponse(resp) + } + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return nil, &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return nil, &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + } + } + + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, body) + } + + // Calculate response timeout + responseTimeout := c.responseTimeout + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + // Check if context deadline has already passed + if remaining <= 0 { + deleteResponseChan() + return nil, ctx.Err() + } + // Use the shorter of remaining time or default timeout + if remaining < responseTimeout { + responseTimeout = remaining + } + } + + timer := time.NewTimer(responseTimeout) + defer timer.Stop() + + select { + case <-ctx.Done(): + deleteResponseChan() + return nil, ctx.Err() + case <-timer.C: + // Timeout handling + deleteResponseChan() + return nil, fmt.Errorf("timeout waiting for SSE response after %v", responseTimeout) + case response, ok := <-responseChan: + if ok { + return response, nil + } + return nil, fmt.Errorf("connection has been closed") + } +} + +// Close shuts down the SSE client connection and cleans up any pending responses. +// Returns an error if the shutdown process fails. +func (c *SSE) Close() error { + if !c.closed.CompareAndSwap(false, true) { + return nil // Already closed + } + + if c.cancelSSEStream != nil { + // It could stop the sse stream body, to quit the readSSE loop immediately + // Also, it could quit start() immediately if not receiving the endpoint + c.cancelSSEStream() + } + + // Clean up any pending responses + c.mu.Lock() + for _, ch := range c.responses { + close(ch) + } + c.responses = make(map[string]chan *JSONRPCResponse) + c.mu.Unlock() + + return nil +} + +// GetSessionId returns the session ID of the transport. +// Since SSE does not maintain a session ID, it returns an empty string. +func (c *SSE) GetSessionId() string { + return "" +} + +// SetProtocolVersion sets the negotiated protocol version for this connection. +func (c *SSE) SetProtocolVersion(version string) { + c.protocolVersion.Store(version) +} + +// SendNotification sends a JSON-RPC notification to the server without expecting a response. +func (c *SSE) SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error { + if c.endpoint == nil { + return fmt.Errorf("endpoint not received") + } + + notificationBytes, err := json.Marshal(notification) + if err != nil { + return fmt.Errorf("failed to marshal notification: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + "POST", + c.endpoint.String(), + bytes.NewReader(notificationBytes), + ) + if err != nil { + return fmt.Errorf("failed to create notification request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + // Set protocol version header if negotiated + if v := c.protocolVersion.Load(); v != nil { + if version, ok := v.(string); ok && version != "" { + req.Header.Set(HeaderKeyProtocolVersion, version) + } + } + // Set custom HTTP headers + for k, v := range c.headers { + req.Header.Set(k, v) + } + + // Add OAuth authorization if configured + if c.oauthHandler != nil { + authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx) + if err != nil { + // If we get an authorization error, return a specific error that can be handled by the client + if errors.Is(err, ErrOAuthAuthorizationRequired) { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, + } + } + return fmt.Errorf("failed to get authorization header: %w", err) + } + req.Header.Set("Authorization", authHeader) + } + + if c.headerFunc != nil { + for k, v := range c.headerFunc(ctx) { + req.Header.Set(k, v) + } + } + + // Set custom Host header if provided + if c.host != "" { + req.Host = c.host + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Values("WWW-Authenticate")) + + // Feed discovered URL back to OAuthHandler so next auth attempt uses it. + // HandleUnauthorizedResponse applies RFC 9728 origin validation — a + // compromised resource advertising a cross-origin PRM URL is ignored. + if c.oauthHandler != nil { + c.oauthHandler.HandleUnauthorizedResponse(resp) + } + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + } + } + + // Handle other error responses + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf( + "notification failed with status %d: %s", + resp.StatusCode, + body, + ) + } + + return nil +} + +// GetEndpoint returns the current endpoint URL for the SSE connection. +func (c *SSE) GetEndpoint() *url.URL { + return c.endpoint +} + +// GetBaseURL returns the base URL set in the SSE constructor. +func (c *SSE) GetBaseURL() *url.URL { + return c.baseURL +} + +// GetOAuthHandler returns the OAuth handler if configured +func (c *SSE) GetOAuthHandler() *OAuthHandler { + return c.oauthHandler +} + +// IsOAuthEnabled returns true if OAuth is enabled +func (c *SSE) IsOAuthEnabled() bool { + return c.oauthHandler != nil +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go b/vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go new file mode 100644 index 000000000..11268f83c --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/stdio.go @@ -0,0 +1,592 @@ +package transport + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +var ( + // ErrTransportClosed is returned when attempting to send a request or notification + // to a transport that has already been closed. + ErrTransportClosed = errors.New("transport closed") + // ErrChildShutdownTimeout is returned when a stdio subprocess still has not exited + // after the forced shutdown path has been attempted. + ErrChildShutdownTimeout = errors.New("stdio child did not exit after forced shutdown") +) + +// Stdio implements the transport layer of the MCP protocol using stdio communication. +// It launches a subprocess and communicates with it via standard input/output streams +// using JSON-RPC messages. The client handles message routing between requests and +// responses, and supports asynchronous notifications. +type Stdio struct { + command string + args []string + env []string + + cmd *exec.Cmd + cmdFunc CommandFunc + stdin io.WriteCloser + stdinMu sync.Mutex + stdout *bufio.Reader + stderr io.ReadCloser + responses map[string]chan *JSONRPCResponse + mu sync.RWMutex + done chan struct{} + closeOnce sync.Once + closeCleanupOnce sync.Once + onNotification func(mcp.JSONRPCNotification) + notifyMu sync.RWMutex + onRequest RequestHandler + requestMu sync.RWMutex + ctx context.Context + ctxMu sync.RWMutex + logger *slog.Logger + started bool + startedMu sync.Mutex +} + +const ( + gracefulShutdownTimeout = 2 * time.Second + forceKillTimeout = 3 * time.Second +) + +func waitForProcessExit(waitErrCh <-chan error, timeout time.Duration) (error, bool) { + select { + case err := <-waitErrCh: + return err, true + case <-time.After(timeout): + return nil, false + } +} + +// StdioOption defines a function that configures a Stdio transport instance. +// Options can be used to customize the behavior of the transport before it starts, +// such as setting a custom command function. +type StdioOption func(*Stdio) + +// CommandFunc is a factory function that returns a custom exec.Cmd used to launch the MCP subprocess. +// It can be used to apply sandboxing, custom environment control, working directories, etc. +type CommandFunc func(ctx context.Context, command string, env []string, args []string) (*exec.Cmd, error) + +// WithCommandFunc sets a custom command factory function for the stdio transport. +// The CommandFunc is responsible for constructing the exec.Cmd used to launch the subprocess, +// allowing control over attributes like environment, working directory, and system-level sandboxing. +func WithCommandFunc(f CommandFunc) StdioOption { + return func(s *Stdio) { + s.cmdFunc = f + } +} + +// WithCommandLogger sets a custom structured logger for the stdio transport. +// A nil logger falls back to slog.Default(). +func WithCommandLogger(logger *slog.Logger) StdioOption { + return func(s *Stdio) { + if logger == nil { + s.logger = slog.Default() + return + } + s.logger = logger + } +} + +// NewIO returns a new stdio-based transport using existing input, output, and +// logging streams instead of spawning a subprocess. +// This is useful for testing and simulating client behavior. +func NewIO(input io.Reader, output io.WriteCloser, logging io.ReadCloser) *Stdio { + return &Stdio{ + stdin: output, + stdout: bufio.NewReader(input), + stderr: logging, + + responses: make(map[string]chan *JSONRPCResponse), + done: make(chan struct{}), + ctx: context.Background(), + logger: slog.Default(), + } +} + +// NewStdio creates a new stdio transport to communicate with a subprocess. +// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication. +// Returns an error if the subprocess cannot be started or the pipes cannot be created. +func NewStdio( + command string, + env []string, + args ...string, +) *Stdio { + return NewStdioWithOptions(command, env, args) +} + +// NewStdioWithOptions creates a new stdio transport to communicate with a subprocess. +// It launches the specified command with given arguments and sets up stdin/stdout pipes for communication. +// Returns an error if the subprocess cannot be started or the pipes cannot be created. +// Optional configuration functions can be provided to customize the transport before it starts, +// such as setting a custom command factory. +func NewStdioWithOptions( + command string, + env []string, + args []string, + opts ...StdioOption, +) *Stdio { + s := &Stdio{ + command: command, + args: args, + env: env, + + responses: make(map[string]chan *JSONRPCResponse), + done: make(chan struct{}), + ctx: context.Background(), + logger: slog.Default(), + } + + for _, opt := range opts { + opt(s) + } + + return s +} + +func (c *Stdio) Start(ctx context.Context) error { + c.startedMu.Lock() + if c.started { + c.startedMu.Unlock() + return nil + } + c.started = true + c.startedMu.Unlock() + + // Store the context for use in request handling + c.ctxMu.Lock() + c.ctx = ctx + c.ctxMu.Unlock() + + if err := c.spawnCommand(ctx); err != nil { + c.startedMu.Lock() + c.started = false + c.startedMu.Unlock() + return err + } + + ready := make(chan struct{}) + go func() { + c.readResponses(ready) + }() + <-ready + + return nil +} + +// spawnCommand spawns a new process running the configured command, args, and env. +// If an (optional) cmdFunc custom command factory function was configured, it will be used to construct the subprocess; +// otherwise, the default behavior uses exec.CommandContext with the merged environment. +// Initializes stdin, stdout, and stderr pipes for JSON-RPC communication. +func (c *Stdio) spawnCommand(ctx context.Context) error { + if c.command == "" { + return nil + } + + var cmd *exec.Cmd + var err error + + // Standard behavior if no command func present. + if c.cmdFunc == nil { + cmd = exec.CommandContext(ctx, c.command, c.args...) + cmd.Env = append(os.Environ(), c.env...) + } else if cmd, err = c.cmdFunc(ctx, c.command, c.env, c.args); err != nil { + return err + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to create stdin pipe: %w", err) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + c.cmd = cmd + c.stdin = stdin + c.stderr = stderr + c.stdout = bufio.NewReader(stdout) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start command: %w", err) + } + + return nil +} + +// closeDone safely closes the done channel exactly once, unblocking all +// in-flight SendRequest calls. Safe to call from multiple goroutines. +func (c *Stdio) closeDone() { + c.closeOnce.Do(func() { close(c.done) }) +} + +// Close shuts down the stdio client, closing the stdin pipe and waiting for the subprocess to exit. +// Returns an error if there are issues closing stdin or waiting for the subprocess to terminate. +// Safe to call multiple times and concurrently with readResponses calling closeDone(). +func (c *Stdio) Close() error { + // Signal all in-flight requests to unblock. + c.closeDone() + + // Perform resource cleanup exactly once, even if readResponses already + // called closeDone() (e.g. server died). Without this, the old early-return + // guard would skip stdin/stderr cleanup and cmd.Wait(), causing FD leaks + // and zombie processes. + var closeErr error + c.closeCleanupOnce.Do(func() { + if c.stdin != nil { + if err := c.stdin.Close(); err != nil { + closeErr = fmt.Errorf("failed to close stdin: %w", err) + } + } + if c.stderr != nil { + if err := c.stderr.Close(); err != nil && closeErr == nil { + closeErr = fmt.Errorf("failed to close stderr: %w", err) + } + } + if c.cmd != nil { + waitErrCh := make(chan error, 1) + go func() { + waitErrCh <- c.cmd.Wait() + }() + + if err, done := waitForProcessExit(waitErrCh, gracefulShutdownTimeout); done { + if err != nil && closeErr == nil { + closeErr = err + } + return + } + + if c.cmd.Process != nil { + _ = c.cmd.Process.Signal(syscall.SIGTERM) + } + + if err, done := waitForProcessExit(waitErrCh, forceKillTimeout); done { + if err != nil && closeErr == nil { + closeErr = err + } + return + } + + if c.cmd.Process != nil { + if err := c.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) && closeErr == nil { + closeErr = fmt.Errorf("failed to kill process: %w", err) + } + } + + if err, done := waitForProcessExit(waitErrCh, forceKillTimeout); done { + if err != nil && closeErr == nil { + closeErr = err + } + } else if closeErr == nil { + closeErr = ErrChildShutdownTimeout + } + } + }) + return closeErr +} + +// GetSessionId returns the session ID of the transport. +// Since stdio does not maintain a session ID, it returns an empty string. +func (c *Stdio) GetSessionId() string { + return "" +} + +// SetNotificationHandler sets the handler function to be called when a notification is received. +// Only one handler can be set at a time; setting a new one replaces the previous handler. +func (c *Stdio) SetNotificationHandler( + handler func(notification mcp.JSONRPCNotification), +) { + c.notifyMu.Lock() + defer c.notifyMu.Unlock() + c.onNotification = handler +} + +// SetRequestHandler sets the handler function to be called when a request is received from the server. +// This enables bidirectional communication for features like sampling. +func (c *Stdio) SetRequestHandler(handler RequestHandler) { + c.requestMu.Lock() + defer c.requestMu.Unlock() + c.onRequest = handler +} + +// readResponses continuously reads and processes responses from the server's stdout. +// It handles both responses to requests and notifications, routing them appropriately. +// Runs until the done channel is closed or an error occurs reading from stdout. +// The ready channel, if non-nil, is closed once the read loop is entered, signaling +// to Start() that the transport is actively processing responses. +func (c *Stdio) readResponses(ready chan struct{}) { + for { + // Signal readiness on the first iteration, inside the loop, so that + // Start() only unblocks after the reader is actively processing. + if ready != nil { + close(ready) + ready = nil + } + select { + case <-c.done: + return + default: + line, err := c.stdout.ReadString('\n') + if err != nil { + if err != io.EOF && !errors.Is(err, context.Canceled) && !errors.Is(err, fs.ErrClosed) { + c.logger.Error("Error reading from stdout", "err", err) + } + // Signal done so in-flight SendRequest calls unblock + // instead of hanging forever when the server dies. + c.closeDone() + return + } + + line = strings.TrimRight(line, "\r\n") + // First try to parse as a generic message to check for ID field + var baseMessage struct { + JSONRPC string `json:"jsonrpc"` + ID *mcp.RequestId `json:"id,omitempty"` + Method string `json:"method,omitempty"` + } + if err := json.Unmarshal([]byte(line), &baseMessage); err != nil { + continue + } + + // If it has a method but no ID, it's a notification + if baseMessage.Method != "" && baseMessage.ID == nil { + var notification mcp.JSONRPCNotification + if err := json.Unmarshal([]byte(line), ¬ification); err != nil { + continue + } + c.notifyMu.RLock() + if c.onNotification != nil { + c.onNotification(notification) + } + c.notifyMu.RUnlock() + continue + } + + // If it has a method and an ID, it's an incoming request + if baseMessage.Method != "" && baseMessage.ID != nil { + var request JSONRPCRequest + if err := json.Unmarshal([]byte(line), &request); err == nil { + c.handleIncomingRequest(request) + continue + } + } + + // Otherwise, it's a response to our request + var response JSONRPCResponse + if err := json.Unmarshal([]byte(line), &response); err != nil { + continue + } + + // Create string key for map lookup + idKey := response.ID.String() + + c.mu.RLock() + ch, exists := c.responses[idKey] + c.mu.RUnlock() + + if exists { + ch <- &response + c.mu.Lock() + delete(c.responses, idKey) + c.mu.Unlock() + } + } + } +} + +// SendRequest sends a JSON-RPC request to the server and waits for a response. +// It creates a unique request ID, sends the request over stdin, and waits for +// the corresponding response or context cancellation. +// Returns the raw JSON response message or an error if the request fails. +func (c *Stdio) SendRequest( + ctx context.Context, + request JSONRPCRequest, +) (*JSONRPCResponse, error) { + // Check if transport is closed or context is already canceled before doing any work + select { + case <-c.done: + return nil, ErrTransportClosed + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + if c.stdin == nil { + return nil, fmt.Errorf("stdio client not started") + } + + // Marshal request + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + requestBytes = append(requestBytes, '\n') + + // Create string key for map lookup + idKey := request.ID.String() + + // Register response channel + responseChan := make(chan *JSONRPCResponse, 1) + c.mu.Lock() + c.responses[idKey] = responseChan + c.mu.Unlock() + deleteResponseChan := func() { + c.mu.Lock() + delete(c.responses, idKey) + c.mu.Unlock() + } + + // Send request. stdinMu serializes frame writes so concurrent + // SendRequest/SendNotification/sendResponse calls cannot interleave + // JSON-RPC lines on the subprocess's stdin. + c.stdinMu.Lock() + _, err = c.stdin.Write(requestBytes) + c.stdinMu.Unlock() + if err != nil { + deleteResponseChan() + return nil, fmt.Errorf("failed to write request: %w", err) + } + + select { + case <-c.done: + // Drain responseChan first: a valid response may have been delivered + // just before readResponses closed the done channel on EOF. + select { + case response := <-responseChan: + return response, nil + default: + } + deleteResponseChan() + return nil, ErrTransportClosed + case <-ctx.Done(): + deleteResponseChan() + return nil, ctx.Err() + case response := <-responseChan: + return response, nil + } +} + +// SendNotification sends a json RPC Notification to the server. +func (c *Stdio) SendNotification( + ctx context.Context, + notification mcp.JSONRPCNotification, +) error { + select { + case <-c.done: + return ErrTransportClosed + case <-ctx.Done(): + return ctx.Err() + default: + } + + if c.stdin == nil { + return fmt.Errorf("stdio client not started") + } + + notificationBytes, err := json.Marshal(notification) + if err != nil { + return fmt.Errorf("failed to marshal notification: %w", err) + } + notificationBytes = append(notificationBytes, '\n') + + c.stdinMu.Lock() + _, err = c.stdin.Write(notificationBytes) + c.stdinMu.Unlock() + if err != nil { + return fmt.Errorf("failed to write notification: %w", err) + } + + return nil +} + +// handleIncomingRequest processes incoming requests from the server. +// It calls the registered request handler and sends the response back to the server. +func (c *Stdio) handleIncomingRequest(request JSONRPCRequest) { + c.requestMu.RLock() + handler := c.onRequest + c.requestMu.RUnlock() + + if handler == nil { + // Send error response if no handler is configured + errorResponse := *NewJSONRPCErrorResponse( + request.ID, + mcp.METHOD_NOT_FOUND, + "No request handler configured", + nil, + ) + c.sendResponse(errorResponse) + return + } + + // Handle the request in a goroutine to avoid blocking + go func() { + c.ctxMu.RLock() + ctx := c.ctx + c.ctxMu.RUnlock() + + // Check if context is already cancelled before processing + select { + case <-ctx.Done(): + errorResponse := *NewJSONRPCErrorResponse(request.ID, mcp.INTERNAL_ERROR, ctx.Err().Error(), nil) + c.sendResponse(errorResponse) + return + default: + } + + response, err := handler(ctx, request) + if err != nil { + errorResponse := *NewJSONRPCErrorResponse(request.ID, mcp.INTERNAL_ERROR, err.Error(), nil) + c.sendResponse(errorResponse) + return + } + + if response != nil { + c.sendResponse(*response) + } + }() +} + +// sendResponse sends a response back to the server. +func (c *Stdio) sendResponse(response JSONRPCResponse) { + responseBytes, err := json.Marshal(response) + if err != nil { + c.logger.Error("Error marshaling response", "err", err) + return + } + responseBytes = append(responseBytes, '\n') + + c.stdinMu.Lock() + _, err = c.stdin.Write(responseBytes) + c.stdinMu.Unlock() + if err != nil { + c.logger.Error("Error writing response", "err", err) + } +} + +// Stderr returns a reader for the stderr output of the subprocess. +// This can be used to capture error messages or logs from the subprocess. +func (c *Stdio) Stderr() io.Reader { + return c.stderr +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go b/vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go new file mode 100644 index 000000000..75ebf814f --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/streamable_http.go @@ -0,0 +1,1154 @@ +package transport + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +// StreamableHTTPCOption configures a StreamableHTTP transport client. +type StreamableHTTPCOption func(*StreamableHTTP) + +// WithContinuousListening enables receiving server-to-client notifications when no request is in flight. +// In particular, if you want to receive global notifications from the server (like ToolListChangedNotification), +// you should enable this option. +// +// It will establish a standalone long-live GET HTTP connection to the server. +// https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#listening-for-messages-from-the-server +// NOTICE: Even enabled, the server may not support this feature. +func WithContinuousListening() StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.getListeningEnabled = true + } +} + +// WithHTTPBasicClient sets a custom HTTP client on the StreamableHTTP transport. +func WithHTTPBasicClient(client *http.Client) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.httpClient = client + } +} + +// WithHTTPHeaders sets static headers for StreamableHTTP requests. +func WithHTTPHeaders(headers map[string]string) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.headers = headers + } +} + +// WithHTTPHeaderFunc sets a function that returns headers for StreamableHTTP requests. +func WithHTTPHeaderFunc(headerFunc HTTPHeaderFunc) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.headerFunc = headerFunc + } +} + +// WithHTTPTimeout sets the timeout for a HTTP request and stream. +func WithHTTPTimeout(timeout time.Duration) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.httpClient.Timeout = timeout + } +} + +// WithHTTPOAuth enables OAuth authentication for the client. +func WithHTTPOAuth(config OAuthConfig) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.oauthHandler = NewOAuthHandler(config) + } +} + +// WithHTTPLogger sets a custom structured logger for the StreamableHTTP +// transport. A nil logger falls back to slog.Default(). +func WithHTTPLogger(logger *slog.Logger) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + if logger == nil { + sc.logger = slog.Default() + return + } + sc.logger = logger + } +} + +// WithLogger sets a custom structured logger for the StreamableHTTP transport. +// +// Deprecated: Use [WithHTTPLogger] instead. +func WithLogger(logger *slog.Logger) StreamableHTTPCOption { + return WithHTTPLogger(logger) +} + +// WithSession creates a client with a pre-configured session +func WithSession(sessionID string) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.sessionID.Store(sessionID) + } +} + +// WithStreamableHTTPHost sets a custom Host header for the StreamableHTTP client, enabling manual DNS resolution. +// This allows connecting to an IP address while sending a specific Host header to the server. +// For example, connecting to "http://192.168.1.100:8080/mcp" but sending Host: "api.example.com" +func WithStreamableHTTPHost(host string) StreamableHTTPCOption { + return func(sc *StreamableHTTP) { + sc.host = host + } +} + +// StreamableHTTP implements Streamable HTTP transport. +// +// It transmits JSON-RPC messages over individual HTTP requests. One message per request. +// The HTTP response body can either be a single JSON-RPC response, +// or an upgraded SSE stream that concludes with a JSON-RPC response for the same request. +// +// https://modelcontextprotocol.io/specification/2025-03-26/basic/transports +// +// The current implementation does not support the following features: +// - resuming stream +// (https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#resumability-and-redelivery) +type StreamableHTTP struct { + serverURL *url.URL + httpClient *http.Client + headers map[string]string + headerFunc HTTPHeaderFunc + host string + logger *slog.Logger + getListeningEnabled bool + + sessionID atomic.Value // string + protocolVersion atomic.Value // string + + initialized chan struct{} + initializedOnce sync.Once + + notificationHandler func(mcp.JSONRPCNotification) + notifyMu sync.RWMutex + + // Request handler for incoming server-to-client requests (like sampling) + requestHandler RequestHandler + requestMu sync.RWMutex + + closed chan struct{} + closeOnce sync.Once + + // OAuth support + oauthHandler *OAuthHandler +} + +// NewStreamableHTTP creates a new Streamable HTTP transport with the given server URL. +// Returns an error if the URL is invalid. +func NewStreamableHTTP(serverURL string, options ...StreamableHTTPCOption) (*StreamableHTTP, error) { + parsedURL, err := url.Parse(serverURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + smc := &StreamableHTTP{ + serverURL: parsedURL, + httpClient: &http.Client{}, + headers: make(map[string]string), + closed: make(chan struct{}), + logger: slog.Default(), + initialized: make(chan struct{}), + } + smc.sessionID.Store("") // set initial value to simplify later usage + + for _, opt := range options { + if opt != nil { + opt(smc) + } + } + + // If OAuth is configured, set the base URL for metadata discovery + if smc.oauthHandler != nil { + discoveryURL := *parsedURL + discoveryURL.RawQuery = "" + discoveryURL.Fragment = "" + baseURL := discoveryURL.String() + smc.oauthHandler.SetBaseURL(baseURL) + } + + return smc, nil +} + +// Start initiates the HTTP connection to the server. +func (c *StreamableHTTP) Start(ctx context.Context) error { + // Start is idempotent - check if already initialized + select { + case <-c.initialized: + return nil + default: + } + + // For Streamable HTTP, we don't need to establish a persistent connection by default + if c.getListeningEnabled { + go func() { + defer func() { + if r := recover(); r != nil { + c.logger.Error("panic in listener goroutine", "panic", r) + } + }() + select { + case <-c.initialized: + ctx, cancel := c.contextAwareOfClientClose(ctx) + defer cancel() + c.listenForever(ctx) + case <-c.closed: + return + case <-ctx.Done(): + return + } + }() + } + + return nil +} + +// Close closes the all the HTTP connections to the server. +func (c *StreamableHTTP) Close() error { + c.closeOnce.Do(func() { + // Cancel all in-flight requests + close(c.closed) + + sessionId := c.sessionID.Load().(string) + if sessionId != "" { + c.sessionID.Store("") + // notify server session closed + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.serverURL.String(), nil) + if err != nil { + c.logger.Error("failed to create close request", "err", err) + return + } + req.Header.Set(HeaderKeySessionID, sessionId) + // Set protocol version header if negotiated + if v := c.protocolVersion.Load(); v != nil { + if version, ok := v.(string); ok && version != "" { + req.Header.Set(HeaderKeyProtocolVersion, version) + } + } + + // Set custom Host header if provided + if c.host != "" { + req.Host = c.host + } + res, err := c.httpClient.Do(req) + if err != nil { + c.logger.Error("failed to send close request", "err", err) + return + } + res.Body.Close() + } + }) + return nil +} + +// SetProtocolVersion sets the negotiated protocol version for this connection. +func (c *StreamableHTTP) SetProtocolVersion(version string) { + c.protocolVersion.Store(version) +} + +// ErrOAuthAuthorizationRequired is a sentinel error for OAuth authorization required +var ErrOAuthAuthorizationRequired = errors.New("no valid token available, authorization required") + +// ErrAuthorizationRequired is a sentinel error for authorization required (401) +var ErrAuthorizationRequired = errors.New("authorization required") + +// parseAuthParams parses the auth-params from a WWW-Authenticate header value +// per RFC 7235. It skips the auth-scheme (first token) and returns a map of +// key=value pairs. Values may be tokens or quoted-strings (with backslash +// escaping per RFC 7230 §3.2.6). +func parseAuthParams(header string) map[string]string { + params := make(map[string]string) + + // Skip leading whitespace + header = strings.TrimSpace(header) + if header == "" { + return params + } + + // Skip the auth-scheme (first token before space) + _, rest, found := strings.Cut(header, " ") + if !found { + return params // auth-scheme only, no params + } + rest = strings.TrimSpace(rest) + + for rest != "" { + rest = strings.TrimSpace(rest) + if rest == "" { + break + } + + // Parse key + eqIdx := strings.IndexByte(rest, '=') + if eqIdx == -1 { + break + } + key := strings.TrimSpace(rest[:eqIdx]) + rest = strings.TrimLeft(rest[eqIdx+1:], " \t") + + // Parse value: quoted-string or token + var value string + if len(rest) > 0 && rest[0] == '"' { + value, rest = parseQuotedString(rest) + } else { + // Token value: ends at comma, space, or end of string + end := strings.IndexAny(rest, ", \t") + if end == -1 { + value = rest + rest = "" + } else { + value = rest[:end] + rest = rest[end:] + } + } + + params[key] = value + + // Skip comma separator + rest = strings.TrimSpace(rest) + if len(rest) > 0 && rest[0] == ',' { + rest = rest[1:] + } + } + + return params +} + +// parseQuotedString parses a quoted-string value per RFC 7230 §3.2.6. +// Input must start with a double-quote. Returns the unescaped value and +// the remaining unparsed input after the closing quote. +func parseQuotedString(s string) (value, rest string) { + if len(s) == 0 || s[0] != '"' { + return "", s + } + s = s[1:] // skip opening quote + + var b strings.Builder + for i := 0; i < len(s); i++ { + switch s[i] { + case '\\': + if i+1 < len(s) { + b.WriteByte(s[i+1]) + i++ // skip escaped char + } + case '"': + return b.String(), s[i+1:] + default: + b.WriteByte(s[i]) + } + } + // No closing quote found; return what we have + return b.String(), "" +} + +// extractResourceMetadataURL extracts the resource_metadata parameter from WWW-Authenticate headers +// per RFC9728 Section 5.1. Scans all provided header values since a response may contain multiple +// WWW-Authenticate headers (RFC 9110). Returns empty string if not found. +// Example: Bearer resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource" +func extractResourceMetadataURL(wwwAuthHeaders []string) string { + for _, header := range wwwAuthHeaders { + for _, u := range extractResourceMetadataURLs(header) { + if u != "" { + return u + } + } + } + return "" +} + +// extractResourceMetadataURLs returns every resource_metadata parameter +// value from a single WWW-Authenticate header value per RFC 9728 §5.1, +// in the order they appear. Returns an empty slice when the header is +// empty or no such parameters are present. Parameter names are matched +// case-insensitively per RFC 9110 §11.2; both quoted-string and token +// value forms are accepted. Multiple occurrences are possible when a +// single header value contains several Bearer challenges each carrying +// their own resource_metadata — an attacker-controlled first candidate +// must not mask a legitimate later one. +func extractResourceMetadataURLs(header string) []string { + const target = "resource_metadata" + var out []string + i := 0 + for i < len(header) { + // Advance to the next token start. + for i < len(header) && !isAuthTokenChar(header[i]) { + i++ + } + nameStart := i + for i < len(header) && isAuthTokenChar(header[i]) { + i++ + } + name := header[nameStart:i] + // Skip optional whitespace between the name and '='. + for i < len(header) && (header[i] == ' ' || header[i] == '\t') { + i++ + } + if i >= len(header) || header[i] != '=' { + // Name was a scheme token (e.g. "Bearer"), not a parameter. + continue + } + // Skip '=' and optional whitespace. + i++ + for i < len(header) && (header[i] == ' ' || header[i] == '\t') { + i++ + } + value, next, ok := parseAuthParamValue(header, i) + i = next + if !ok { + continue + } + if value != "" && strings.EqualFold(name, target) { + out = append(out, value) + } + } + return out +} + +// parseAuthParamValue reads a single WWW-Authenticate parameter value +// starting at offset i: a quoted-string (with backslash escapes) when the +// first byte is '"', otherwise a bare token. It returns the decoded +// value, the index of the first byte after it, and whether the value +// was well-formed. Truncated quoted strings (no closing '"') and lone +// trailing backslashes yield ok=false so malformed input is rejected +// rather than producing a partial value. +func parseAuthParamValue(s string, i int) (string, int, bool) { + if i >= len(s) { + return "", i, false + } + if s[i] == '"' { + i++ + var b strings.Builder + for i < len(s) { + c := s[i] + if c == '\\' { + if i+1 >= len(s) { + // Lone trailing backslash — the quoted string was + // truncated mid-escape, so the value is malformed. + return "", i + 1, false + } + b.WriteByte(s[i+1]) + i += 2 + continue + } + if c == '"' { + return b.String(), i + 1, true + } + b.WriteByte(c) + i++ + } + // Reached end of input without a closing '"'. + return "", i, false + } + start := i + for i < len(s) && isAuthTokenChar(s[i]) { + i++ + } + return s[start:i], i, i > start +} + +// isAuthTokenChar reports whether c is a valid RFC 9110 §5.6.2 token +// character — the character class used for scheme and parameter names in +// WWW-Authenticate. +func isAuthTokenChar(c byte) bool { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + return true + } + return strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0 +} + +// AuthorizationRequiredError is returned when a 401 Unauthorized response is received. +// It contains the protected resource metadata URL from the WWW-Authenticate header if present. +type AuthorizationRequiredError struct { + ResourceMetadataURL string // Extracted from WWW-Authenticate header per RFC9728 +} + +func (e *AuthorizationRequiredError) Error() string { + return ErrAuthorizationRequired.Error() +} + +func (e *AuthorizationRequiredError) Unwrap() error { + return ErrAuthorizationRequired +} + +// OAuthAuthorizationRequiredError is returned when OAuth authorization is required +// and an OAuth handler is available. +type OAuthAuthorizationRequiredError struct { + Handler *OAuthHandler + AuthorizationRequiredError +} + +func (e *OAuthAuthorizationRequiredError) Error() string { + return ErrOAuthAuthorizationRequired.Error() +} + +func (e *OAuthAuthorizationRequiredError) Unwrap() error { + return ErrOAuthAuthorizationRequired +} + +// SendRequest sends a JSON-RPC request to the server and waits for a response. +// Returns the raw JSON response message or an error if the request fails. +func (c *StreamableHTTP) SendRequest( + ctx context.Context, + request JSONRPCRequest, +) (*JSONRPCResponse, error) { + // Marshal request + requestBody, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + ctx, cancel := c.contextAwareOfClientClose(ctx) + + resp, err := c.sendHTTP(ctx, http.MethodPost, bytes.NewReader(requestBody), "application/json, text/event-stream", request.Header) + if err != nil { + cancel() + if errors.Is(err, ErrSessionTerminated) && request.Method == string(mcp.MethodInitialize) { + // Per the MCP spec's backwards compatibility section: a 404 on an + // initialize POST means the server likely only supports legacy SSE. + return nil, ErrLegacySSEServer + } + return nil, fmt.Errorf("failed to send request: %w", err) + } + + // Only proceed if we have a valid response. + if resp == nil { + cancel() + return nil, fmt.Errorf("failed to send request: no response received") + } + // Cancel the context before closing the body. On HTTP/2, Close() blocks in a + // select on cs.donec (stream cleanup) or cs.ctx.Done() (context cancellation). + // If cc.wmu is contended, cs.donec may never close, making ctx.Done() the only + // exit path. Canceling first guarantees Close() returns promptly. + defer func() { cancel(); resp.Body.Close() }() + + // Check if we got an error response + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + + // Handle unauthorized error + if resp.StatusCode == http.StatusUnauthorized { + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Values("WWW-Authenticate")) + + // Feed discovered URL back to OAuthHandler so next auth attempt uses it. + // HandleUnauthorizedResponse applies RFC 9728 origin validation — a + // compromised resource advertising a cross-origin PRM URL is ignored. + if c.oauthHandler != nil { + c.oauthHandler.HandleUnauthorizedResponse(resp) + } + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return nil, &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return nil, &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + } + } + + // Per the MCP spec's backwards compatibility section: if an initialize + // POST receives an HTTP 4xx (e.g. 405 Method Not Allowed, 404 Not Found), + // the server likely only supports the legacy HTTP+SSE transport. + if request.Method == string(mcp.MethodInitialize) && resp.StatusCode >= 400 && resp.StatusCode < 500 { + return nil, ErrLegacySSEServer + } + + // handle error response + var errResponse JSONRPCResponse + body, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(body, &errResponse); err == nil { + return &errResponse, nil + } + return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, body) + } + + if request.Method == string(mcp.MethodInitialize) { + // saved the received session ID in the response + // empty session ID is allowed + if sessionID := resp.Header.Get(HeaderKeySessionID); sessionID != "" { + c.sessionID.Store(sessionID) + } + + c.initializedOnce.Do(func() { + close(c.initialized) + }) + } + + // Handle different response types + mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + switch mediaType { + case "application/json": + // Single response + var response JSONRPCResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + // should not be a notification + if response.ID.IsNil() { + return nil, fmt.Errorf("response should contain RPC id: %v", response) + } + + return &response, nil + + case "text/event-stream": + // Server is using SSE for streaming responses + return c.handleSSEResponse(ctx, resp.Body, false) + + default: + return nil, fmt.Errorf("unexpected content type: %s", resp.Header.Get("Content-Type")) + } +} + +func (c *StreamableHTTP) sendHTTP( + ctx context.Context, + method string, + body io.Reader, + acceptType string, + header http.Header, +) (resp *http.Response, err error) { + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, method, c.serverURL.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // request headers + if header != nil { + req.Header = header + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", acceptType) + sessionID := c.sessionID.Load().(string) + if sessionID != "" { + req.Header.Set(HeaderKeySessionID, sessionID) + } + // Set protocol version header if negotiated + if v := c.protocolVersion.Load(); v != nil { + if version, ok := v.(string); ok && version != "" { + req.Header.Set(HeaderKeyProtocolVersion, version) + } + } + for k, v := range c.headers { + req.Header.Set(k, v) + } + + // Set custom Host header if provided + if c.host != "" { + req.Host = c.host + } + + // Add OAuth authorization if configured + if c.oauthHandler != nil { + authHeader, err := c.oauthHandler.GetAuthorizationHeader(ctx) + if err != nil { + // If we get an authorization error, return a specific error that can be handled by the client + if errors.Is(err, ErrOAuthAuthorizationRequired) { + return nil, &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: "", // No response available in this code path + }, + } + } + return nil, fmt.Errorf("failed to get authorization header: %w", err) + } + req.Header.Set("Authorization", authHeader) + } + + if c.headerFunc != nil { + for k, v := range c.headerFunc(ctx) { + req.Header.Set(k, v) + } + } + + // Send request + resp, err = c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + // universal handling for session terminated + if resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + c.sessionID.CompareAndSwap(sessionID, "") + return nil, ErrSessionTerminated + } + + return resp, nil +} + +// handleSSEResponse processes an SSE stream for a specific request. +// It returns the final result for the request once received, or an error. +// If ignoreResponse is true, it won't return when a response messge is received. This is for continuous listening. +func (c *StreamableHTTP) handleSSEResponse(ctx context.Context, reader io.ReadCloser, ignoreResponse bool) (*JSONRPCResponse, error) { + // Create a channel for this specific request + responseChan := make(chan *JSONRPCResponse, 1) + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Start a goroutine to process the SSE stream + go func() { + defer func() { + if r := recover(); r != nil { + c.logger.Error("panic in SSE stream reader", "panic", r) + } + }() + // Ensure this goroutine respects the context + defer close(responseChan) + + c.readSSE(ctx, reader, func(event, data string) { + // Try to unmarshal as a response first + var message JSONRPCResponse + if err := json.Unmarshal([]byte(data), &message); err != nil { + c.logger.Info("failed to unmarshal message (non-fatal)", "err", err, "message_len", len(data)) + return + } + + // Handle notification + if message.ID.IsNil() { + var notification mcp.JSONRPCNotification + if err := json.Unmarshal([]byte(data), ¬ification); err != nil { + c.logger.Error("failed to unmarshal notification", "err", err) + return + } + c.notifyMu.RLock() + if c.notificationHandler != nil { + c.notificationHandler(notification) + } + c.notifyMu.RUnlock() + return + } + + // Check if this is actually a request from the server by looking for method field + var rawMessage map[string]json.RawMessage + if err := json.Unmarshal([]byte(data), &rawMessage); err == nil { + if _, hasMethod := rawMessage["method"]; hasMethod && !message.ID.IsNil() { + var request JSONRPCRequest + if err := json.Unmarshal([]byte(data), &request); err == nil { + // This is a request from the server + c.handleIncomingRequest(ctx, request) + return + } + } + } + + if !ignoreResponse { + responseChan <- &message + } + }) + }() + + // Wait for the response or context cancellation + select { + case response := <-responseChan: + if response == nil { + return nil, fmt.Errorf("unexpected nil response") + } + return response, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// readSSE reads the SSE stream(reader) and calls the handler for each event and data pair. +// It will end when the reader is closed (or the context is done). +// +// A background goroutine closes the reader when ctx is cancelled, which unblocks +// any in-progress ReadString call. This is necessary because ReadString is blocking +// I/O that does not respect context cancellation on its own. +func (c *StreamableHTTP) readSSE(ctx context.Context, reader io.ReadCloser, handler func(event, data string)) { + // Close the reader when context is cancelled to interrupt blocking reads. + // This ensures ReadString returns immediately with an error instead of + // blocking indefinitely when the SSE stream is open but idle. + go func() { + defer func() { + if r := recover(); r != nil { + c.logger.Error("panic closing SSE reader", "panic", r) + } + }() + <-ctx.Done() + reader.Close() + }() + + br := bufio.NewReader(reader) + var event, data string + + for { + line, err := br.ReadString('\n') + if err != nil { + // Context was cancelled — reader was closed by the goroutine above. + if ctx.Err() != nil { + return + } + if err == io.EOF { + // Process any pending event before exit + if data != "" { + if event == "" { + event = "message" + } + handler(event, data) + } + return + } + c.logger.Error("SSE stream error", "err", err) + return + } + + // Remove only newline markers + line = strings.TrimRight(line, "\r\n") + if line == "" { + // Empty line means end of event + if data != "" { + if event == "" { + event = "message" + } + handler(event, data) + event = "" + data = "" + } + continue + } + + if eventStr, ok := strings.CutPrefix(line, "event:"); ok { + event = strings.TrimSpace(eventStr) + } else if dataStr, ok := strings.CutPrefix(line, "data:"); ok { + data = appendSSEData(data, dataStr) + } + } +} + +// SendNotification sends a JSON-RPC notification to the server without expecting a response. +func (c *StreamableHTTP) SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error { + // Marshal request + requestBody, err := json.Marshal(notification) + if err != nil { + return fmt.Errorf("failed to marshal notification: %w", err) + } + + // Create HTTP request + ctx, cancel := c.contextAwareOfClientClose(ctx) + + resp, err := c.sendHTTP(ctx, http.MethodPost, bytes.NewReader(requestBody), "application/json, text/event-stream", nil) + if err != nil { + cancel() + return fmt.Errorf("failed to send request: %w", err) + } + defer func() { cancel(); resp.Body.Close() }() + + switch resp.StatusCode { + case http.StatusOK, http.StatusAccepted, http.StatusNoContent: + return nil + case http.StatusUnauthorized: + // Extract discovered metadata URL per RFC9728 + metadataURL := extractResourceMetadataURL(resp.Header.Values("WWW-Authenticate")) + + // Feed discovered URL back to OAuthHandler so next auth attempt uses it. + // HandleUnauthorizedResponse applies RFC 9728 origin validation — a + // compromised resource advertising a cross-origin PRM URL is ignored. + if c.oauthHandler != nil { + c.oauthHandler.HandleUnauthorizedResponse(resp) + } + + // If OAuth handler exists, return OAuth-specific error + if c.oauthHandler != nil { + return &OAuthAuthorizationRequiredError{ + Handler: c.oauthHandler, + AuthorizationRequiredError: AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + }, + } + } + + // No OAuth handler, return base authorization error + return &AuthorizationRequiredError{ + ResourceMetadataURL: metadataURL, + } + default: + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf( + "notification failed with status %d: %s", + resp.StatusCode, + body, + ) + } +} + +// SetNotificationHandler sets the handler for incoming JSON-RPC notifications. +func (c *StreamableHTTP) SetNotificationHandler(handler func(mcp.JSONRPCNotification)) { + c.notifyMu.Lock() + defer c.notifyMu.Unlock() + c.notificationHandler = handler +} + +// SetRequestHandler sets the handler for incoming requests from the server. +func (c *StreamableHTTP) SetRequestHandler(handler RequestHandler) { + c.requestMu.Lock() + defer c.requestMu.Unlock() + c.requestHandler = handler +} + +// GetSessionId returns the current StreamableHTTP session ID. +func (c *StreamableHTTP) GetSessionId() string { + return c.sessionID.Load().(string) +} + +// GetOAuthHandler returns the OAuth handler if configured +func (c *StreamableHTTP) GetOAuthHandler() *OAuthHandler { + return c.oauthHandler +} + +// IsOAuthEnabled returns true if OAuth is enabled +func (c *StreamableHTTP) IsOAuthEnabled() bool { + return c.oauthHandler != nil +} + +func (c *StreamableHTTP) listenForever(ctx context.Context) { + c.logger.Info("listening to server forever") + for { + // Use the original context for continuous listening - no per-iteration timeout + // The SSE connection itself will detect disconnections via the underlying HTTP transport, + // and the context cancellation will propagate from the parent to stop listening gracefully. + // We don't add an artificial timeout here because: + // 1. Persistent SSE connections are meant to stay open indefinitely + // 2. Network-level timeouts and keep-alives handle connection health + // 3. Context cancellation (user-initiated or system shutdown) provides clean shutdown + err := c.createGETConnectionToServer(ctx) + if errors.Is(err, ErrGetMethodNotAllowed) { + // server does not support listening + c.logger.Error("server does not support listening") + return + } + if errors.Is(err, ErrSessionTerminated) { + // Server returned 404: the session no longer exists (server restarted + // or session expired). Retrying is pointless because the server won't + // recognize this session. The caller must re-initialize. + c.logger.Error("session terminated, stopping listener", "err", err) + return + } + + select { + case <-ctx.Done(): + return + default: + } + + if err != nil { + c.logger.Error("failed to listen to server. retry in 1 second", "err", err) + } + + // Use context-aware sleep + select { + case <-time.After(retryInterval): + case <-ctx.Done(): + return + } + } +} + +var ( + // ErrSessionTerminated indicates the server no longer recognizes the current session. + ErrSessionTerminated = fmt.Errorf("session terminated (404). need to re-initialize") + ErrGetMethodNotAllowed = fmt.Errorf("GET method not allowed") + ErrUnauthorized = fmt.Errorf("unauthorized (401)") + ErrLegacySSEServer = fmt.Errorf("server returned 4xx for initialize POST, likely a legacy SSE server") + + retryInterval = 1 * time.Second // a variable is convenient for testing +) + +func (c *StreamableHTTP) createGETConnectionToServer(ctx context.Context) error { + resp, err := c.sendHTTP(ctx, http.MethodGet, nil, "text/event-stream", nil) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + // Cancel the context before closing the body to prevent HTTP/2 drain hangs, + // matching the pattern used in SendRequest and SendNotification. + defer func() { resp.Body.Close() }() + + // Check if we got an error response + if resp.StatusCode == http.StatusMethodNotAllowed { + return ErrGetMethodNotAllowed + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, body) + } + + // handle SSE response. Parse the media type to tolerate parameters such as + // "text/event-stream; charset=utf-8" (same handling as SendRequest). + mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if mediaType != "text/event-stream" { + return fmt.Errorf("unexpected content type: %s", resp.Header.Get("Content-Type")) + } + + // When ignoreResponse is true, the function will never return expect context is done. + // NOTICE: Due to the ambiguity of the specification, other SDKs may use the GET connection to transfer the response + // messages. To be more compatible, we should handle this response, however, as the transport layer is message-based, + // currently, there is no convenient way to handle this response. + // So we ignore the response here. It's not a bug, but may be not compatible with other SDKs. + _, err = c.handleSSEResponse(ctx, resp.Body, true) + if err != nil { + return fmt.Errorf("failed to handle SSE response: %w", err) + } + + return nil +} + +// handleIncomingRequest processes requests from the server (like sampling requests) +func (c *StreamableHTTP) handleIncomingRequest(ctx context.Context, request JSONRPCRequest) { + c.requestMu.RLock() + handler := c.requestHandler + c.requestMu.RUnlock() + + if handler == nil { + c.logger.Error("received request from server but no handler set", "method", request.Method) + // Send method not found error + errorResponse := NewJSONRPCErrorResponse( + request.ID, + mcp.METHOD_NOT_FOUND, + fmt.Sprintf("no handler configured for method: %s", request.Method), + nil, + ) + c.sendResponseToServer(ctx, errorResponse) + return + } + + // Handle the request in a goroutine to avoid blocking the SSE reader + go func() { + defer func() { + if r := recover(); r != nil { + c.logger.Error("panic handling server request", "method", request.Method, "panic", r) + // Attempt to send an internal error response so the server doesn't hang. + // Use a nested recover to prevent sendResponseToServer from propagating + // a secondary panic (e.g., nil serverURL during shutdown). + func() { + defer func() { + if r2 := recover(); r2 != nil { + c.logger.Error("failed to send error response after panic", "err", r2) + } + }() + errorResponse := NewJSONRPCErrorResponse( + request.ID, + mcp.INTERNAL_ERROR, + fmt.Sprintf("internal error: panic in request handler: %v", r), + nil, + ) + c.sendResponseToServer(ctx, errorResponse) + }() + } + }() + // Create a new context with timeout for request handling, respecting parent context + requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + response, err := handler(requestCtx, request) + if err != nil { + c.logger.Error("error handling request", "method", request.Method, "err", err) + + // Determine appropriate JSON-RPC error code based on error type + var errorCode int + var errorMessage string + + // Check for specific sampling-related errors + if errors.Is(err, context.Canceled) { + errorCode = mcp.REQUEST_INTERRUPTED + errorMessage = "request was cancelled" + } else if errors.Is(err, context.DeadlineExceeded) { + errorCode = mcp.REQUEST_INTERRUPTED + errorMessage = "request timed out" + } else { + // Generic error cases + switch request.Method { + case string(mcp.MethodSamplingCreateMessage): + errorCode = mcp.INTERNAL_ERROR + errorMessage = fmt.Sprintf("sampling request failed: %v", err) + default: + errorCode = mcp.INTERNAL_ERROR + errorMessage = err.Error() + } + } + + // Send error response + errorResponse := NewJSONRPCErrorResponse(request.ID, errorCode, errorMessage, nil) + c.sendResponseToServer(requestCtx, errorResponse) + return + } + + if response != nil { + c.sendResponseToServer(requestCtx, response) + } + }() +} + +// sendResponseToServer sends a response back to the server via HTTP POST +func (c *StreamableHTTP) sendResponseToServer(ctx context.Context, response *JSONRPCResponse) { + if response == nil { + c.logger.Error("cannot send nil response to server") + return + } + + responseBody, err := json.Marshal(response) + if err != nil { + c.logger.Error("failed to marshal response", "err", err) + return + } + + ctx, cancel := c.contextAwareOfClientClose(ctx) + + resp, err := c.sendHTTP(ctx, http.MethodPost, bytes.NewReader(responseBody), "application/json, text/event-stream", nil) + if err != nil { + cancel() + c.logger.Error("failed to send response to server", "err", err) + return + } + defer func() { cancel(); resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(resp.Body) + c.logger.Error("server rejected response", "status", resp.StatusCode, "body_len", len(body)) + } +} + +func (c *StreamableHTTP) contextAwareOfClientClose(ctx context.Context) (context.Context, context.CancelFunc) { + newCtx, cancel := context.WithCancel(ctx) + go func() { + defer func() { + if r := recover(); r != nil { + c.logger.Error("panic in context-close watcher", "panic", r) + } + }() + select { + case <-c.closed: + cancel() + case <-newCtx.Done(): + // The original context was canceled + cancel() + } + }() + return newCtx, cancel +} diff --git a/vendor/github.com/mark3labs/mcp-go/client/transport/utils.go b/vendor/github.com/mark3labs/mcp-go/client/transport/utils.go new file mode 100644 index 000000000..2d0e847da --- /dev/null +++ b/vendor/github.com/mark3labs/mcp-go/client/transport/utils.go @@ -0,0 +1,26 @@ +package transport + +import ( + "encoding/json" + + "github.com/mark3labs/mcp-go/mcp" +) + +// NewJSONRPCErrorResponse creates a new JSONRPCResponse with an error. +func NewJSONRPCErrorResponse(id mcp.RequestId, code int, message string, data any) *JSONRPCResponse { + details := mcp.NewJSONRPCErrorDetails(code, message, data) + return &JSONRPCResponse{ + JSONRPC: mcp.JSONRPC_VERSION, + ID: id, + Error: &details, + } +} + +// NewJSONRPCResultResponse creates a new JSONRPCResponse with a result. +func NewJSONRPCResultResponse(id mcp.RequestId, result json.RawMessage) *JSONRPCResponse { + return &JSONRPCResponse{ + JSONRPC: mcp.JSONRPC_VERSION, + ID: id, + Result: result, + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 2d1c491b6..8ef964777 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -73,6 +73,8 @@ github.com/jackc/puddle/v2/internal/genstack github.com/joho/godotenv # github.com/mark3labs/mcp-go v0.58.0 ## explicit; go 1.25.5 +github.com/mark3labs/mcp-go/client +github.com/mark3labs/mcp-go/client/transport github.com/mark3labs/mcp-go/mcp github.com/mark3labs/mcp-go/server github.com/mark3labs/mcp-go/tracing From 03613c7481c1208bd69e529e196b5895c84c574c Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 18:50:54 -0400 Subject: [PATCH 02/18] test(e2e): drive init without a marker and pass mcp --timeout (P7-SHIP-02) With the cli gaps closed, the replay starts `nself mcp` in the empty fixture project and calls nself_init without the .env marker or --force. The replay and live runs pass `--timeout 30m` so a cold start that pulls every image fits one call. The README's gap list becomes short notes; the ci leg still needs the served ci plugin's machine surface (P7-CI-62). (P7-SHIP-02) --- test/e2e/agent/README.md | 15 +++++---------- test/e2e/agent/agent_test.go | 11 +++++++---- test/e2e/agent/harness.go | 10 +--------- test/e2e/agent/live_test.go | 5 +---- test/e2e/agent/sandbox.go | 6 ++++++ test/e2e/agent/testdata/replay.jsonl | 2 +- 6 files changed, 21 insertions(+), 28 deletions(-) diff --git a/test/e2e/agent/README.md b/test/e2e/agent/README.md index 72cafd56b..04601c681 100644 --- a/test/e2e/agent/README.md +++ b/test/e2e/agent/README.md @@ -42,14 +42,9 @@ One JSON object per line: `{seq, time, role, kind, tool, arguments, envelope, is The verifier fails a transcript that has a non-MCP agent step, a missing D7 step, steps out of order, a promote apply before its dry run, CI evidence before the run wait, a `tools/call` result that is not a v1 envelope, or a failed recovery or gate leg. -## Known gaps (found while building P7-SHIP-02) +## Notes -The replay drives the real product and stops at the first failing step. These gaps stop it today: - -1. `nself mcp` in v1.5 will not start outside a project (`resolveProjectDir`), so an MCP-only agent cannot reach `init`. The harness writes an empty `.env` marker and the agent calls `nself_init` with `force`. Owner: P7-SURF. -2. `nself_build` on a fresh project fails with E451: the gate runs `--plan`, the plan needs no confirmation, the gate then runs `--plan-id`, and a build that generates secrets cannot bind a plan id. The agent never sees a plan to confirm. Owner: P7-SURF-25 / P7-LIVE-03. -3. MCP tools are built once at server start, so `nself_ci` and its sub-tools do not appear in `tools/list` after `nself_add ci` in the same session. The ci plugin's machine surface (P7-CI-62) is also not merged yet. Owner: P7-SURF-03, P7-CI-62. -4. `nself_deploy_promote` accepts at most one argument (the registry names one arg `source target`), so `["qa", "prod"]` is rejected. Owner: P7-SURF-15 / P7-DEPL-15. -5. Document invocations stop at 300 s (`invoke.DefaultTimeout`) and `nself mcp` has no `--timeout` flag, so a cold `start` that pulls every image may time out. - -The sim hosts get recording `docker` and `nself` stubs, as the deploy-sim tests do; whether `deploy qa` and `deploy promote` run end to end against them is not proven until the gaps above are closed. +- The server starts in the empty fixture project and lists `nself_init` plus the read-only tools; after `init` it lists every tool, and after `add ci` it sends `notifications/tools/list_changed` and the ci tools appear. +- The replay and live runs start `nself mcp --timeout 30m`: a cold `start` pulls every image and outlasts the 5m default. +- The ci leg needs the served ci plugin to declare its machine surface (P7-CI-62); without it `tools/list` after `add ci` lacks `nself_ci`, and the replay stops there with that message. +- The sim hosts get recording `docker` and `nself` stubs, as the deploy-sim tests do. The image-digest compare after the promote reads the stubs' record of `compose up`. diff --git a/test/e2e/agent/agent_test.go b/test/e2e/agent/agent_test.go index 529d08860..e04f6ede5 100644 --- a/test/e2e/agent/agent_test.go +++ b/test/e2e/agent/agent_test.go @@ -28,6 +28,10 @@ import ( "github.com/nself-org/cli/sdk/go/v2/remote" ) +// replayTimeout bounds one tool call in the replay and live runs: a cold +// `start` pulls every image, which outlasts the server's 5m default. +const replayTimeout = "30m" + func TestVerifyFixtures(t *testing.T) { good, err := ReadTranscript(filepath.Join("testdata", "good.jsonl")) if err != nil { @@ -115,7 +119,8 @@ func TestToolsList(t *testing.T) { if err != nil { t.Fatal(err) } - // `nself mcp` needs a project marker to start (see README, gap 1). + // Outside a project the server lists only init and the read-only tools; + // a project marker makes it list the whole surface (the parity input). if err := os.WriteFile(filepath.Join(box.Project, ".env"), nil, 0o600); err != nil { t.Fatal(err) } @@ -168,6 +173,7 @@ func TestAgentReplay(t *testing.T) { if err != nil { t.Fatal(err) } + box.Timeout = replayTimeout for _, step := range []func() error{ func() error { return box.InstallKey(fleet.KeyPath()) }, box.WriteSSHConfig, @@ -193,9 +199,6 @@ func TestAgentReplay(t *testing.T) { if _, err := run(ctx, box.Project, "git", "init", "-q"); err != nil { t.Fatal(err) } - if err := h.Do(ctx, Step{ID: "fixture", Action: "fixture-marker", Note: "project marker so nself mcp can start (README gap 1)"}, w); err != nil { - t.Fatal(err) - } steps, err := LoadReplay(filepath.Join("testdata", "replay.jsonl")) if err != nil { t.Fatal(err) diff --git a/test/e2e/agent/harness.go b/test/e2e/agent/harness.go index ea6c558c7..8beb4a307 100644 --- a/test/e2e/agent/harness.go +++ b/test/e2e/agent/harness.go @@ -3,8 +3,7 @@ package agent // Purpose: the harness side of the replay: the two loopback sim hosts (qa tier -// local-servers, prod tier prod) and every harness action (fixture, git -// commit, service stop, docker volume and image-digest probes). Each action is +// local-servers, prod tier prod) and every harness action (git commit, service stop, docker volume and image-digest probes). Each action is // a `role: harness` transcript record; none of them is an agent step. // Inputs: a *testing.T, the Sandbox, the replay's harness Steps. // Outputs: transcript records; an error when an action or a probe fails. @@ -16,7 +15,6 @@ import ( "context" "encoding/json" "fmt" - "os" "os/exec" "path/filepath" "sort" @@ -68,12 +66,6 @@ func (h *SimHarness) Do(ctx context.Context, s Step, w *Writer) error { note += ": " + s.Note } switch s.Action { - case "fixture-marker": - // Gap: `nself mcp` (v1.5) will not start outside a project, so an - // MCP-only agent cannot reach `init` without a project marker. - if err := os.WriteFile(filepath.Join(h.Box.Project, ".env"), nil, 0o600); err != nil { - return err - } case "git-commit": if _, err := run(ctx, h.Box.Project, "git", "add", "-A"); err != nil { return err diff --git a/test/e2e/agent/live_test.go b/test/e2e/agent/live_test.go index 040981966..ff5a0cf3b 100644 --- a/test/e2e/agent/live_test.go +++ b/test/e2e/agent/live_test.go @@ -38,6 +38,7 @@ func TestAgentLive(t *testing.T) { if err != nil { t.Fatal(err) } + box.Timeout = replayTimeout if err := box.InstallKey(fleet.KeyPath()); err != nil { t.Fatal(err) } @@ -56,14 +57,10 @@ func TestAgentLive(t *testing.T) { t.Fatal(err) } defer w.Close() - h := &SimHarness{T: t, Fleet: fleet, Box: box, Hosts: hosts} ctx := context.Background() if _, err := run(ctx, box.Project, "git", "init", "-q"); err != nil { t.Fatal(err) } - if err := h.Do(ctx, Step{ID: "fixture", Action: "fixture-marker"}, w); err != nil { - t.Fatal(err) - } // The trust step needs the sandbox HOME; claude keeps the operator's. t.Setenv("HOME", box.Home) trustHosts(t, hosts) diff --git a/test/e2e/agent/sandbox.go b/test/e2e/agent/sandbox.go index da57368a2..6c81ab24f 100644 --- a/test/e2e/agent/sandbox.go +++ b/test/e2e/agent/sandbox.go @@ -46,6 +46,9 @@ type Sandbox struct { Root, Home, Project string // KeyPath is the copy of the fleet key inside Home (empty until InstallKey). KeyPath string + // Timeout, when set, is passed as `nself mcp --timeout` (a cold start + // that pulls every image outlasts the 5m default). + Timeout string } // NewSandbox makes root/home (with an empty .ssh) and root/project. @@ -131,5 +134,8 @@ func (s *Sandbox) ServerCommand(bin, operatorHome string, extraEnv ...string) (s args = append(args, "--env", e) } args = append(args, "--", bin, "mcp") + if s.Timeout != "" { + args = append(args, "--timeout", s.Timeout) + } return "bash", args } diff --git a/test/e2e/agent/testdata/replay.jsonl b/test/e2e/agent/testdata/replay.jsonl index 919040fc4..b2b6ce8f5 100644 --- a/test/e2e/agent/testdata/replay.jsonl +++ b/test/e2e/agent/testdata/replay.jsonl @@ -3,7 +3,7 @@ // with v1.5 tool names. Harness steps are `role: harness` records, never agent // actions. A step with "confirm": true expects a confirmation request first and // repeats the identical call with the server's token (plan id or nonce). -{"id":"init","role":"agent","tool":"nself_init","arguments":{"flags":{"non-interactive":true,"force":true}}} +{"id":"init","role":"agent","tool":"nself_init","arguments":{"flags":{"non-interactive":true}}} {"id":"commit-after-init","role":"harness","action":"git-commit"} {"id":"build","role":"agent","tool":"nself_build","confirm":true} {"id":"start","role":"agent","tool":"nself_start"} From 2b5b0d3dac5840fd1d3f2a366f2d224db93a7ee1 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 21:02:25 -0400 Subject: [PATCH 03/18] test(e2e): let the sandboxed docker CLI find the compose plugin (P7-SHIP-02) On Homebrew and Docker Desktop hosts the compose plugin lives outside the system plugin dirs, so with the sandbox HOME docker compose failed (unknown shorthand flag -f). The sandbox now writes HOME/.docker/config.json with only cliPluginsExtraDirs for that directory. (P7-SHIP-02) --- test/e2e/agent/README.md | 2 +- test/e2e/agent/agent_test.go | 3 +++ test/e2e/agent/live_test.go | 3 +++ test/e2e/agent/sandbox.go | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/test/e2e/agent/README.md b/test/e2e/agent/README.md index 04601c681..4d2a96de5 100644 --- a/test/e2e/agent/README.md +++ b/test/e2e/agent/README.md @@ -34,7 +34,7 @@ bash scripts/e2e/agent/run.sh --live --transcript /tmp/agent-e2e/live.jsonl [--p ## Sandbox -`mcp-sandbox.sh` starts the server with `env -i` (PATH, HOME, TMPDIR, NSELF_V15 and a fixed list of extras). HOME is a temp dir with only the sim key (generated per run by `sdk/go/simharness`, never committed), an ssh config and known hosts. The script refuses to start when SSH_AUTH_SOCK is set, when HOME links to or names the operator's `~/.ssh`, or when HOME, the inventory or the ssh config names a host other than `127.0.0.1` or `localhost`. +`mcp-sandbox.sh` starts the server with `env -i` (PATH, HOME, TMPDIR, NSELF_V15 and a fixed list of extras). HOME is a temp dir with only the sim key (generated per run by `sdk/go/simharness`, never committed), an ssh config, known hosts and, when the compose plugin is outside the system plugin dirs, a docker config naming its directory (no auths). The script refuses to start when SSH_AUTH_SOCK is set, when HOME links to or names the operator's `~/.ssh`, or when HOME, the inventory or the ssh config names a host other than `127.0.0.1` or `localhost`. ## Transcript diff --git a/test/e2e/agent/agent_test.go b/test/e2e/agent/agent_test.go index e04f6ede5..2d1c5e7d0 100644 --- a/test/e2e/agent/agent_test.go +++ b/test/e2e/agent/agent_test.go @@ -174,6 +174,9 @@ func TestAgentReplay(t *testing.T) { t.Fatal(err) } box.Timeout = replayTimeout + if err := box.WriteDockerConfig(); err != nil { + t.Fatal(err) + } for _, step := range []func() error{ func() error { return box.InstallKey(fleet.KeyPath()) }, box.WriteSSHConfig, diff --git a/test/e2e/agent/live_test.go b/test/e2e/agent/live_test.go index ff5a0cf3b..1f5612ab5 100644 --- a/test/e2e/agent/live_test.go +++ b/test/e2e/agent/live_test.go @@ -39,6 +39,9 @@ func TestAgentLive(t *testing.T) { t.Fatal(err) } box.Timeout = replayTimeout + if err := box.WriteDockerConfig(); err != nil { + t.Fatal(err) + } if err := box.InstallKey(fleet.KeyPath()); err != nil { t.Fatal(err) } diff --git a/test/e2e/agent/sandbox.go b/test/e2e/agent/sandbox.go index 6c81ab24f..b827957b7 100644 --- a/test/e2e/agent/sandbox.go +++ b/test/e2e/agent/sandbox.go @@ -14,8 +14,10 @@ package agent // operator's ~/.ssh. import ( + "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -90,6 +92,37 @@ func (s *Sandbox) WriteSSHConfig() error { return os.WriteFile(filepath.Join(s.Home, ".ssh", "config"), []byte(cfg), 0o600) } +// systemPluginDirs are where the docker CLI finds plugins without a config. +var systemPluginDirs = map[string]bool{ + "/usr/local/lib/docker/cli-plugins": true, "/usr/local/libexec/docker/cli-plugins": true, + "/usr/lib/docker/cli-plugins": true, "/usr/libexec/docker/cli-plugins": true, +} + +// WriteDockerConfig lets the docker CLI in the sandbox find the compose +// plugin when it lives outside the system plugin dirs (Homebrew, Docker +// Desktop): HOME/.docker/config.json with only cliPluginsExtraDirs, no auths. +// Call it before HOME points at the sandbox. +func (s *Sandbox) WriteDockerConfig() error { + out, err := exec.Command("docker", "info", "--format", + `{{range .ClientInfo.Plugins}}{{if eq .Name "compose"}}{{.Path}}{{end}}{{end}}`).Output() + if err != nil { + return fmt.Errorf("sandbox: find the docker compose plugin: %w", err) + } + path := strings.TrimSpace(string(out)) + if path == "" { + return fmt.Errorf("sandbox: docker has no compose plugin") + } + dir := filepath.Dir(path) + if systemPluginDirs[dir] { + return nil + } + b, _ := json.Marshal(map[string]any{"cliPluginsExtraDirs": []string{dir}}) + if err := os.MkdirAll(filepath.Join(s.Home, ".docker"), 0o700); err != nil { + return err + } + return os.WriteFile(filepath.Join(s.Home, ".docker", "config.json"), b, 0o600) +} + // WriteInventory writes the inventory v2 file: local, plus one remote env per // sim host (qa tier local-servers, prod tier prod). func (s *Sandbox) WriteInventory(hosts ...SimHost) error { From 52c75aa56d9c236db48bd4aef5ddf79ec54ebe8c Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 21:05:46 -0400 Subject: [PATCH 04/18] test(e2e): unique fixture project, teardown and a shared TMPDIR (P7-SHIP-02) The fixture project gets a random name so its compose containers, volumes and networks never collide with another run, the harness removes them after the run, and the gate leg's volume probe is scoped to them. run.sh sets GOTMPDIR to its own scratch dir, which go test passes on as the test's TMPDIR, so bind mounts stay under TMPDIR (a Colima VM shares only HOME). (P7-SHIP-02) --- scripts/e2e/agent/run.sh | 6 ++++-- test/e2e/agent/agent_test.go | 1 + test/e2e/agent/harness.go | 27 ++++++++++++++++++++++++++- test/e2e/agent/live_test.go | 1 + test/e2e/agent/sandbox.go | 15 +++++++++++++-- 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/scripts/e2e/agent/run.sh b/scripts/e2e/agent/run.sh index 002c8e212..c66841fe4 100755 --- a/scripts/e2e/agent/run.sh +++ b/scripts/e2e/agent/run.sh @@ -64,7 +64,9 @@ mode_replay() { transcript=$(abspath "$transcript") tmp=$(scratch); bin="$tmp/nself" build_bin "$bin" - (cd "$ROOT" && INTEGRATION=1 NSELF_V15=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ + # GOTMPDIR becomes the test's TMPDIR: keep the fixture project under TMPDIR + # (a Colima VM shares only $HOME, so bind mounts elsewhere fail). + (cd "$ROOT" && GOTMPDIR="$tmp" INTEGRATION=1 NSELF_V15=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ $GOTEST -timeout 60m -v -run '^TestAgentReplay$' "$PKG") rc=$? rm -rf "$tmp" @@ -96,7 +98,7 @@ mode_live() { if [ -n "$probe" ]; then mkdir -p "$probe" && prod_probe "$probe" before; fi tmp=$(scratch); bin="$tmp/nself" build_bin "$bin" - (cd "$ROOT" && INTEGRATION=1 NSELF_V15=1 AGENT_E2E_LIVE=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ + (cd "$ROOT" && GOTMPDIR="$tmp" INTEGRATION=1 NSELF_V15=1 AGENT_E2E_LIVE=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ $GOTEST -timeout 90m -v -run '^TestAgentLive$' "$PKG") rc=$? rm -rf "$tmp" diff --git a/test/e2e/agent/agent_test.go b/test/e2e/agent/agent_test.go index 2d1c5e7d0..4d3925ee0 100644 --- a/test/e2e/agent/agent_test.go +++ b/test/e2e/agent/agent_test.go @@ -198,6 +198,7 @@ func TestAgentReplay(t *testing.T) { } defer w.Close() h := &SimHarness{T: t, Fleet: fleet, Box: box, Hosts: hosts} + t.Cleanup(h.Teardown) ctx := context.Background() if _, err := run(ctx, box.Project, "git", "init", "-q"); err != nil { t.Fatal(err) diff --git a/test/e2e/agent/harness.go b/test/e2e/agent/harness.go index 8beb4a307..74cd83351 100644 --- a/test/e2e/agent/harness.go +++ b/test/e2e/agent/harness.go @@ -84,7 +84,8 @@ func (h *SimHarness) Do(ctx context.Context, s Step, w *Writer) error { } note = NoteStopService + ": " + name case "volumes-before", "volumes-after": - out, err := run(ctx, "", "docker", "volume", "ls", "--format", "{{.Name}}") + out, err := run(ctx, "", "docker", "volume", "ls", "--format", "{{.Name}}", + "--filter", "label=com.docker.compose.project="+h.Box.ComposeProject()) if err != nil { return err } @@ -130,6 +131,30 @@ func (h *SimHarness) digests(action string, w *Writer) error { return nil } +// Teardown removes the fixture project's containers, volumes and networks +// (by its unique compose project label) after the run, pass or fail. It is a +// harness cleanup, never an agent step, and touches nothing else. +func (h *SimHarness) Teardown() { + ctx := context.Background() + label := "label=com.docker.compose.project=" + h.Box.ComposeProject() + for _, kind := range [][]string{{"container", "rm", "-f", "-v"}, {"volume", "rm", "-f"}, {"network", "rm"}} { + lsArgs := []string{kind[0], "ls", "-q", "--filter", label} + if kind[0] == "container" { + lsArgs = []string{"ps", "-aq", "--filter", label} + } + out, err := run(ctx, "", "docker", lsArgs...) + if err != nil { + h.T.Logf("teardown: %v", err) + continue + } + if ids := strings.Fields(out); len(ids) > 0 { + if _, err := run(ctx, "", "docker", append(kind, ids...)...); err != nil { + h.T.Logf("teardown: %v", err) + } + } + } +} + // serviceContainer picks one running service container of the project // (hasura when present, else the first by name). func (h *SimHarness) serviceContainer(ctx context.Context) (string, error) { diff --git a/test/e2e/agent/live_test.go b/test/e2e/agent/live_test.go index 1f5612ab5..38d5de744 100644 --- a/test/e2e/agent/live_test.go +++ b/test/e2e/agent/live_test.go @@ -60,6 +60,7 @@ func TestAgentLive(t *testing.T) { t.Fatal(err) } defer w.Close() + t.Cleanup((&SimHarness{T: t, Box: box}).Teardown) ctx := context.Background() if _, err := run(ctx, box.Project, "git", "init", "-q"); err != nil { t.Fatal(err) diff --git a/test/e2e/agent/sandbox.go b/test/e2e/agent/sandbox.go index b827957b7..ab451ea26 100644 --- a/test/e2e/agent/sandbox.go +++ b/test/e2e/agent/sandbox.go @@ -14,6 +14,8 @@ package agent // operator's ~/.ssh. import ( + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "os" @@ -53,9 +55,15 @@ type Sandbox struct { Timeout string } -// NewSandbox makes root/home (with an empty .ssh) and root/project. +// NewSandbox makes root/home (with an empty .ssh) and root/ae2e, the +// project. The random name becomes the compose project name, so container, +// volume and network names never collide with another run on the same host. func NewSandbox(root string) (*Sandbox, error) { - s := &Sandbox{Root: root, Home: filepath.Join(root, "home"), Project: filepath.Join(root, "project")} + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return nil, err + } + s := &Sandbox{Root: root, Home: filepath.Join(root, "home"), Project: filepath.Join(root, "ae2e"+hex.EncodeToString(b[:]))} for _, d := range []string{filepath.Join(s.Home, ".ssh"), s.Project} { if err := os.MkdirAll(d, 0o700); err != nil { return nil, err @@ -137,6 +145,9 @@ func (s *Sandbox) WriteInventory(hosts ...SimHost) error { return controlplane.Write(s.Project, inv) } +// ComposeProject is the compose project name of the fixture project. +func (s *Sandbox) ComposeProject() string { return strings.ToLower(filepath.Base(s.Project)) } + // InventoryPath is where controlplane.Write puts the inventory. func (s *Sandbox) InventoryPath() string { return filepath.Join(s.Project, ".nself", "control-plane.yaml") From aef0cc9d9af59ad8eb97d8ccac9440d8aedf3c4b Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 21:08:10 -0400 Subject: [PATCH 05/18] test(e2e): create the first migration before migrate up (P7-SHIP-02) A fresh project has no hasura/migrations/default, so db migrate up alone fails E400. The agent now creates its first migration over MCP (nself_db_migrate_create) and then applies it. (P7-SHIP-02) --- test/e2e/agent/testdata/live-prompt.md | 3 ++- test/e2e/agent/testdata/replay.jsonl | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e/agent/testdata/live-prompt.md b/test/e2e/agent/testdata/live-prompt.md index c3d952f81..0bd23a202 100644 --- a/test/e2e/agent/testdata/live-prompt.md +++ b/test/e2e/agent/testdata/live-prompt.md @@ -15,7 +15,8 @@ Do these steps, in order, using MCP tools only: `nself_start` and check status again. 5. Call `nself_reset` once without a confirmation, read the confirmation request it returns, and do NOT confirm it. -6. Apply database migrations (`nself_db_migrate_up`). +6. Create a first migration (`nself_db_migrate_create` named + `agent_e2e_first`) and apply pending migrations (`nself_db_migrate_up`). 7. Install the ci plugin (`nself_add` with `ci`), list the tools again, then run CI the async way: `nself_ci` with `detach: true`, then `nself_ci_runs_wait` with the returned run id until it is terminal, then diff --git a/test/e2e/agent/testdata/replay.jsonl b/test/e2e/agent/testdata/replay.jsonl index b2b6ce8f5..fac08c79c 100644 --- a/test/e2e/agent/testdata/replay.jsonl +++ b/test/e2e/agent/testdata/replay.jsonl @@ -17,6 +17,7 @@ {"id":"gate-volumes-before","role":"harness","action":"volumes-before"} {"id":"reset-unconfirmed","role":"agent","tool":"nself_reset","expect":"confirmation"} {"id":"gate-volumes-after","role":"harness","action":"volumes-after"} +{"id":"migrate-create","role":"agent","tool":"nself_db_migrate_create","arguments":{"args":["agent_e2e_first"]}} {"id":"migrate","role":"agent","tool":"nself_db_migrate_up"} {"id":"add-ci","role":"agent","tool":"nself_add","arguments":{"args":["ci"]},"confirm":true} {"id":"discover-ci","role":"agent","kind":"tools/list","require":["nself_ci","nself_ci_runs_wait","nself_ci_evidence"]} From 93052b8de8a8cea7bf92a4dc8714cba7bd87a212 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 21:25:40 -0400 Subject: [PATCH 06/18] test(e2e): look up the compose plugin from test code (P7-SHIP-02) The docker info call moves out of sandbox.go into the tests, so the harness adds no direct docker execution site outside internal/docker (repoqa TestDockerFunnel ratchet). (P7-SHIP-02) --- test/e2e/agent/agent_test.go | 14 +++++++++++++- test/e2e/agent/live_test.go | 2 +- test/e2e/agent/sandbox.go | 19 ++++++------------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/test/e2e/agent/agent_test.go b/test/e2e/agent/agent_test.go index 4d3925ee0..02b2e7f21 100644 --- a/test/e2e/agent/agent_test.go +++ b/test/e2e/agent/agent_test.go @@ -174,7 +174,7 @@ func TestAgentReplay(t *testing.T) { t.Fatal(err) } box.Timeout = replayTimeout - if err := box.WriteDockerConfig(); err != nil { + if err := box.WriteDockerConfig(composePluginPath(t)); err != nil { t.Fatal(err) } for _, step := range []func() error{ @@ -217,6 +217,18 @@ func TestAgentReplay(t *testing.T) { } } +// composePluginPath is the docker compose CLI plugin path `docker info` +// reports (run with the operator's HOME, before the sandbox HOME is set). +func composePluginPath(t *testing.T) string { + t.Helper() + out, err := exec.Command("docker", "info", "--format", + `{{range .ClientInfo.Plugins}}{{if eq .Name "compose"}}{{.Path}}{{end}}{{end}}`).Output() + if err != nil { + t.Fatalf("find the docker compose plugin: %v", err) + } + return strings.TrimSpace(string(out)) +} + // trustHosts records each sim host's ed25519 key in the sandbox HOME (the // HOME must already be the sandbox's), as the deploy-sim tests do. func trustHosts(t *testing.T, hosts []SimHost) { diff --git a/test/e2e/agent/live_test.go b/test/e2e/agent/live_test.go index 38d5de744..9f77af55d 100644 --- a/test/e2e/agent/live_test.go +++ b/test/e2e/agent/live_test.go @@ -39,7 +39,7 @@ func TestAgentLive(t *testing.T) { t.Fatal(err) } box.Timeout = replayTimeout - if err := box.WriteDockerConfig(); err != nil { + if err := box.WriteDockerConfig(composePluginPath(t)); err != nil { t.Fatal(err) } if err := box.InstallKey(fleet.KeyPath()); err != nil { diff --git a/test/e2e/agent/sandbox.go b/test/e2e/agent/sandbox.go index ab451ea26..0be0a056f 100644 --- a/test/e2e/agent/sandbox.go +++ b/test/e2e/agent/sandbox.go @@ -19,7 +19,6 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -107,20 +106,14 @@ var systemPluginDirs = map[string]bool{ } // WriteDockerConfig lets the docker CLI in the sandbox find the compose -// plugin when it lives outside the system plugin dirs (Homebrew, Docker -// Desktop): HOME/.docker/config.json with only cliPluginsExtraDirs, no auths. -// Call it before HOME points at the sandbox. -func (s *Sandbox) WriteDockerConfig() error { - out, err := exec.Command("docker", "info", "--format", - `{{range .ClientInfo.Plugins}}{{if eq .Name "compose"}}{{.Path}}{{end}}{{end}}`).Output() - if err != nil { - return fmt.Errorf("sandbox: find the docker compose plugin: %w", err) - } - path := strings.TrimSpace(string(out)) - if path == "" { +// plugin (at composePlugin, the path `docker info` reports) when it lives +// outside the system plugin dirs (Homebrew, Docker Desktop): +// HOME/.docker/config.json with only cliPluginsExtraDirs, no auths. +func (s *Sandbox) WriteDockerConfig(composePlugin string) error { + if composePlugin == "" { return fmt.Errorf("sandbox: docker has no compose plugin") } - dir := filepath.Dir(path) + dir := filepath.Dir(composePlugin) if systemPluginDirs[dir] { return nil } From eabaf35aa105041d3167eee076a7ed694a13e6e3 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 23:18:45 -0400 Subject: [PATCH 07/18] test(e2e): run the ci leg against a ci plugin built from plugins source (P7-SHIP-02) The served ci release predates the ci machine surface (P7-CI-62). run.sh --replay --ci-plugin-src builds ci from a plugins checkout; after the agent's nself_add ci the harness swaps the build in (a harness record naming the revision) and an agent nself_status call refreshes the live tool list before discover-ci. The workflow checks out plugins main for it. (P7-SHIP-02) --- .github/workflows/agent-e2e.yml | 11 +++- scripts/e2e/agent/run.sh | 17 ++++-- test/e2e/agent/README.md | 4 +- test/e2e/agent/agent_test.go | 3 +- test/e2e/agent/ci_plugin.go | 77 ++++++++++++++++++++++++++++ test/e2e/agent/harness.go | 8 +++ test/e2e/agent/testdata/replay.jsonl | 2 + 7 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 test/e2e/agent/ci_plugin.go diff --git a/.github/workflows/agent-e2e.yml b/.github/workflows/agent-e2e.yml index 5cdf27d35..927433e1d 100644 --- a/.github/workflows/agent-e2e.yml +++ b/.github/workflows/agent-e2e.yml @@ -33,10 +33,19 @@ jobs: 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 - name: Self-test (verifier and sandbox refusals) run: bash scripts/e2e/agent/run.sh --self-test - name: Replay over MCP - run: bash scripts/e2e/agent/run.sh --replay --transcript "$RUNNER_TEMP/agent-e2e/replay.jsonl" + # 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 diff --git a/scripts/e2e/agent/run.sh b/scripts/e2e/agent/run.sh index c66841fe4..718f6507b 100755 --- a/scripts/e2e/agent/run.sh +++ b/scripts/e2e/agent/run.sh @@ -2,12 +2,14 @@ # run.sh — the MCP-only agent E2E harness (P7-SHIP-02, SHIP Epic D7). # # Modes (run from the cli repo root): -# run.sh --replay [--transcript FILE] +# run.sh --replay [--transcript FILE] [--ci-plugin-src DIR] # Build nself, start two loopback sim hosts (qa tier local-servers, # prod tier prod) and replay test/e2e/agent/testdata/replay.jsonl over # `nself mcp` in the sandbox. Writes the transcript (default # $AGENT_E2E_OUT/replay.jsonl, else $TMPDIR/agent-e2e-out) and verifies it. -# Needs Docker. +# Needs Docker. --ci-plugin-src (a plugins checkout's free/ci) makes the +# harness swap in a ci plugin built from that source after the agent's +# `add ci`, until a served ci release carries the machine surface. # run.sh --live --transcript FILE [--prod-probe DIR] # The same fleet and sandbox, driven by a headless `claude -p` session # whose only tools are the sandboxed nself server (P7-SHIP-13). @@ -33,7 +35,7 @@ PKG=./test/e2e/agent/ SANDBOX="$ROOT/scripts/e2e/agent/mcp-sandbox.sh" GOTEST="go test -mod=vendor -tags e2eagent -count=1" -usage() { sed -n '2,28p' "$0" >&2; exit 2; } +usage() { sed -n '2,29p' "$0" >&2; exit 2; } fail() { printf 'agent-e2e: %s\n' "$1" >&2; exit 1; } scratch() { mktemp -d "${TMPDIR:-/tmp}/agent-e2e.XXXXXX"; } @@ -52,13 +54,18 @@ abspath() { (cd "$(dirname "$1")" && printf '%s/%s' "$(pwd)" "$(basename "$1")") mode_replay() { out="${AGENT_E2E_OUT:-${TMPDIR:-/tmp}/agent-e2e-out}" - transcript="$out/replay.jsonl" + transcript="$out/replay.jsonl" cisrc="" while [ $# -gt 0 ]; do case "$1" in --transcript) transcript="${2:-}"; shift 2 ;; + --ci-plugin-src) cisrc="${2:-}"; shift 2 ;; *) usage ;; esac done + if [ -n "$cisrc" ]; then + [ -f "$cisrc/plugin.json" ] || fail "--ci-plugin-src $cisrc has no plugin.json" + cisrc=$(cd "$cisrc" && pwd) + fi need_docker mkdir -p "$(dirname "$transcript")" || fail "cannot create $(dirname "$transcript")" transcript=$(abspath "$transcript") @@ -67,7 +74,7 @@ mode_replay() { # GOTMPDIR becomes the test's TMPDIR: keep the fixture project under TMPDIR # (a Colima VM shares only $HOME, so bind mounts elsewhere fail). (cd "$ROOT" && GOTMPDIR="$tmp" INTEGRATION=1 NSELF_V15=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ - $GOTEST -timeout 60m -v -run '^TestAgentReplay$' "$PKG") + AGENT_E2E_CI_PLUGIN_SRC="$cisrc" $GOTEST -timeout 60m -v -run '^TestAgentReplay$' "$PKG") rc=$? rm -rf "$tmp" [ $rc -eq 0 ] || fail "replay failed; transcript: $transcript" diff --git a/test/e2e/agent/README.md b/test/e2e/agent/README.md index 4d2a96de5..b346b2538 100644 --- a/test/e2e/agent/README.md +++ b/test/e2e/agent/README.md @@ -24,7 +24,7 @@ Scripts: `scripts/e2e/agent/run.sh` (all modes) and `scripts/e2e/agent/mcp-sandb ```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 --replay --transcript /tmp/agent-e2e/replay.jsonl [--ci-plugin-src ../plugins/free/ci] bash scripts/e2e/agent/run.sh --verify-transcript /tmp/agent-e2e/replay.jsonl bash scripts/e2e/agent/run.sh --tools-list ./nself --env NSELF_V15=1 bash scripts/e2e/agent/run.sh --live --transcript /tmp/agent-e2e/live.jsonl [--prod-probe DIR] @@ -46,5 +46,5 @@ The verifier fails a transcript that has a non-MCP agent step, a missing D7 step - The server starts in the empty fixture project and lists `nself_init` plus the read-only tools; after `init` it lists every tool, and after `add ci` it sends `notifications/tools/list_changed` and the ci tools appear. - The replay and live runs start `nself mcp --timeout 30m`: a cold `start` pulls every image and outlasts the 5m default. -- The ci leg needs the served ci plugin to declare its machine surface (P7-CI-62); without it `tools/list` after `add ci` lacks `nself_ci`, and the replay stops there with that message. +- The served ci release (1.2.13) predates the ci machine surface (P7-CI-62). `run.sh --replay --ci-plugin-src /free/ci` builds ci from that source; after the agent's `nself_add ci` the harness swaps the build in (a `role: harness` record naming the revision) and an agent `nself_status` call lets the server refresh its tool list. The workflow uses plugins main. Without the flag the served plugin is kept, and `discover-ci` fails until a release carries the surface. The live mode has no such swap, so it needs that release. - The sim hosts get recording `docker` and `nself` stubs, as the deploy-sim tests do. The image-digest compare after the promote reads the stubs' record of `compose up`. diff --git a/test/e2e/agent/agent_test.go b/test/e2e/agent/agent_test.go index 02b2e7f21..dbc830331 100644 --- a/test/e2e/agent/agent_test.go +++ b/test/e2e/agent/agent_test.go @@ -186,6 +186,7 @@ func TestAgentReplay(t *testing.T) { t.Fatal(err) } } + ciPlugin := BuildCIPlugin(t) t.Setenv("HOME", box.Home) trustHosts(t, hosts) tpath := os.Getenv("AGENT_E2E_TRANSCRIPT_OUT") @@ -197,7 +198,7 @@ func TestAgentReplay(t *testing.T) { t.Fatal(err) } defer w.Close() - h := &SimHarness{T: t, Fleet: fleet, Box: box, Hosts: hosts} + h := &SimHarness{T: t, Fleet: fleet, Box: box, Hosts: hosts, CI: ciPlugin} t.Cleanup(h.Teardown) ctx := context.Background() if _, err := run(ctx, box.Project, "git", "init", "-q"); err != nil { diff --git a/test/e2e/agent/ci_plugin.go b/test/e2e/agent/ci_plugin.go new file mode 100644 index 000000000..c46a45348 --- /dev/null +++ b/test/e2e/agent/ci_plugin.go @@ -0,0 +1,77 @@ +//go:build e2eagent + +package agent + +// Purpose: run the replay's CI leg against a ci plugin built from plugins +// source (run.sh --ci-plugin-src) until a served ci release carries the +// machine surface (P7-CI-62). The agent's `nself_add ci` stays a real MCP +// step; the harness then replaces the installed manifest and binary with the +// source build, as a `role: harness` record naming the source revision. +// Inputs: AGENT_E2E_CI_PLUGIN_SRC (the free/ci directory of a plugins checkout). +// Outputs: HOME/.nself/plugins/ci/plugin.json and HOME/.nself/plugins/bin/nself-ci +// in the sandbox (the P7-CI-47 layout). +// Constraints: the build runs before HOME points at the sandbox, so the Go +// caches stay the operator's; without a source dir the served plugin is kept. + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// CIPluginSrcEnv names the plugins free/ci source directory. +const CIPluginSrcEnv = "AGENT_E2E_CI_PLUGIN_SRC" + +// CIPlugin is a ci plugin built from source. +type CIPlugin struct { + Manifest, Binary, Revision string +} + +// BuildCIPlugin builds the ci plugin from $AGENT_E2E_CI_PLUGIN_SRC, or +// returns nil when it is not set. +func BuildCIPlugin(t *testing.T) *CIPlugin { + t.Helper() + src := os.Getenv(CIPluginSrcEnv) + if src == "" { + return nil + } + bin := filepath.Join(t.TempDir(), "nself-ci") + ctx := context.Background() + if _, err := run(ctx, src, "go", "build", "-mod=vendor", "-trimpath", "-o", bin, "./cmd"); err != nil { + t.Fatalf("build the ci plugin from %s: %v", src, err) + } + rev, err := run(ctx, src, "git", "rev-parse", "HEAD") + if err != nil { + rev = "unknown" + } + return &CIPlugin{Manifest: filepath.Join(src, "plugin.json"), Binary: bin, Revision: strings.TrimSpace(rev)} +} + +// installCIPlugin replaces the installed ci plugin with the source build. +func (h *SimHarness) installCIPlugin() (string, error) { + if h.CI == nil { + return "served ci plugin kept (no " + CIPluginSrcEnv + ")", nil + } + root := filepath.Join(h.Box.Home, ".nself", "plugins") + for src, dst := range map[string]string{ + h.CI.Manifest: filepath.Join(root, "ci", "plugin.json"), + h.CI.Binary: filepath.Join(root, "bin", "nself-ci"), + } { + b, err := os.ReadFile(src) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(dst+".tmp", b, 0o755); err != nil { + return "", err + } + if err := os.Rename(dst+".tmp", dst); err != nil { + return "", err + } + } + return "ci plugin from plugins source " + h.CI.Revision, nil +} diff --git a/test/e2e/agent/harness.go b/test/e2e/agent/harness.go index 74cd83351..f17f07807 100644 --- a/test/e2e/agent/harness.go +++ b/test/e2e/agent/harness.go @@ -34,6 +34,8 @@ type SimHarness struct { Fleet *simharness.Fleet Box *Sandbox Hosts []SimHost + // CI, when set, replaces the ci plugin after the agent's add (ci_plugin.go). + CI *CIPlugin // before holds the image sets of the sim hosts captured by digest-before. before map[string]string } @@ -97,6 +99,12 @@ func (h *SimHarness) Do(ctx context.Context, s Step, w *Writer) error { return err case "digest-before", "digest-compare": return h.digests(s.Action, w) + case "ci-plugin-from-source": + msg, err := h.installCIPlugin() + if err != nil { + return err + } + note = s.Action + ": " + msg default: return fmt.Errorf("unknown harness action %q", s.Action) } diff --git a/test/e2e/agent/testdata/replay.jsonl b/test/e2e/agent/testdata/replay.jsonl index fac08c79c..dc9062468 100644 --- a/test/e2e/agent/testdata/replay.jsonl +++ b/test/e2e/agent/testdata/replay.jsonl @@ -20,6 +20,8 @@ {"id":"migrate-create","role":"agent","tool":"nself_db_migrate_create","arguments":{"args":["agent_e2e_first"]}} {"id":"migrate","role":"agent","tool":"nself_db_migrate_up"} {"id":"add-ci","role":"agent","tool":"nself_add","arguments":{"args":["ci"]},"confirm":true} +{"id":"ci-plugin-from-source","role":"harness","action":"ci-plugin-from-source","note":"until a served ci release carries the machine surface (P7-CI-62)"} +{"id":"status-after-add","role":"agent","tool":"nself_status"} {"id":"discover-ci","role":"agent","kind":"tools/list","require":["nself_ci","nself_ci_runs_wait","nself_ci_evidence"]} {"id":"ci-detach","role":"agent","tool":"nself_ci","arguments":{"flags":{"detach":true}},"confirm":true,"capture":{"run_id":"run_id"}} {"id":"ci-wait","role":"agent","tool":"nself_ci_runs_wait","arguments":{"args":["${run_id}"]},"until":"terminal","max_repeats":30} From b1afe178cc664c3758922f7bac0ea2d25ad8a940 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 23:23:50 -0400 Subject: [PATCH 08/18] test(e2e): seed the sim hosts with the project's plugin manifests (P7-SHIP-02) deploy refuses a target whose plugin set differs from the local one (E485). The sim hosts stand in for servers already provisioned with the project's plugins, so a harness step copies each installed plugin manifest to the hosts before deploy qa. (P7-SHIP-02) --- test/e2e/agent/harness.go | 37 ++++++++++++++++++++++++++++ test/e2e/agent/testdata/replay.jsonl | 1 + 2 files changed, 38 insertions(+) diff --git a/test/e2e/agent/harness.go b/test/e2e/agent/harness.go index f17f07807..f741745fa 100644 --- a/test/e2e/agent/harness.go +++ b/test/e2e/agent/harness.go @@ -15,6 +15,7 @@ import ( "context" "encoding/json" "fmt" + "os" "os/exec" "path/filepath" "sort" @@ -99,6 +100,12 @@ func (h *SimHarness) Do(ctx context.Context, s Step, w *Writer) error { return err case "digest-before", "digest-compare": return h.digests(s.Action, w) + case "seed-target-plugins": + names, err := h.seedTargetPlugins() + if err != nil { + return err + } + note = s.Action + ": " + strings.Join(names, ", ") case "ci-plugin-from-source": msg, err := h.installCIPlugin() if err != nil { @@ -163,6 +170,36 @@ func (h *SimHarness) Teardown() { } } +// seedTargetPlugins copies the manifest of every plugin installed in the +// sandbox HOME to each sim host's ~/.nself/plugins, so the hosts stand in for +// servers already provisioned with the project's plugins (deploy refuses a +// target whose plugin set differs, E485). +func (h *SimHarness) seedTargetPlugins() ([]string, error) { + root := filepath.Join(h.Box.Home, ".nself", "plugins") + entries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + var names []string + for _, e := range entries { + if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "plugin.json")) { + names = append(names, e.Name()) + } + } + for _, host := range h.Hosts { + home := strings.TrimSpace(h.Fleet.Exec(h.T, host.Server, "sh", "-c", "getent passwd "+host.User+" | cut -d: -f6")) + for _, name := range names { + dir := home + "/.nself/plugins/" + name + h.Fleet.Exec(h.T, host.Server, "mkdir", "-p", dir) + h.Fleet.CopyTo(h.T, host.Server, filepath.Join(root, name, "plugin.json"), dir+"/plugin.json") + } + h.Fleet.Exec(h.T, host.Server, "chown", "-R", host.User, home+"/.nself") + } + return names, nil +} + +func fileExists(p string) bool { _, err := os.Stat(p); return err == nil } + // serviceContainer picks one running service container of the project // (hasura when present, else the first by name). func (h *SimHarness) serviceContainer(ctx context.Context) (string, error) { diff --git a/test/e2e/agent/testdata/replay.jsonl b/test/e2e/agent/testdata/replay.jsonl index dc9062468..bd593df91 100644 --- a/test/e2e/agent/testdata/replay.jsonl +++ b/test/e2e/agent/testdata/replay.jsonl @@ -26,6 +26,7 @@ {"id":"ci-detach","role":"agent","tool":"nself_ci","arguments":{"flags":{"detach":true}},"confirm":true,"capture":{"run_id":"run_id"}} {"id":"ci-wait","role":"agent","tool":"nself_ci_runs_wait","arguments":{"args":["${run_id}"]},"until":"terminal","max_repeats":30} {"id":"ci-evidence","role":"agent","tool":"nself_ci_evidence","arguments":{"args":["${run_id}"]},"expect":"evidence-pass"} +{"id":"seed-target-plugins","role":"harness","action":"seed-target-plugins","note":"the sim hosts stand in for servers provisioned with the project's plugins"} {"id":"deploy-qa","role":"agent","tool":"nself_deploy","arguments":{"args":["qa"]},"confirm":true} {"id":"digest-before","role":"harness","action":"digest-before"} {"id":"promote-dry-run","role":"agent","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"confirm":true} From 34ae103ddf9be2b3b43337518a856a13c89ed853 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 30 Oct 2026 00:59:29 -0400 Subject: [PATCH 09/18] test(e2e): sim prod declares no verified release; scratch under HOME (P7-SHIP-02) The prod sim env sets require_verified_release: false (DEPL D20 inventory field): D7 proves the promote and its digest compare, while a verified release needs a signed release-class pipeline the fixture has not got. run.sh keeps its scratch, TMPDIR and GOTMPDIR under HOME/.cache/nself-agent-e2e by default, since a Colima VM shares only HOME. (P7-SHIP-02) --- scripts/e2e/agent/run.sh | 13 ++++++++----- test/e2e/agent/README.md | 2 ++ test/e2e/agent/sandbox.go | 10 +++++++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/scripts/e2e/agent/run.sh b/scripts/e2e/agent/run.sh index 718f6507b..8a0894aca 100755 --- a/scripts/e2e/agent/run.sh +++ b/scripts/e2e/agent/run.sh @@ -6,7 +6,7 @@ # Build nself, start two loopback sim hosts (qa tier local-servers, # prod tier prod) and replay test/e2e/agent/testdata/replay.jsonl over # `nself mcp` in the sandbox. Writes the transcript (default -# $AGENT_E2E_OUT/replay.jsonl, else $TMPDIR/agent-e2e-out) and verifies it. +# $AGENT_E2E_OUT/replay.jsonl, else $AGENT_E2E_TMP/out) and verifies it. # Needs Docker. --ci-plugin-src (a plugins checkout's free/ci) makes the # harness swap in a ci plugin built from that source after the agent's # `add ci`, until a served ci release carries the machine surface. @@ -38,7 +38,10 @@ GOTEST="go test -mod=vendor -tags e2eagent -count=1" usage() { sed -n '2,29p' "$0" >&2; exit 2; } fail() { printf 'agent-e2e: %s\n' "$1" >&2; exit 1; } -scratch() { mktemp -d "${TMPDIR:-/tmp}/agent-e2e.XXXXXX"; } +# Scratch lives under $HOME by default: a Colima or Docker Desktop VM shares +# only the home directory, so bind mounts from a system TMPDIR fail there. +E2E_TMP="${AGENT_E2E_TMP:-$HOME/.cache/nself-agent-e2e}" +scratch() { mkdir -p "$E2E_TMP" && mktemp -d "$E2E_TMP/run.XXXXXX"; } need_docker() { command -v docker >/dev/null 2>&1 || fail "Docker is required (no docker CLI)" @@ -53,7 +56,7 @@ build_bin() { # out abspath() { (cd "$(dirname "$1")" && printf '%s/%s' "$(pwd)" "$(basename "$1")"); } mode_replay() { - out="${AGENT_E2E_OUT:-${TMPDIR:-/tmp}/agent-e2e-out}" + out="${AGENT_E2E_OUT:-$E2E_TMP/out}" transcript="$out/replay.jsonl" cisrc="" while [ $# -gt 0 ]; do case "$1" in @@ -73,7 +76,7 @@ mode_replay() { build_bin "$bin" # GOTMPDIR becomes the test's TMPDIR: keep the fixture project under TMPDIR # (a Colima VM shares only $HOME, so bind mounts elsewhere fail). - (cd "$ROOT" && GOTMPDIR="$tmp" INTEGRATION=1 NSELF_V15=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ + (cd "$ROOT" && TMPDIR="$tmp" GOTMPDIR="$tmp" INTEGRATION=1 NSELF_V15=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ AGENT_E2E_CI_PLUGIN_SRC="$cisrc" $GOTEST -timeout 60m -v -run '^TestAgentReplay$' "$PKG") rc=$? rm -rf "$tmp" @@ -105,7 +108,7 @@ mode_live() { if [ -n "$probe" ]; then mkdir -p "$probe" && prod_probe "$probe" before; fi tmp=$(scratch); bin="$tmp/nself" build_bin "$bin" - (cd "$ROOT" && GOTMPDIR="$tmp" INTEGRATION=1 NSELF_V15=1 AGENT_E2E_LIVE=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ + (cd "$ROOT" && TMPDIR="$tmp" GOTMPDIR="$tmp" INTEGRATION=1 NSELF_V15=1 AGENT_E2E_LIVE=1 AGENT_E2E_BIN="$bin" AGENT_E2E_TRANSCRIPT_OUT="$transcript" \ $GOTEST -timeout 90m -v -run '^TestAgentLive$' "$PKG") rc=$? rm -rf "$tmp" diff --git a/test/e2e/agent/README.md b/test/e2e/agent/README.md index b346b2538..a4b5b69ad 100644 --- a/test/e2e/agent/README.md +++ b/test/e2e/agent/README.md @@ -47,4 +47,6 @@ The verifier fails a transcript that has a non-MCP agent step, a missing D7 step - The server starts in the empty fixture project and lists `nself_init` plus the read-only tools; after `init` it lists every tool, and after `add ci` it sends `notifications/tools/list_changed` and the ci tools appear. - The replay and live runs start `nself mcp --timeout 30m`: a cold `start` pulls every image and outlasts the 5m default. - The served ci release (1.2.13) predates the ci machine surface (P7-CI-62). `run.sh --replay --ci-plugin-src /free/ci` builds ci from that source; after the agent's `nself_add ci` the harness swaps the build in (a `role: harness` record naming the revision) and an agent `nself_status` call lets the server refresh its tool list. The workflow uses plugins main. Without the flag the served plugin is kept, and `discover-ci` fails until a release carries the surface. The live mode has no such swap, so it needs that release. +- The prod sim env sets `require_verified_release: false` (an inventory field, DEPL D20). The proof covers the promote and its digest compare; a verified release needs a signed release-class ci pipeline, which DEPL-19's own tests cover. The fixture repo has no origin, so nothing committed overrides the field. +- run.sh keeps its scratch (and the test's TMPDIR and GOTMPDIR) under `$HOME/.cache/nself-agent-e2e` unless `AGENT_E2E_TMP` says otherwise: a Colima or Docker Desktop VM shares only HOME. - The sim hosts get recording `docker` and `nself` stubs, as the deploy-sim tests do. The image-digest compare after the promote reads the stubs' record of `compose up`. diff --git a/test/e2e/agent/sandbox.go b/test/e2e/agent/sandbox.go index 0be0a056f..4becf3459 100644 --- a/test/e2e/agent/sandbox.go +++ b/test/e2e/agent/sandbox.go @@ -130,10 +130,18 @@ func (s *Sandbox) WriteInventory(hosts ...SimHost) error { inv := &controlplane.Inventory{SchemaVersion: 2, Environments: map[string]controlplane.Environment{ "local": {Name: "local", Kind: "local", Tier: controlplane.TierLocal}, }} + // The prod sim env declares no verified release: D7 proves the promote and + // its digest compare; release verification (DEPL D20) has its own proof and + // needs a signed release-class pipeline the fixture project does not have. + noRelease := false for _, h := range hosts { - inv.Environments[h.Env] = controlplane.Environment{Name: h.Env, Kind: "remote", Tier: h.Tier, + env := controlplane.Environment{Name: h.Env, Kind: "remote", Tier: h.Tier, Servers: []controlplane.Server{{Name: h.Server, Role: controlplane.RoleApp, Host: h.Host(), RemotePath: h.RemotePath, Primary: true, SSHKeyRef: SimKeyEnv}}} + if h.Tier == controlplane.TierProd { + env.RequireVerifiedRelease = &noRelease + } + inv.Environments[h.Env] = env } return controlplane.Write(s.Project, inv) } From 115a824799745db59b287d033ec5e3f7c6664b92 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 30 Oct 2026 01:03:39 -0400 Subject: [PATCH 10/18] test(e2e): sim hosts report the images' real registry digests (P7-SHIP-02) promote compares per-platform image identities against the registry, so the stub's invented digests read as release drift (E490). The harness now records each project image's RepoDigest from the operator's Docker on the sim hosts, and the docker stub's image inspect reports it with the host's real platform. (P7-SHIP-02) --- test/e2e/agent/harness.go | 43 +++++++++++++++++++++++++++ test/e2e/agent/testdata/replay.jsonl | 1 + test/e2e/agent/testdata/sim/docker.sh | 36 ++++++++++++++-------- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/test/e2e/agent/harness.go b/test/e2e/agent/harness.go index f741745fa..e725db9f4 100644 --- a/test/e2e/agent/harness.go +++ b/test/e2e/agent/harness.go @@ -106,6 +106,12 @@ func (h *SimHarness) Do(ctx context.Context, s Step, w *Writer) error { return err } note = s.Action + ": " + strings.Join(names, ", ") + case "seed-image-digests": + n, err := h.seedImageDigests(ctx) + if err != nil { + return err + } + note = fmt.Sprintf("%s: %d images", s.Action, n) case "ci-plugin-from-source": msg, err := h.installCIPlugin() if err != nil { @@ -198,6 +204,43 @@ func (h *SimHarness) seedTargetPlugins() ([]string, error) { return names, nil } +// seedImageDigests writes " @sha256:" for every image the +// local project runs (as the operator's Docker pulled it) to each sim host's +// /tmp/agent-e2e/digests, which the docker stub's `image inspect` reports. +func (h *SimHarness) seedImageDigests(ctx context.Context) (int, error) { + out, err := run(ctx, "", "docker", "ps", "--format", "{{.Image}}", + "--filter", "label=com.docker.compose.project="+h.Box.ComposeProject()) + if err != nil { + return 0, err + } + seen := map[string]bool{} + var lines []string + for _, ref := range strings.Fields(out) { + if seen[ref] { + continue + } + seen[ref] = true + rd, err := run(ctx, "", "docker", "image", "inspect", "--format", "{{index .RepoDigests 0}}", ref) + if err != nil { + return 0, err + } + lines = append(lines, ref+" "+strings.TrimSpace(rd)) + } + if len(lines) == 0 { + return 0, fmt.Errorf("the project runs no images to seed") + } + sort.Strings(lines) + f := filepath.Join(h.T.TempDir(), "digests") + if err := os.WriteFile(f, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + return 0, err + } + for _, host := range h.Hosts { + h.Fleet.CopyTo(h.T, host.Server, f, "/tmp/agent-e2e/digests") + h.Fleet.Exec(h.T, host.Server, "chmod", "644", "/tmp/agent-e2e/digests") + } + return len(lines), nil +} + func fileExists(p string) bool { _, err := os.Stat(p); return err == nil } // serviceContainer picks one running service container of the project diff --git a/test/e2e/agent/testdata/replay.jsonl b/test/e2e/agent/testdata/replay.jsonl index bd593df91..9568db649 100644 --- a/test/e2e/agent/testdata/replay.jsonl +++ b/test/e2e/agent/testdata/replay.jsonl @@ -27,6 +27,7 @@ {"id":"ci-wait","role":"agent","tool":"nself_ci_runs_wait","arguments":{"args":["${run_id}"]},"until":"terminal","max_repeats":30} {"id":"ci-evidence","role":"agent","tool":"nself_ci_evidence","arguments":{"args":["${run_id}"]},"expect":"evidence-pass"} {"id":"seed-target-plugins","role":"harness","action":"seed-target-plugins","note":"the sim hosts stand in for servers provisioned with the project's plugins"} +{"id":"seed-image-digests","role":"harness","action":"seed-image-digests","note":"the stubbed hosts report the registry digests of the images the project pulled"} {"id":"deploy-qa","role":"agent","tool":"nself_deploy","arguments":{"args":["qa"]},"confirm":true} {"id":"digest-before","role":"harness","action":"digest-before"} {"id":"promote-dry-run","role":"agent","tool":"nself_deploy_promote","arguments":{"args":["qa","prod"],"flags":{"dry-run":true}},"confirm":true} diff --git a/test/e2e/agent/testdata/sim/docker.sh b/test/e2e/agent/testdata/sim/docker.sh index c43e41283..96aec1175 100755 --- a/test/e2e/agent/testdata/sim/docker.sh +++ b/test/e2e/agent/testdata/sim/docker.sh @@ -1,33 +1,45 @@ #!/bin/sh # Recording docker stub for the agent E2E sim hosts (P7-SHIP-02). It mirrors # the stubs of internal/controlplane/sim: the sim hosts are sshd containers -# without a Docker engine, so `compose up` records the image set of the +# without a Docker engine, so `compose up` records the image references of the # shipped docker-compose.yml in /tmp/agent-e2e/running and the read commands -# answer from that record. Image digests are derived from the reference, so -# two hosts running the same references report the same digests. +# answer from that record. `image inspect` reports the image's real registry +# digest from /tmp/agent-e2e/digests (" @sha256:" lines the +# harness writes from the operator's pulled images) and the host's platform, +# so promote's digest compare sees the same identities a real host would. S=/tmp/agent-e2e mkdir -p "$S" echo "$*" >> "$S/docker.log" -images() { awk '/^ image:/{print $2}' docker-compose.yml 2>/dev/null | sort -u; } -digest() { printf '%s' "$1" | sha256sum | cut -c1-64; } +images() { awk '/^ image:/{print $2}' docker-compose.yml 2>/dev/null | tr -d "\"'" | sort -u; } +idof() { printf 'sha256:%s' "$(printf '%s' "$1" | sha256sum | cut -c1-64)"; } +platform() { case "$(uname -m)" in aarch64|arm64) echo linux/arm64 ;; *) echo linux/amd64 ;; esac; } case "$*" in *"config --services"*) awk '/^services:/{s=1;next} s&&/^ [A-Za-z0-9_.-]+:/{sub(":","",$1);print $1} s&&/^[^ ]/{s=0}' docker-compose.yml ;; *"config --format json"*) printf '{"services":{}}' ;; *"up -d --dry-run"*) : ;; - *" up "*|*" up") for i in $(images); do printf '%s@sha256:%s\n' "$i" "$(digest "$i")"; done > "$S/running" ;; + *" up "*|*" up") images > "$S/running" ;; *" images --format json"*) printf '['; sep='' - while IFS= read -r line; do - [ -n "$line" ] || continue - ref=${line%@*}; d=${line#*@} - printf '%s{"ContainerName":"app-%s-1","Repository":"%s","Tag":"%s","ID":"%s"}' "$sep" "$(digest "$ref" | cut -c1-8)" "${ref%:*}" "${ref##*:}" "$d"; sep=',' + while IFS= read -r ref; do + [ -n "$ref" ] || continue + base=${ref%@*}; repo=${base%:*}; tag=${base##*:} + [ "$repo" = "$base" ] && tag=latest + printf '%s{"ContainerName":"app-%s-1","Repository":"%s","Tag":"%s","ID":"%s"}' "$sep" "$(idof "$ref" | cut -c8-15)" "$repo" "$tag" "$(idof "$ref")"; sep=',' done < "$S/running" 2>/dev/null printf ']' ;; *"ps -a --format json"*|*"ps --all --format json"*) printf '[]' ;; *"image inspect --format"*) for last; do :; done - ref=$(grep -F "$last" "$S/running" 2>/dev/null | head -1); ref=${ref%@*} - printf '["%s@%s"]|linux/amd64' "${ref%:*}" "$last" ;; + while IFS= read -r ref; do + [ "$(idof "$ref")" = "$last" ] || continue + case "$ref" in + *@sha256:*) base=${ref%@*}; rd="${base%:*}@${ref#*@}" ;; + *) rd=$(awk -v r="$ref" '$1==r{print $2; exit}' "$S/digests" 2>/dev/null) ;; + esac + printf '["%s"]|%s' "$rd" "$(platform)" + exit 0 + done < "$S/running" 2>/dev/null + exit 1 ;; "network inspect"*) exit 1 ;; esac exit 0 From 5bf9808577af5223a3718c7abb1b4504fcf5b573 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 30 Oct 2026 01:05:35 -0400 Subject: [PATCH 11/18] test(e2e): resolve seeded digests through the container image id (P7-SHIP-02) Images pulled by digest carry no local tag, so inspecting the reference by name fails; the harness now inspects each project container's image id. (P7-SHIP-02) --- test/e2e/agent/harness.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/test/e2e/agent/harness.go b/test/e2e/agent/harness.go index e725db9f4..da01c71d9 100644 --- a/test/e2e/agent/harness.go +++ b/test/e2e/agent/harness.go @@ -208,23 +208,30 @@ func (h *SimHarness) seedTargetPlugins() ([]string, error) { // local project runs (as the operator's Docker pulled it) to each sim host's // /tmp/agent-e2e/digests, which the docker stub's `image inspect` reports. func (h *SimHarness) seedImageDigests(ctx context.Context) (int, error) { - out, err := run(ctx, "", "docker", "ps", "--format", "{{.Image}}", + // Resolve through each container's image id: an image pulled by digest has + // no local tag, so inspecting the reference by name can fail. + out, err := run(ctx, "", "docker", "ps", "--format", "{{.Image}} {{.ID}}", "--filter", "label=com.docker.compose.project="+h.Box.ComposeProject()) if err != nil { return 0, err } seen := map[string]bool{} var lines []string - for _, ref := range strings.Fields(out) { - if seen[ref] { + for _, row := range strings.Split(strings.TrimSpace(out), "\n") { + f := strings.Fields(row) + if len(f) != 2 || seen[f[0]] { continue } - seen[ref] = true - rd, err := run(ctx, "", "docker", "image", "inspect", "--format", "{{index .RepoDigests 0}}", ref) + seen[f[0]] = true + id, err := run(ctx, "", "docker", "inspect", "--format", "{{.Image}}", f[1]) if err != nil { return 0, err } - lines = append(lines, ref+" "+strings.TrimSpace(rd)) + rd, err := run(ctx, "", "docker", "image", "inspect", "--format", "{{index .RepoDigests 0}}", strings.TrimSpace(id)) + if err != nil { + return 0, err + } + lines = append(lines, f[0]+" "+strings.TrimSpace(rd)) } if len(lines) == 0 { return 0, fmt.Errorf("the project runs no images to seed") From f6c3d3359863c7267e1b1e027ef1826c341081b2 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 30 Oct 2026 01:08:54 -0400 Subject: [PATCH 12/18] test(e2e): sim docker stub reports compose services; log stub calls on failure (P7-SHIP-02) promote maps source images to services through compose images or compose ps; the stub now records service and image pairs from the shipped compose and answers both. A failed run logs each sim host's docker and nself call log. (P7-SHIP-02) --- test/e2e/agent/harness.go | 9 +++++++++ test/e2e/agent/testdata/sim/docker.sh | 18 +++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/test/e2e/agent/harness.go b/test/e2e/agent/harness.go index da01c71d9..8a579e54e 100644 --- a/test/e2e/agent/harness.go +++ b/test/e2e/agent/harness.go @@ -59,6 +59,15 @@ func StartSim(t *testing.T) (*simharness.Fleet, []SimHost) { } fleet.Exec(t, n.Name, "sh", "-c", "mkdir -p "+SimRemotePath+" /tmp/agent-e2e && chown -R "+n.User+" /tmp/nself-agent-e2e /tmp/agent-e2e && chmod 1777 /tmp") } + t.Cleanup(func() { + if !t.Failed() { + return + } + for _, h := range hosts { + t.Logf("sim host %s calls:\n%s", h.Server, fleet.Exec(t, h.Server, "sh", "-c", + "tail -n 60 /tmp/agent-e2e/docker.log /tmp/agent-e2e/nself.log 2>/dev/null; cat /tmp/agent-e2e/running 2>/dev/null; true")) + } + }) return fleet, hosts } diff --git a/test/e2e/agent/testdata/sim/docker.sh b/test/e2e/agent/testdata/sim/docker.sh index 96aec1175..bc2ee459b 100755 --- a/test/e2e/agent/testdata/sim/docker.sh +++ b/test/e2e/agent/testdata/sim/docker.sh @@ -2,7 +2,8 @@ # Recording docker stub for the agent E2E sim hosts (P7-SHIP-02). It mirrors # the stubs of internal/controlplane/sim: the sim hosts are sshd containers # without a Docker engine, so `compose up` records the image references of the -# shipped docker-compose.yml in /tmp/agent-e2e/running and the read commands +# shipped docker-compose.yml (" " lines) in /tmp/agent-e2e/running +# and the read commands # answer from that record. `image inspect` reports the image's real registry # digest from /tmp/agent-e2e/digests (" @sha256:" lines the # harness writes from the operator's pulled images) and the host's platform, @@ -10,27 +11,30 @@ S=/tmp/agent-e2e mkdir -p "$S" echo "$*" >> "$S/docker.log" -images() { awk '/^ image:/{print $2}' docker-compose.yml 2>/dev/null | tr -d "\"'" | sort -u; } +services() { awk '/^services:/{s=1;next} s&&/^[^ ]/{s=0} s&&/^ [A-Za-z0-9_.-]+:/{svc=$1;sub(":","",svc)} s&&svc!=""&&/^ image:/{gsub(/["\047]/,"",$2);print svc" "$2}' docker-compose.yml 2>/dev/null | sort -u; } idof() { printf 'sha256:%s' "$(printf '%s' "$1" | sha256sum | cut -c1-64)"; } platform() { case "$(uname -m)" in aarch64|arm64) echo linux/arm64 ;; *) echo linux/amd64 ;; esac; } case "$*" in *"config --services"*) awk '/^services:/{s=1;next} s&&/^ [A-Za-z0-9_.-]+:/{sub(":","",$1);print $1} s&&/^[^ ]/{s=0}' docker-compose.yml ;; *"config --format json"*) printf '{"services":{}}' ;; *"up -d --dry-run"*) : ;; - *" up "*|*" up") images > "$S/running" ;; + *" up "*|*" up") services > "$S/running" ;; *" images --format json"*) printf '['; sep='' - while IFS= read -r ref; do + while read -r svc ref; do [ -n "$ref" ] || continue base=${ref%@*}; repo=${base%:*}; tag=${base##*:} [ "$repo" = "$base" ] && tag=latest - printf '%s{"ContainerName":"app-%s-1","Repository":"%s","Tag":"%s","ID":"%s"}' "$sep" "$(idof "$ref" | cut -c8-15)" "$repo" "$tag" "$(idof "$ref")"; sep=',' + printf '%s{"Service":"%s","ContainerName":"app-%s-1","Repository":"%s","Tag":"%s","ID":"%s"}' "$sep" "$svc" "$svc" "$repo" "$tag" "$(idof "$ref")"; sep=',' done < "$S/running" 2>/dev/null printf ']' ;; - *"ps -a --format json"*|*"ps --all --format json"*) printf '[]' ;; + *"ps -a --format json"*|*"ps --all --format json"*) + while read -r svc ref; do + [ -n "$ref" ] && printf '{"Name":"app-%s-1","Service":"%s","State":"running","Health":"healthy"}\n' "$svc" "$svc" + done < "$S/running" 2>/dev/null ;; *"image inspect --format"*) for last; do :; done - while IFS= read -r ref; do + while read -r svc ref; do [ "$(idof "$ref")" = "$last" ] || continue case "$ref" in *@sha256:*) base=${ref%@*}; rd="${base%:*}@${ref#*@}" ;; From ea1b28f359b0e2505750b2465df1530105bffe18 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 30 Oct 2026 01:12:02 -0400 Subject: [PATCH 13/18] test(e2e): sim docker stub models compose up, ps and inspect like deploy-sim (P7-SHIP-02) The stub answered up --dry-run with nothing, so deploy saw no change and never ran up, leaving promote no source images (E490). It now lists every service as Recreate, records per-service up -d --no-deps, answers ps -a, ps --all and inspect -f from that record, and reads the compose file named by -f. (P7-SHIP-02) --- test/e2e/agent/testdata/sim/docker.sh | 55 ++++++++++++++++++++------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/test/e2e/agent/testdata/sim/docker.sh b/test/e2e/agent/testdata/sim/docker.sh index bc2ee459b..b21641710 100755 --- a/test/e2e/agent/testdata/sim/docker.sh +++ b/test/e2e/agent/testdata/sim/docker.sh @@ -1,24 +1,45 @@ #!/bin/sh -# Recording docker stub for the agent E2E sim hosts (P7-SHIP-02). It mirrors -# the stubs of internal/controlplane/sim: the sim hosts are sshd containers -# without a Docker engine, so `compose up` records the image references of the -# shipped docker-compose.yml (" " lines) in /tmp/agent-e2e/running -# and the read commands -# answer from that record. `image inspect` reports the image's real registry -# digest from /tmp/agent-e2e/digests (" @sha256:" lines the -# harness writes from the operator's pulled images) and the host's platform, -# so promote's digest compare sees the same identities a real host would. +# Recording docker stub for the agent E2E sim hosts (P7-SHIP-02), modelled on +# the stubs of internal/controlplane/sim. The sim hosts are sshd containers +# without a Docker engine, so: +# - `compose ... up -d --dry-run` lists every service of the compose file named +# by -f as "Recreate", and `up -d [--no-deps] [svc...]` records " +# " lines in /tmp/agent-e2e/running and bumps a start counter; +# - `compose images`, `compose ps`, `inspect -f` answer from that record; +# - `image inspect` reports the image's real registry digest from +# /tmp/agent-e2e/digests (" @sha256:" lines the harness +# writes from the operator's pulled images) and the host's platform, so +# promote's digest compare sees what a real host would report. S=/tmp/agent-e2e mkdir -p "$S" echo "$*" >> "$S/docker.log" -services() { awk '/^services:/{s=1;next} s&&/^[^ ]/{s=0} s&&/^ [A-Za-z0-9_.-]+:/{svc=$1;sub(":","",svc)} s&&svc!=""&&/^ image:/{gsub(/["\047]/,"",$2);print svc" "$2}' docker-compose.yml 2>/dev/null | sort -u; } +F=docker-compose.yml +prev='' +for a; do [ "$prev" = "-f" ] && F=$a; prev=$a; done +services() { awk '/^services:/{s=1;next} s&&/^[^ ]/{s=0} s&&/^ [A-Za-z0-9_.-]+:/{svc=$1;sub(":","",svc)} s&&svc!=""&&/^ image:/{gsub(/["\047]/,"",$2);print svc" "$2}' "$F" 2>/dev/null | sort -u; } idof() { printf 'sha256:%s' "$(printf '%s' "$1" | sha256sum | cut -c1-64)"; } platform() { case "$(uname -m)" in aarch64|arm64) echo linux/arm64 ;; *) echo linux/amd64 ;; esac; } +up() { # svc...: record those services (all when none) as running + services > "$S/compose.now" + [ $# -gt 0 ] || set -- $(awk '{print $1}' "$S/compose.now") + for svc; do + line=$(awk -v s="$svc" '$1==s' "$S/compose.now") + [ -n "$line" ] || continue + { grep -v "^$svc " "$S/running" 2>/dev/null; echo "$line"; } | sort > "$S/running.new" + mv "$S/running.new" "$S/running" + n=$(cat "$S/up-$svc" 2>/dev/null || echo 0); echo $((n+1)) > "$S/up-$svc" + done +} case "$*" in - *"config --services"*) awk '/^services:/{s=1;next} s&&/^ [A-Za-z0-9_.-]+:/{sub(":","",$1);print $1} s&&/^[^ ]/{s=0}' docker-compose.yml ;; + *"config --services"*) services | awk '{print $1}' ;; *"config --format json"*) printf '{"services":{}}' ;; - *"up -d --dry-run"*) : ;; - *" up "*|*" up") services > "$S/running" ;; + *"up -d --dry-run"*) services | while read -r svc ref; do printf ' Container app-%s-1 Recreate\n' "$svc"; done ;; + *" up "*|*" up") + known=$(services | awk '{print $1}') + list='' + for a; do for k in $known; do [ "$a" = "$k" ] && list="$list $a"; done; done + # shellcheck disable=SC2086 + up $list ;; *" images --format json"*) printf '['; sep='' while read -r svc ref; do @@ -28,10 +49,16 @@ case "$*" in printf '%s{"Service":"%s","ContainerName":"app-%s-1","Repository":"%s","Tag":"%s","ID":"%s"}' "$sep" "$svc" "$svc" "$repo" "$tag" "$(idof "$ref")"; sep=',' done < "$S/running" 2>/dev/null printf ']' ;; - *"ps -a --format json"*|*"ps --all --format json"*) + *"ps -a --format json"*) + for last; do :; done + printf '[{"ID":"id-%s","Name":"app-%s-1","Service":"%s","State":"running","Health":"healthy"}]' "$last" "$last" "$last" ;; + *"ps --all --format json"*) while read -r svc ref; do [ -n "$ref" ] && printf '{"Name":"app-%s-1","Service":"%s","State":"running","Health":"healthy"}\n' "$svc" "$svc" done < "$S/running" 2>/dev/null ;; + "inspect -f"*) + shift 3 + for i; do n=$(cat "$S/up-${i#id-}" 2>/dev/null || echo 0); printf '2026-10-30T00:00:%02dZ\n' "$n"; done ;; *"image inspect --format"*) for last; do :; done while read -r svc ref; do From 48429fd855d8a3030831c49e5b299eb3cfdda1a3 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 30 Oct 2026 01:14:47 -0400 Subject: [PATCH 14/18] test(e2e): sim docker stub answers compose config with a service model (P7-SHIP-02) Remote apply plans the recreate set from compose config --format json; an empty model meant no up ran on the sim host. The stub now builds the model (image, restart) from the compose file, as deploy-sim's fixture does. (P7-SHIP-02) --- test/e2e/agent/testdata/sim/docker.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/e2e/agent/testdata/sim/docker.sh b/test/e2e/agent/testdata/sim/docker.sh index b21641710..f5f9f1081 100755 --- a/test/e2e/agent/testdata/sim/docker.sh +++ b/test/e2e/agent/testdata/sim/docker.sh @@ -2,6 +2,8 @@ # Recording docker stub for the agent E2E sim hosts (P7-SHIP-02), modelled on # the stubs of internal/controlplane/sim. The sim hosts are sshd containers # without a Docker engine, so: +# - `compose config --format json` is a model of every service (image, +# restart) of the compose file named by -f, as deploy-sim's fixture is; # - `compose ... up -d --dry-run` lists every service of the compose file named # by -f as "Recreate", and `up -d [--no-deps] [svc...]` records " # " lines in /tmp/agent-e2e/running and bumps a start counter; @@ -32,7 +34,10 @@ up() { # svc...: record those services (all when none) as running } case "$*" in *"config --services"*) services | awk '{print $1}' ;; - *"config --format json"*) printf '{"services":{}}' ;; + *"config --format json"*) + printf '{"name":"app","services":{'; sep='' + services | while read -r svc ref; do printf '%s"%s":{"image":"%s","restart":"always"}' "$sep" "$svc" "$ref"; sep=','; done + printf '}}' ;; *"up -d --dry-run"*) services | while read -r svc ref; do printf ' Container app-%s-1 Recreate\n' "$svc"; done ;; *" up "*|*" up") known=$(services | awk '{print $1}') From 6ff6a775a6b48de74f253225c17148287c3e6e94 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 30 Oct 2026 01:18:11 -0400 Subject: [PATCH 15/18] test(e2e): sim docker stub reads services at any indentation (P7-SHIP-02) nself build writes the compose file with four-space service keys and eight-space image keys; the stub's fixed two/four-space pattern found no services, so deploy applied nothing. The parser now follows the services block's own indentation. (P7-SHIP-02) --- test/e2e/agent/testdata/sim/docker.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/e2e/agent/testdata/sim/docker.sh b/test/e2e/agent/testdata/sim/docker.sh index f5f9f1081..44c2156cc 100755 --- a/test/e2e/agent/testdata/sim/docker.sh +++ b/test/e2e/agent/testdata/sim/docker.sh @@ -18,7 +18,17 @@ echo "$*" >> "$S/docker.log" F=docker-compose.yml prev='' for a; do [ "$prev" = "-f" ] && F=$a; prev=$a; done -services() { awk '/^services:/{s=1;next} s&&/^[^ ]/{s=0} s&&/^ [A-Za-z0-9_.-]+:/{svc=$1;sub(":","",svc)} s&&svc!=""&&/^ image:/{gsub(/["\047]/,"",$2);print svc" "$2}' "$F" 2>/dev/null | sort -u; } +services() { # " " for every service of $F, at any indentation + awk ' + function ind(l) { match(l, /^ */); return RLENGTH } + /^services:/ { s=1; si=-1; next } + s && /^[^ #]/ { s=0 } + !s || /^ *(#|$)/ { next } + si < 0 && /^ +[A-Za-z0-9_.-]+: *$/ { si=ind($0) } + ind($0) == si && /^ +[A-Za-z0-9_.-]+: *$/ { svc=$1; sub(":", "", svc); next } + svc != "" && ind($0) > si && $1 == "image:" { img=$2; gsub(/["\047]/, "", img); print svc " " img } + ' "$F" 2>/dev/null | sort -u +} idof() { printf 'sha256:%s' "$(printf '%s' "$1" | sha256sum | cut -c1-64)"; } platform() { case "$(uname -m)" in aarch64|arm64) echo linux/arm64 ;; *) echo linux/amd64 ;; esac; } up() { # svc...: record those services (all when none) as running From a6f1c8393eaafddb64abe2844e1f1d9fdab2521c Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 30 Oct 2026 01:31:07 -0400 Subject: [PATCH 16/18] ci(agent-e2e): install the pinned gitleaks for the replay's CI run (P7-SHIP-02) The replay runs nself ci, whose default pipeline has a gitleaks job; hosted ubuntu has no gitleaks, so the run ended E618. The workflow installs the same checksummed gitleaks 8.21.2 as nself-ci.yml into /usr/local/bin, which the sandboxed server's PATH includes. (P7-SHIP-02) --- .github/workflows/agent-e2e.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/agent-e2e.yml b/.github/workflows/agent-e2e.yml index 927433e1d..6dfc876cb 100644 --- a/.github/workflows/agent-e2e.yml +++ b/.github/workflows/agent-e2e.yml @@ -26,6 +26,7 @@ jobs: timeout-minutes: 60 env: NSELF_V15: "1" + GITLEAKS_SHA256: "5bc41815076e6ed6ef8fbecc9d9b75bcae31f39029ceb55da08086315316e3ba" # gitleaks 8.21.2 linux_x64 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -40,6 +41,16 @@ jobs: ref: main path: plugins-src persist-credentials: false + # The replay's CI run needs gitleaks; the sandboxed server sees only the + # system PATH, so it goes to /usr/local/bin (same pin as nself-ci.yml). + - name: Install gitleaks + 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 - name: Self-test (verifier and sandbox refusals) run: bash scripts/e2e/agent/run.sh --self-test - name: Replay over MCP From f165809cb396efcf5deaaf808dee7d45a49f8c2c Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 30 Oct 2026 01:40:40 -0400 Subject: [PATCH 17/18] ci(agent-e2e): install pinned trivy for the replay's CI run (P7-SHIP-02) The default pipeline also has trivy jobs; without trivy they cannot be placed and the run fails even when every placed job passes (reproduced on mm6). The workflow installs checksummed trivy 0.76.0 into /usr/local/bin next to gitleaks. (P7-SHIP-02) --- .github/workflows/agent-e2e.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/agent-e2e.yml b/.github/workflows/agent-e2e.yml index 6dfc876cb..8aaf4da47 100644 --- a/.github/workflows/agent-e2e.yml +++ b/.github/workflows/agent-e2e.yml @@ -27,6 +27,7 @@ jobs: 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 @@ -41,9 +42,10 @@ jobs: ref: main path: plugins-src persist-credentials: false - # The replay's CI run needs gitleaks; the sandboxed server sees only the - # system PATH, so it goes to /usr/local/bin (same pin as nself-ci.yml). - - name: Install gitleaks + # 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" \ @@ -51,6 +53,11 @@ jobs: 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 - name: Self-test (verifier and sandbox refusals) run: bash scripts/e2e/agent/run.sh --self-test - name: Replay over MCP From fdc55feded05ce980e50676af356bf548099f336 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 30 Oct 2026 02:01:55 -0400 Subject: [PATCH 18/18] ci(agent-e2e): pre-pull the pinned sim host image with backoff (P7-SHIP-02) A PR replay attempt failed at fleet start on a registry throttle (toomanyrequests) before any agent step. The workflow now pulls the digest-pinned simharness openssh image first, with a bounded backoff; the harness itself still never retries a step. (P7-SHIP-02) --- .github/workflows/agent-e2e.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/agent-e2e.yml b/.github/workflows/agent-e2e.yml index 8aaf4da47..34cb868ef 100644 --- a/.github/workflows/agent-e2e.yml +++ b/.github/workflows/agent-e2e.yml @@ -58,6 +58,18 @@ jobs: 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