pantheon-opencode 1.5.1 → 1.6.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/CHANGELOG.md +22 -0
- package/README.md +86 -52
- package/README.pt-BR.md +63 -43
- package/commands/pantheon-optimize.md +2 -2
- package/package.json +13 -1
- package/plugin.json +1 -1
- package/pyproject.toml +8 -15
- package/scripts/build-agents-md.mjs +2 -2
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/code_mode_server.py +116 -24
- package/scripts/doctor.mjs +85 -28
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +2 -2
- package/scripts/install/health-check.mjs +6 -19
- package/scripts/install/interactive.mjs +6 -6
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/install/state.mjs +27 -2
- package/scripts/mcp_persistence_server.py +390 -76
- package/scripts/memory_mcp_server.py +370 -196
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +28 -11
- package/scripts/prune.mjs +2 -2
- package/scripts/sync-tui.mjs +11 -5
- package/scripts/test-opencode-v2-sandbox.sh +1759 -0
- package/scripts/validate-routing.mjs +40 -0
- package/skills-lock.json +9 -10
- package/src/agents/mnemosyne.md +15 -16
- package/src/agents/zeus.md +1 -2
- package/src/mcp/code_mode_server.py +116 -24
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/mcp_persistence_server.py +390 -76
- package/src/mcp/memory_mcp_server.py +371 -248
- package/src/mcp/pantheon_vision_server.py +136 -24
- package/src/mcp/requirements-mcp.txt +5 -3
- package/src/mcp/requirements-vision.txt +2 -0
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/todo-enforcer.ts +1 -1
- package/src/pantheon/vision.ts +1 -1
- package/src/plugin-v2.ts +134 -104
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +20 -20
- package/src/plugins/tui/package.json +12 -12
- package/src/routing.yml +0 -1
- package/src/skills/README.md +31 -36
- package/src/skills/auto-continue/SKILL.md +25 -75
- package/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -21
- package/scripts/zenodo-release-state.mjs +0 -53
- package/src/skills/orchestration-workflow/SKILL.md +0 -311
|
@@ -0,0 +1,1759 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test-opencode-v2-sandbox.sh — Pantheon global-install sandbox validator for
|
|
3
|
+
# OpenCode V2.
|
|
4
|
+
#
|
|
5
|
+
# Validates the installed pantheon-opencode package AS A REAL USER inside an
|
|
6
|
+
# isolated sandbox (own HOME, npm prefix and venv), never the dev environment.
|
|
7
|
+
#
|
|
8
|
+
# WHAT "V2" MEANS HERE
|
|
9
|
+
# --------------------
|
|
10
|
+
# It does not mean a different binary. On hosts where both `opencode` and
|
|
11
|
+
# `opencode2` exist, `opencode2` is typically a two-line shim that execs the very
|
|
12
|
+
# same `opencode` binary, so a "V1 vs V2 side-by-side" comparison proved nothing
|
|
13
|
+
# about the binary. "V2" here means this script observes a specific hook canary
|
|
14
|
+
# against an OpenCode v2.0.18 host. It does not establish a stable
|
|
15
|
+
# `@opencode/plugin@2.0.18` SDK contract or compatibility across the full 2.x
|
|
16
|
+
# line. This project is V2-exclusive, so there is a single host leg to validate.
|
|
17
|
+
#
|
|
18
|
+
# Modes (combinable):
|
|
19
|
+
# --prepare Build tarball, install pantheon-opencode + the OpenCode V2
|
|
20
|
+
# binary in the sandbox prefix, headless init, and generate
|
|
21
|
+
# the project config and sandbox run-test.sh.
|
|
22
|
+
# --run v2 Base validation: opencode mcp list + doctor (config is
|
|
23
|
+
# regenerated first).
|
|
24
|
+
# --prompts Prompt battery via `opencode run --format json`.
|
|
25
|
+
# --cost Offline pantheon_cost probe.
|
|
26
|
+
# --rehydrate Offline context_rehydrate + context_session_summary probe.
|
|
27
|
+
# --hooks V2 hook canary: proves hook callbacks FIRE through the
|
|
28
|
+
# sandbox's V2 binary (not merely that a plugin loaded).
|
|
29
|
+
# This does not test transform callback effects or Pantheon
|
|
30
|
+
# execute.before security enforcement (deferred to S6).
|
|
31
|
+
# --reset Wipe the sandbox root.
|
|
32
|
+
# --help Usage.
|
|
33
|
+
#
|
|
34
|
+
# Env overrides:
|
|
35
|
+
# PANTHEON_SANDBOX_ROOT Sandbox root (default: ~/pantheon-sandbox)
|
|
36
|
+
# OPENCODE_V2_SPEC npm spec providing the V2 binary
|
|
37
|
+
# (default: @opencode-ai/cli@beta). NOTE: this is an
|
|
38
|
+
# install spec only — it is not a dependency of this
|
|
39
|
+
# package, and no code imports it.
|
|
40
|
+
# PANTHEON_REPO Repository the sandbox is built from (default: the
|
|
41
|
+
# checkout this script lives in). Only read by the
|
|
42
|
+
# GENERATED run-test.sh, which cannot derive it from
|
|
43
|
+
# its own location and reads the .repo-dir file this
|
|
44
|
+
# script generates beside it. --reset refuses a
|
|
45
|
+
# sandbox root that overlaps either protected repo.
|
|
46
|
+
# PANTHEON_SANDBOX_MODEL Model used by init/prompts
|
|
47
|
+
# (default: opencode-go/mimo-v2.5)
|
|
48
|
+
# PANTHEON_PROMPT_TIMEOUT Per-prompt timeout in seconds (default: 300)
|
|
49
|
+
# Exit codes: 0 = every required check returned explicit PASS,
|
|
50
|
+
# 1 = any failure or untested check (see reports in the sandbox root),
|
|
51
|
+
# 2 = usage error, 3 = sandbox not prepared.
|
|
52
|
+
set -euo pipefail
|
|
53
|
+
|
|
54
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
55
|
+
# Derived from this script's own location so the sandbox is always built from
|
|
56
|
+
# the checkout that CONTAINS this harness. It must never be inferred from a
|
|
57
|
+
# sibling directory: a sibling may be a different checkout (or a different
|
|
58
|
+
# branch) with unrelated uncommitted work, and --prepare runs `npm pack` inside
|
|
59
|
+
# it, writing a tarball into that tree.
|
|
60
|
+
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
61
|
+
|
|
62
|
+
SANDBOX_ROOT="${PANTHEON_SANDBOX_ROOT:-$HOME/pantheon-sandbox}"
|
|
63
|
+
SANDBOX_HOME="$SANDBOX_ROOT/home"
|
|
64
|
+
NPM_PREFIX="$SANDBOX_HOME/.npm-global"
|
|
65
|
+
|
|
66
|
+
OPENCODE_V2_SPEC="${OPENCODE_V2_SPEC:-@opencode-ai/cli@beta}"
|
|
67
|
+
PANTHEON_SANDBOX_MODEL="${PANTHEON_SANDBOX_MODEL:-opencode-go/mimo-v2.5}"
|
|
68
|
+
PANTHEON_PROMPT_TIMEOUT="${PANTHEON_PROMPT_TIMEOUT:-300}"
|
|
69
|
+
|
|
70
|
+
V2_BIN="${OPENCODE_V2_BIN:-opencode2}"
|
|
71
|
+
|
|
72
|
+
# OpenCode V2's CLI talks to its background service for `mcp list`. Keep the
|
|
73
|
+
# probe hermetic: an inherited config, database, or PORT can otherwise make
|
|
74
|
+
# the command query a different service and report a false result.
|
|
75
|
+
V2_CONFIG="${PANTHEON_V2_CONFIG:-$SANDBOX_ROOT/project-v2/opencode.json}"
|
|
76
|
+
V2_DB="${PANTHEON_V2_DB:-$SANDBOX_ROOT/opencode-v2.db}"
|
|
77
|
+
V2_PORT="${PANTHEON_V2_PORT:-49376}"
|
|
78
|
+
V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
|
|
79
|
+
V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
|
|
80
|
+
V2_MCP_LIST_TIMEOUT="${PANTHEON_V2_MCP_LIST_TIMEOUT:-15}"
|
|
81
|
+
V2_SERVER_PID=""
|
|
82
|
+
V2_SERVER_LOG="$SANDBOX_ROOT/v2-server.log"
|
|
83
|
+
V2_SERVICE_STATE="$SANDBOX_HOME/.local/state/opencode/service.json"
|
|
84
|
+
|
|
85
|
+
# The single target version. Kept as a name so reports and prompts keep their
|
|
86
|
+
# per-version labels without reintroducing a second leg.
|
|
87
|
+
TARGET_VERSION="v2"
|
|
88
|
+
|
|
89
|
+
REPORT_FILE="$SANDBOX_ROOT/prompts-report.md"
|
|
90
|
+
COST_REPORT_FILE="$SANDBOX_ROOT/pantheon-cost-report.md"
|
|
91
|
+
REHYDRATE_REPORT_FILE="$SANDBOX_ROOT/context-rehydrate-report.md"
|
|
92
|
+
EXTRACT_PY="$SANDBOX_ROOT/.prompt-extract-json.py"
|
|
93
|
+
|
|
94
|
+
MODE_PREPARE=0
|
|
95
|
+
MODE_RESET=0
|
|
96
|
+
MODE_PROMPTS=0
|
|
97
|
+
MODE_COST=0
|
|
98
|
+
MODE_REHYDRATE=0
|
|
99
|
+
MODE_HOOKS=0
|
|
100
|
+
RUN_VERSION=""
|
|
101
|
+
|
|
102
|
+
usage() {
|
|
103
|
+
sed -n '2,45p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
104
|
+
exit 2
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
log() { printf '%s\n' "$*"; }
|
|
108
|
+
warn() { printf 'WARN: %s\n' "$*" >&2; }
|
|
109
|
+
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
|
110
|
+
|
|
111
|
+
project_dir() { printf '%s/project-%s' "$SANDBOX_ROOT" "$TARGET_VERSION"; }
|
|
112
|
+
|
|
113
|
+
# Resolve a binary STRICTLY inside the sandbox npm prefix — never fall back to
|
|
114
|
+
# the dev PATH, otherwise a non-prepared sandbox would silently test the host
|
|
115
|
+
# installation.
|
|
116
|
+
sandbox_bin_for() { # name → prints path; rc 1 if not inside sandbox prefix
|
|
117
|
+
local p
|
|
118
|
+
p="$(command -v "$1" 2>/dev/null || true)"
|
|
119
|
+
case "$p" in
|
|
120
|
+
"$NPM_PREFIX"/*) printf '%s' "$p" ;;
|
|
121
|
+
*) return 1 ;;
|
|
122
|
+
esac
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
sandbox_bin() { # echoes the V2 binary path; rc 1 if missing
|
|
126
|
+
sandbox_bin_for "$V2_BIN"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# ── Sandbox environment isolation ─────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
sandbox_env() {
|
|
132
|
+
export HOME="$SANDBOX_HOME"
|
|
133
|
+
export PATH="$NPM_PREFIX/bin:$HOME/.config/opencode/.venv/bin:$PATH"
|
|
134
|
+
export npm_config_prefix="$NPM_PREFIX"
|
|
135
|
+
export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
|
|
136
|
+
# Do not inherit an OpenChamber/developer-machine config selector. The V2
|
|
137
|
+
# project config is created by prepare_project and is the only config this
|
|
138
|
+
# gate is allowed to inspect.
|
|
139
|
+
export OPENCODE_CONFIG_DIR="$(dirname "$V2_CONFIG")"
|
|
140
|
+
export OPENCODE_DB="$V2_DB"
|
|
141
|
+
export PANTHEON_V2_PORT="$V2_PORT"
|
|
142
|
+
export PORT="$V2_PORT"
|
|
143
|
+
unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE
|
|
144
|
+
mkdir -p "$SANDBOX_ROOT" "$(dirname "$V2_DB")" "$(dirname "$V2_SERVICE_STATE")"
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
stop_v2_service() {
|
|
148
|
+
if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
149
|
+
kill "$V2_SERVER_PID" 2>/dev/null || true
|
|
150
|
+
wait "$V2_SERVER_PID" 2>/dev/null || true
|
|
151
|
+
fi
|
|
152
|
+
rm -f "$V2_SERVICE_STATE"
|
|
153
|
+
V2_SERVER_PID=""
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
print_v2_diagnostics() {
|
|
157
|
+
printf '%s\n' '--- V2 service registration ---' >&2
|
|
158
|
+
if [ -f "$V2_SERVICE_STATE" ]; then
|
|
159
|
+
cat "$V2_SERVICE_STATE" >&2 || true
|
|
160
|
+
printf '\n' >&2
|
|
161
|
+
else
|
|
162
|
+
printf 'missing service registration: %s\n' "$V2_SERVICE_STATE" >&2
|
|
163
|
+
fi
|
|
164
|
+
printf '%s\n' '--- V2 service log ---' >&2
|
|
165
|
+
if [ -f "$V2_SERVER_LOG" ]; then
|
|
166
|
+
tail -200 "$V2_SERVER_LOG" >&2 || true
|
|
167
|
+
else
|
|
168
|
+
printf 'missing service log: %s\n' "$V2_SERVER_LOG" >&2
|
|
169
|
+
fi
|
|
170
|
+
local runtime_log="$HOME/.local/share/opencode/log/opencode.log"
|
|
171
|
+
printf '%s\n' '--- OpenCode runtime log ---' >&2
|
|
172
|
+
if [ -f "$runtime_log" ]; then
|
|
173
|
+
tail -200 "$runtime_log" >&2 || true
|
|
174
|
+
else
|
|
175
|
+
printf 'missing runtime log: %s\n' "$runtime_log" >&2
|
|
176
|
+
fi
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
wait_for_v2_service_registration() {
|
|
180
|
+
local registration_url="http://$V2_LOOPBACK_HOST:$V2_PORT"
|
|
181
|
+
local registration_pid="$V2_SERVER_PID"
|
|
182
|
+
local attempt
|
|
183
|
+
|
|
184
|
+
for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
|
|
185
|
+
if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
186
|
+
printf 'V2 service exited before registration was written.\n' >&2
|
|
187
|
+
print_v2_diagnostics
|
|
188
|
+
return 1
|
|
189
|
+
fi
|
|
190
|
+
if [ -f "$V2_SERVICE_STATE" ] \
|
|
191
|
+
&& SERVICE_STATE="$V2_SERVICE_STATE" \
|
|
192
|
+
REGISTRATION_URL="$registration_url" \
|
|
193
|
+
REGISTRATION_PID="$registration_pid" \
|
|
194
|
+
python3 - <<'PY'
|
|
195
|
+
import json
|
|
196
|
+
import os
|
|
197
|
+
from pathlib import Path
|
|
198
|
+
|
|
199
|
+
path = Path(os.environ["SERVICE_STATE"])
|
|
200
|
+
try:
|
|
201
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
202
|
+
except (OSError, json.JSONDecodeError):
|
|
203
|
+
raise SystemExit(1)
|
|
204
|
+
|
|
205
|
+
expected_url = os.environ["REGISTRATION_URL"]
|
|
206
|
+
expected_pid = int(os.environ["REGISTRATION_PID"])
|
|
207
|
+
if not isinstance(state, dict):
|
|
208
|
+
raise SystemExit(1)
|
|
209
|
+
if state.get("url") != expected_url or state.get("pid") != expected_pid:
|
|
210
|
+
raise SystemExit(1)
|
|
211
|
+
if not all(isinstance(state.get(key), str) and state[key] for key in ("id", "version", "password")):
|
|
212
|
+
raise SystemExit(1)
|
|
213
|
+
PY
|
|
214
|
+
then
|
|
215
|
+
return 0
|
|
216
|
+
fi
|
|
217
|
+
sleep 1
|
|
218
|
+
done
|
|
219
|
+
printf 'Timed out after %ss waiting for V2 service registration matching url=%s pid=%s.\n' \
|
|
220
|
+
"$V2_HANDSHAKE_TIMEOUT" "$registration_url" "$registration_pid" >&2
|
|
221
|
+
print_v2_diagnostics
|
|
222
|
+
return 1
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
wait_for_v2_mcp_registrations() {
|
|
226
|
+
local runtime_dir="$1" python_bin="$2" attempt
|
|
227
|
+
for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
|
|
228
|
+
if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
229
|
+
echo 'ERROR: V2 service exited before all five MCP registrations completed' >&2
|
|
230
|
+
print_v2_diagnostics
|
|
231
|
+
return 1
|
|
232
|
+
fi
|
|
233
|
+
if SERVICE_LOG="$V2_SERVER_LOG" RUNTIME_DIR="$runtime_dir" MCP_PYTHON="$python_bin" \
|
|
234
|
+
python3 - <<'PY'
|
|
235
|
+
import os
|
|
236
|
+
import re
|
|
237
|
+
from pathlib import Path
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
log = Path(os.environ["SERVICE_LOG"]).read_text(encoding="utf-8")
|
|
241
|
+
except OSError:
|
|
242
|
+
raise SystemExit(1)
|
|
243
|
+
expected = {
|
|
244
|
+
"code_mode_server.py",
|
|
245
|
+
"memory_mcp_server.py",
|
|
246
|
+
"mcp_persistence_server.py",
|
|
247
|
+
"mcp_resources_server.py",
|
|
248
|
+
"pantheon_vision_server.py",
|
|
249
|
+
}
|
|
250
|
+
pattern = re.compile(
|
|
251
|
+
r"spawning process \{\s*"
|
|
252
|
+
r"command:\s*\"(?P<command>[^\"]+)\".*?"
|
|
253
|
+
r"args:\s*\[\s*\"(?P<script>[^\"]+)\"\s*\].*?"
|
|
254
|
+
r"cwd:\s*\"(?P<cwd>[^\"]+)\"",
|
|
255
|
+
re.DOTALL,
|
|
256
|
+
)
|
|
257
|
+
records = list(pattern.finditer(log))
|
|
258
|
+
runtime_dir = os.environ["RUNTIME_DIR"]
|
|
259
|
+
python_bin = os.environ["MCP_PYTHON"]
|
|
260
|
+
registered = set()
|
|
261
|
+
for record in records:
|
|
262
|
+
command = record.group("command")
|
|
263
|
+
script = Path(record.group("script")).name
|
|
264
|
+
cwd = record.group("cwd")
|
|
265
|
+
if command != python_bin or cwd != runtime_dir or script not in expected:
|
|
266
|
+
raise SystemExit(1)
|
|
267
|
+
registered.add(script)
|
|
268
|
+
if len(records) != len(expected) or registered != expected:
|
|
269
|
+
raise SystemExit(1)
|
|
270
|
+
PY
|
|
271
|
+
then
|
|
272
|
+
return 0
|
|
273
|
+
fi
|
|
274
|
+
sleep 1
|
|
275
|
+
done
|
|
276
|
+
printf 'ERROR: timed out after %ss waiting for exactly five MCP registrations in the active sandbox\n' \
|
|
277
|
+
"$V2_HANDSHAKE_TIMEOUT" >&2
|
|
278
|
+
print_v2_diagnostics
|
|
279
|
+
return 1
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
wait_for_v2_handshakes() {
|
|
283
|
+
local runtime_log="$HOME/.local/share/opencode/log/opencode.log"
|
|
284
|
+
local names=(
|
|
285
|
+
pantheon-code-mode
|
|
286
|
+
pantheon-memory
|
|
287
|
+
pantheon-persistence
|
|
288
|
+
pantheon-resources
|
|
289
|
+
pantheon-vision
|
|
290
|
+
)
|
|
291
|
+
local attempt name ready connected_names connected_count
|
|
292
|
+
for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
|
|
293
|
+
if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
294
|
+
printf 'V2 service exited before all MCP handshakes completed.\n' >&2
|
|
295
|
+
print_v2_diagnostics
|
|
296
|
+
return 1
|
|
297
|
+
fi
|
|
298
|
+
ready=1
|
|
299
|
+
for name in "${names[@]}"; do
|
|
300
|
+
if [ ! -f "$runtime_log" ] || ! grep -Fq "message=\"mcp connected\" server=$name" "$runtime_log"; then
|
|
301
|
+
ready=0
|
|
302
|
+
break
|
|
303
|
+
fi
|
|
304
|
+
done
|
|
305
|
+
if [ "$ready" -eq 1 ]; then
|
|
306
|
+
# A stale or inherited MCP must not turn five expected handshakes into a
|
|
307
|
+
# false PASS. The service log is dedicated to this run and must contain
|
|
308
|
+
# exactly the five managed server names.
|
|
309
|
+
connected_names="$({ grep -oE 'message="mcp connected" server=[^ ]+' "$runtime_log" || true; } \
|
|
310
|
+
| sed 's/.*server=//' | sort -u)"
|
|
311
|
+
connected_count="$(printf '%s\n' "$connected_names" | sed '/^$/d' | wc -l)"
|
|
312
|
+
if [ "$connected_count" -eq 5 ] \
|
|
313
|
+
&& ! printf '%s\n' "$connected_names" | grep -Evx \
|
|
314
|
+
'pantheon-code-mode|pantheon-memory|pantheon-persistence|pantheon-resources|pantheon-vision' \
|
|
315
|
+
>/dev/null; then
|
|
316
|
+
return 0
|
|
317
|
+
fi
|
|
318
|
+
ready=0
|
|
319
|
+
fi
|
|
320
|
+
sleep 1
|
|
321
|
+
done
|
|
322
|
+
printf 'Timed out after %ss waiting for exactly five MCP handshakes.\n' \
|
|
323
|
+
"$V2_HANDSHAKE_TIMEOUT" >&2
|
|
324
|
+
print_v2_diagnostics
|
|
325
|
+
return 1
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
start_v2_service() { # binary project
|
|
329
|
+
local bin="$1" project="$2" ready=0 attempt
|
|
330
|
+
if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
331
|
+
return 0
|
|
332
|
+
fi
|
|
333
|
+
|
|
334
|
+
# Never attach to an unrelated service on the dedicated port. A successful
|
|
335
|
+
# health probe before our process starts is an environmental collision, not
|
|
336
|
+
# a reason to silently reuse that service.
|
|
337
|
+
if curl --fail --silent --show-error --max-time 1 \
|
|
338
|
+
"http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
|
|
339
|
+
die "dedicated V2 port ${V2_PORT} is already in use; refusing to reuse an unrelated service"
|
|
340
|
+
fi
|
|
341
|
+
|
|
342
|
+
rm -f "$V2_SERVER_LOG" "$V2_SERVICE_STATE" "$HOME/.local/share/opencode/log/opencode.log"
|
|
343
|
+
(cd "$project" && "$bin" serve --hostname "$V2_LOOPBACK_HOST" --port "$V2_PORT" --service) \
|
|
344
|
+
>"$V2_SERVER_LOG" 2>&1 &
|
|
345
|
+
V2_SERVER_PID=$!
|
|
346
|
+
|
|
347
|
+
for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
|
|
348
|
+
if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
349
|
+
printf 'V2 service exited before becoming healthy.\n' >&2
|
|
350
|
+
print_v2_diagnostics
|
|
351
|
+
return 1
|
|
352
|
+
fi
|
|
353
|
+
if curl --fail --silent --show-error --max-time 2 \
|
|
354
|
+
"http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
|
|
355
|
+
ready=1
|
|
356
|
+
break
|
|
357
|
+
fi
|
|
358
|
+
sleep 1
|
|
359
|
+
done
|
|
360
|
+
if [ "$ready" -ne 1 ]; then
|
|
361
|
+
printf 'Timed out after %ss waiting for V2 service health on port %s.\n' \
|
|
362
|
+
"$V2_HANDSHAKE_TIMEOUT" "$V2_PORT" >&2
|
|
363
|
+
print_v2_diagnostics
|
|
364
|
+
return 1
|
|
365
|
+
fi
|
|
366
|
+
if ! wait_for_v2_service_registration; then
|
|
367
|
+
return 1
|
|
368
|
+
fi
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
trap stop_v2_service EXIT
|
|
372
|
+
|
|
373
|
+
# ── Gate (b): sandbox run-test.sh with pantheon://agents content validation ──
|
|
374
|
+
|
|
375
|
+
write_run_test_sh() {
|
|
376
|
+
# Quoted heredoc: the body is emitted verbatim, so nothing inside is expanded
|
|
377
|
+
# at generation time. REPO_DIR is the one value the body needs, and it is
|
|
378
|
+
# handed over as its own generated file instead of a placeholder baked into
|
|
379
|
+
# the shell source: interpolating it meant escaping for TWO languages (sed
|
|
380
|
+
# replacement text AND the double-quoted shell context it lands in), and only
|
|
381
|
+
# the first set was escaped — a path containing `$`, a backtick, a backslash
|
|
382
|
+
# or a quote was silently rewritten or executed by the generated script.
|
|
383
|
+
printf '%s\n' "$REPO_DIR" > "$SANDBOX_ROOT/.repo-dir"
|
|
384
|
+
cat > "$SANDBOX_ROOT/run-test.sh" <<'RUNTEST'
|
|
385
|
+
#!/usr/bin/env bash
|
|
386
|
+
# Generated by pantheon-opencode scripts/test-opencode-v2-sandbox.sh --prepare
|
|
387
|
+
# Validates the global install: the V2 binary, exactly 5 connected MCPs, doctor
|
|
388
|
+
# 0 errors, AND the CONTENT of pantheon://agents (zeus/hermes present).
|
|
389
|
+
set -euo pipefail
|
|
390
|
+
|
|
391
|
+
SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
392
|
+
SANDBOX_HOME="$SANDBOX_DIR/home"
|
|
393
|
+
NPM_PREFIX="$SANDBOX_HOME/.npm-global"
|
|
394
|
+
PANTHEON_GLOBAL="$SANDBOX_HOME/.config/opencode"
|
|
395
|
+
SANDBOX_VENV="$PANTHEON_GLOBAL/.venv"
|
|
396
|
+
# The checkout this harness ran from, written by the generator into the
|
|
397
|
+
# .repo-dir file next to this script. It used to be a placeholder baked into
|
|
398
|
+
# this line and substituted with sed afterwards, but the sandbox lives outside
|
|
399
|
+
# any repo so the value cannot be derived from this file's own location either.
|
|
400
|
+
# Reading a file the generator wrote keeps the path out of the shell source, so
|
|
401
|
+
# no escaping can be wrong. PANTHEON_REPO may still override it; the generator's
|
|
402
|
+
# --reset guard refuses to run when that points inside a repo, so an override
|
|
403
|
+
# can never turn this into a repo-wiping target.
|
|
404
|
+
REPO_DIR="${PANTHEON_REPO:-$(cat "$SANDBOX_DIR/.repo-dir")}"
|
|
405
|
+
|
|
406
|
+
echo "=== Pantheon Sandbox Test (OpenCode V2) ==="
|
|
407
|
+
echo "Sandbox: $SANDBOX_DIR"
|
|
408
|
+
echo "Repo: $REPO_DIR"
|
|
409
|
+
|
|
410
|
+
export HOME="$SANDBOX_HOME"
|
|
411
|
+
export PATH="$NPM_PREFIX/bin:$SANDBOX_VENV/bin:$PATH"
|
|
412
|
+
export npm_config_prefix="$NPM_PREFIX"
|
|
413
|
+
export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
|
|
414
|
+
# The V2 CLI resolves `mcp list` through its service. Keep generated sandbox
|
|
415
|
+
# runs isolated from any config/database/port inherited from the host.
|
|
416
|
+
V2_CONFIG="$SANDBOX_DIR/project-v2/opencode.json"
|
|
417
|
+
V2_DB="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-v2.db}"
|
|
418
|
+
V2_PORT="${PANTHEON_V2_PORT:-49376}"
|
|
419
|
+
V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
|
|
420
|
+
V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
|
|
421
|
+
V2_SERVER_LOG="$SANDBOX_DIR/v2-server.log"
|
|
422
|
+
V2_SERVICE_STATE="$SANDBOX_HOME/.local/state/opencode/service.json"
|
|
423
|
+
V2_SERVER_PID=""
|
|
424
|
+
export OPENCODE_CONFIG_DIR="$(dirname "$V2_CONFIG")"
|
|
425
|
+
export OPENCODE_DB="$V2_DB"
|
|
426
|
+
export PANTHEON_V2_PORT="$V2_PORT"
|
|
427
|
+
export PORT="$V2_PORT"
|
|
428
|
+
unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE
|
|
429
|
+
mkdir -p "$(dirname "$V2_SERVICE_STATE")"
|
|
430
|
+
|
|
431
|
+
stop_v2_service() {
|
|
432
|
+
if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
433
|
+
kill "$V2_SERVER_PID" 2>/dev/null || true
|
|
434
|
+
wait "$V2_SERVER_PID" 2>/dev/null || true
|
|
435
|
+
fi
|
|
436
|
+
rm -f "$V2_SERVICE_STATE"
|
|
437
|
+
V2_SERVER_PID=""
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
print_v2_diagnostics() {
|
|
441
|
+
printf '%s\n' '--- V2 service registration ---' >&2
|
|
442
|
+
if [ -f "$V2_SERVICE_STATE" ]; then
|
|
443
|
+
cat "$V2_SERVICE_STATE" >&2 || true
|
|
444
|
+
printf '\n' >&2
|
|
445
|
+
else
|
|
446
|
+
printf 'missing service registration: %s\n' "$V2_SERVICE_STATE" >&2
|
|
447
|
+
fi
|
|
448
|
+
printf '%s\n' '--- V2 service log ---' >&2
|
|
449
|
+
[ -f "$V2_SERVER_LOG" ] && tail -200 "$V2_SERVER_LOG" >&2 || true
|
|
450
|
+
local runtime_log="$HOME/.local/share/opencode/log/opencode.log"
|
|
451
|
+
printf '%s\n' '--- OpenCode runtime log ---' >&2
|
|
452
|
+
[ -f "$runtime_log" ] && tail -200 "$runtime_log" >&2 || true
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
wait_for_v2_service_registration() {
|
|
456
|
+
local registration_url="http://$V2_LOOPBACK_HOST:$V2_PORT"
|
|
457
|
+
local registration_pid="$V2_SERVER_PID"
|
|
458
|
+
local attempt
|
|
459
|
+
for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
|
|
460
|
+
if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
461
|
+
echo 'ERROR: V2 service exited before registration was written' >&2
|
|
462
|
+
print_v2_diagnostics
|
|
463
|
+
return 1
|
|
464
|
+
fi
|
|
465
|
+
if [ -f "$V2_SERVICE_STATE" ] \
|
|
466
|
+
&& SERVICE_STATE="$V2_SERVICE_STATE" \
|
|
467
|
+
REGISTRATION_URL="$registration_url" \
|
|
468
|
+
REGISTRATION_PID="$registration_pid" \
|
|
469
|
+
python3 - <<'PY'
|
|
470
|
+
import json
|
|
471
|
+
import os
|
|
472
|
+
from pathlib import Path
|
|
473
|
+
|
|
474
|
+
path = Path(os.environ["SERVICE_STATE"])
|
|
475
|
+
try:
|
|
476
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
477
|
+
except (OSError, json.JSONDecodeError):
|
|
478
|
+
raise SystemExit(1)
|
|
479
|
+
expected_url = os.environ["REGISTRATION_URL"]
|
|
480
|
+
expected_pid = int(os.environ["REGISTRATION_PID"])
|
|
481
|
+
if not isinstance(state, dict):
|
|
482
|
+
raise SystemExit(1)
|
|
483
|
+
if state.get("url") != expected_url or state.get("pid") != expected_pid:
|
|
484
|
+
raise SystemExit(1)
|
|
485
|
+
if not all(isinstance(state.get(key), str) and state[key] for key in ("id", "version", "password")):
|
|
486
|
+
raise SystemExit(1)
|
|
487
|
+
PY
|
|
488
|
+
then
|
|
489
|
+
return 0
|
|
490
|
+
fi
|
|
491
|
+
sleep 1
|
|
492
|
+
done
|
|
493
|
+
printf 'ERROR: timed out after %ss waiting for V2 service registration matching url=%s pid=%s\n' \
|
|
494
|
+
"$V2_HANDSHAKE_TIMEOUT" "$registration_url" "$registration_pid" >&2
|
|
495
|
+
print_v2_diagnostics
|
|
496
|
+
return 1
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
wait_for_v2_mcp_registrations() {
|
|
500
|
+
local runtime_dir="$1" python_bin="$2" attempt
|
|
501
|
+
for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
|
|
502
|
+
if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
503
|
+
echo 'ERROR: V2 service exited before all five MCP registrations completed' >&2
|
|
504
|
+
print_v2_diagnostics
|
|
505
|
+
return 1
|
|
506
|
+
fi
|
|
507
|
+
if SERVICE_LOG="$V2_SERVER_LOG" RUNTIME_DIR="$runtime_dir" MCP_PYTHON="$python_bin" \
|
|
508
|
+
python3 - <<'PY'
|
|
509
|
+
import os
|
|
510
|
+
import re
|
|
511
|
+
from pathlib import Path
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
log = Path(os.environ["SERVICE_LOG"]).read_text(encoding="utf-8")
|
|
515
|
+
except OSError:
|
|
516
|
+
raise SystemExit(1)
|
|
517
|
+
expected = {
|
|
518
|
+
"code_mode_server.py",
|
|
519
|
+
"memory_mcp_server.py",
|
|
520
|
+
"mcp_persistence_server.py",
|
|
521
|
+
"mcp_resources_server.py",
|
|
522
|
+
"pantheon_vision_server.py",
|
|
523
|
+
}
|
|
524
|
+
pattern = re.compile(
|
|
525
|
+
r"spawning process \{\s*"
|
|
526
|
+
r"command:\s*\"(?P<command>[^\"]+)\".*?"
|
|
527
|
+
r"args:\s*\[\s*\"(?P<script>[^\"]+)\"\s*\].*?"
|
|
528
|
+
r"cwd:\s*\"(?P<cwd>[^\"]+)\"",
|
|
529
|
+
re.DOTALL,
|
|
530
|
+
)
|
|
531
|
+
records = list(pattern.finditer(log))
|
|
532
|
+
runtime_dir = os.environ["RUNTIME_DIR"]
|
|
533
|
+
python_bin = os.environ["MCP_PYTHON"]
|
|
534
|
+
registered = set()
|
|
535
|
+
for record in records:
|
|
536
|
+
command = record.group("command")
|
|
537
|
+
script = Path(record.group("script")).name
|
|
538
|
+
cwd = record.group("cwd")
|
|
539
|
+
if command != python_bin or cwd != runtime_dir or script not in expected:
|
|
540
|
+
raise SystemExit(1)
|
|
541
|
+
registered.add(script)
|
|
542
|
+
if len(records) != len(expected) or registered != expected:
|
|
543
|
+
raise SystemExit(1)
|
|
544
|
+
PY
|
|
545
|
+
then
|
|
546
|
+
return 0
|
|
547
|
+
fi
|
|
548
|
+
sleep 1
|
|
549
|
+
done
|
|
550
|
+
printf 'ERROR: timed out after %ss waiting for exactly five MCP registrations in the active sandbox\n' \
|
|
551
|
+
"$V2_HANDSHAKE_TIMEOUT" >&2
|
|
552
|
+
print_v2_diagnostics
|
|
553
|
+
return 1
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
start_v2_service() {
|
|
557
|
+
local bin="$1" project="$2" attempt ready=0
|
|
558
|
+
if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
559
|
+
return 0
|
|
560
|
+
fi
|
|
561
|
+
if curl --fail --silent --show-error --max-time 1 \
|
|
562
|
+
"http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
|
|
563
|
+
echo "ERROR: dedicated V2 port $V2_PORT is already in use" >&2
|
|
564
|
+
return 1
|
|
565
|
+
fi
|
|
566
|
+
rm -f "$V2_SERVER_LOG" "$V2_SERVICE_STATE" "$HOME/.local/share/opencode/log/opencode.log"
|
|
567
|
+
(cd "$project" && "$bin" serve --hostname "$V2_LOOPBACK_HOST" --port "$V2_PORT" --service) \
|
|
568
|
+
>"$V2_SERVER_LOG" 2>&1 &
|
|
569
|
+
V2_SERVER_PID=$!
|
|
570
|
+
for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
|
|
571
|
+
if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
572
|
+
echo 'ERROR: V2 service exited before becoming healthy' >&2
|
|
573
|
+
print_v2_diagnostics
|
|
574
|
+
return 1
|
|
575
|
+
fi
|
|
576
|
+
if curl --fail --silent --show-error --max-time 2 \
|
|
577
|
+
"http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
|
|
578
|
+
ready=1
|
|
579
|
+
break
|
|
580
|
+
fi
|
|
581
|
+
sleep 1
|
|
582
|
+
done
|
|
583
|
+
if [ "$ready" -ne 1 ]; then
|
|
584
|
+
echo "ERROR: timed out waiting for V2 service on port $V2_PORT" >&2
|
|
585
|
+
print_v2_diagnostics
|
|
586
|
+
return 1
|
|
587
|
+
fi
|
|
588
|
+
if ! wait_for_v2_service_registration; then
|
|
589
|
+
return 1
|
|
590
|
+
fi
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
wait_for_v2_handshakes() {
|
|
594
|
+
local runtime_log="$HOME/.local/share/opencode/log/opencode.log"
|
|
595
|
+
local names=(pantheon-code-mode pantheon-memory pantheon-persistence pantheon-resources pantheon-vision)
|
|
596
|
+
local attempt name ready connected_names connected_count
|
|
597
|
+
for attempt in $(seq 1 "$V2_HANDSHAKE_TIMEOUT"); do
|
|
598
|
+
if ! kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
599
|
+
echo 'ERROR: V2 service exited before all MCP handshakes completed' >&2
|
|
600
|
+
print_v2_diagnostics
|
|
601
|
+
return 1
|
|
602
|
+
fi
|
|
603
|
+
ready=1
|
|
604
|
+
for name in "${names[@]}"; do
|
|
605
|
+
if [ ! -f "$runtime_log" ] || ! grep -Fq "message=\"mcp connected\" server=$name" "$runtime_log"; then
|
|
606
|
+
ready=0
|
|
607
|
+
break
|
|
608
|
+
fi
|
|
609
|
+
done
|
|
610
|
+
if [ "$ready" -eq 1 ]; then
|
|
611
|
+
connected_names="$({ grep -oE 'message=\"mcp connected\" server=[^ ]+' "$runtime_log" || true; } \
|
|
612
|
+
| sed 's/.*server=//' | sort -u)"
|
|
613
|
+
connected_count="$(printf '%s\n' "$connected_names" | sed '/^$/d' | wc -l)"
|
|
614
|
+
if [ "$connected_count" -eq 5 ] \
|
|
615
|
+
&& ! printf '%s\n' "$connected_names" | grep -Evx \
|
|
616
|
+
'pantheon-code-mode|pantheon-memory|pantheon-persistence|pantheon-resources|pantheon-vision' \
|
|
617
|
+
>/dev/null; then
|
|
618
|
+
return 0
|
|
619
|
+
fi
|
|
620
|
+
ready=0
|
|
621
|
+
fi
|
|
622
|
+
sleep 1
|
|
623
|
+
done
|
|
624
|
+
echo "ERROR: timed out waiting for exactly five MCP handshakes" >&2
|
|
625
|
+
print_v2_diagnostics
|
|
626
|
+
return 1
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
rewrite_v2_mcp_config() {
|
|
630
|
+
local config_path="$V2_CONFIG"
|
|
631
|
+
local runtime_dir="$SANDBOX_DIR/project-v2/.opencode"
|
|
632
|
+
local python_bin="$SANDBOX_DIR/project-v2/.venv/bin/python3"
|
|
633
|
+
[ -f "$config_path" ] || { echo "ERROR: V2 config was not generated: $config_path" >&2; return 1; }
|
|
634
|
+
[ -x "$python_bin" ] || { echo "ERROR: V2 MCP Python is missing: $python_bin" >&2; return 1; }
|
|
635
|
+
CONFIG_PATH="$config_path" RUNTIME_DIR="$runtime_dir" MCP_PYTHON="$python_bin" \
|
|
636
|
+
python3 - <<'PY'
|
|
637
|
+
import json
|
|
638
|
+
import os
|
|
639
|
+
from pathlib import Path
|
|
640
|
+
|
|
641
|
+
config_path = Path(os.environ["CONFIG_PATH"])
|
|
642
|
+
runtime_dir = os.environ["RUNTIME_DIR"]
|
|
643
|
+
python_bin = os.environ["MCP_PYTHON"]
|
|
644
|
+
managed = {
|
|
645
|
+
"pantheon-code-mode": "code_mode_server.py",
|
|
646
|
+
"pantheon-memory": "memory_mcp_server.py",
|
|
647
|
+
"pantheon-persistence": "mcp_persistence_server.py",
|
|
648
|
+
"pantheon-resources": "mcp_resources_server.py",
|
|
649
|
+
"pantheon-vision": "pantheon_vision_server.py",
|
|
650
|
+
}
|
|
651
|
+
try:
|
|
652
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
653
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
654
|
+
raise SystemExit(f"cannot read V2 config {config_path}: {exc}")
|
|
655
|
+
if not isinstance(config, dict):
|
|
656
|
+
raise SystemExit(f"V2 config must be an object: {config_path}")
|
|
657
|
+
mcp = config.setdefault("mcp", {})
|
|
658
|
+
if not isinstance(mcp, dict):
|
|
659
|
+
raise SystemExit("V2 config mcp section must be an object")
|
|
660
|
+
for name, script in managed.items():
|
|
661
|
+
entry = mcp.get(name) if isinstance(mcp.get(name), dict) else {}
|
|
662
|
+
entry.pop("disabled", None)
|
|
663
|
+
entry.update(type="local", cwd=runtime_dir, command=[python_bin, f"scripts/{script}"], enabled=True)
|
|
664
|
+
mcp[name] = entry
|
|
665
|
+
tmp_path = config_path.with_name(config_path.name + ".sandbox-tmp")
|
|
666
|
+
tmp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
667
|
+
os.replace(tmp_path, config_path)
|
|
668
|
+
validated = json.loads(config_path.read_text(encoding="utf-8"))
|
|
669
|
+
for name, script in managed.items():
|
|
670
|
+
entry = validated.get("mcp", {}).get(name)
|
|
671
|
+
expected = {"type": "local", "cwd": runtime_dir, "command": [python_bin, f"scripts/{script}"], "enabled": True}
|
|
672
|
+
if not isinstance(entry, dict) or any(entry.get(key) != value for key, value in expected.items()):
|
|
673
|
+
raise SystemExit(f"stale or invalid paths remain for {name} in {config_path}")
|
|
674
|
+
if not Path(entry["cwd"]).is_dir() or not Path(entry["command"][0]).is_file() or not (Path(entry["cwd"]) / entry["command"][1]).is_file():
|
|
675
|
+
raise SystemExit(f"active sandbox path is missing for {name}: {entry}")
|
|
676
|
+
PY
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
trap stop_v2_service EXIT
|
|
680
|
+
|
|
681
|
+
STATUS_FAIL=0
|
|
682
|
+
STATUS_PASS=0
|
|
683
|
+
|
|
684
|
+
record_status() { # label status detail
|
|
685
|
+
local label="$1" status="$2" detail="$3"
|
|
686
|
+
case "$status" in
|
|
687
|
+
PASS) STATUS_PASS=$((STATUS_PASS + 1)) ;;
|
|
688
|
+
FAIL) STATUS_FAIL=$((STATUS_FAIL + 1)) ;;
|
|
689
|
+
*) STATUS_FAIL=$((STATUS_FAIL + 1)); status="FAIL" ;;
|
|
690
|
+
esac
|
|
691
|
+
printf '%s: %s — %s\n' "$status" "$label" "$detail"
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
check_binary() { # label binary
|
|
695
|
+
local label="$1" bin="$2" out rc=0
|
|
696
|
+
if ! command -v "$bin" >/dev/null 2>&1; then
|
|
697
|
+
record_status "$label" "FAIL" "binary '$bin' is not installed in the sandbox prefix"
|
|
698
|
+
return 0
|
|
699
|
+
fi
|
|
700
|
+
set +e
|
|
701
|
+
out="$("$bin" --version 2>&1)"
|
|
702
|
+
rc=$?
|
|
703
|
+
set -e
|
|
704
|
+
if [ "$rc" -eq 0 ]; then
|
|
705
|
+
record_status "$label" "PASS" "${out:-version command exited 0}"
|
|
706
|
+
else
|
|
707
|
+
record_status "$label" "FAIL" "version check exited $rc"
|
|
708
|
+
fi
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
check_mcp() { # label binary project
|
|
712
|
+
local label="$1" bin="$2" project="$3" out connected_count rc=0 list_file list_pid
|
|
713
|
+
local final_file final_pid final_rc final_count
|
|
714
|
+
if ! command -v "$bin" >/dev/null 2>&1; then
|
|
715
|
+
record_status "$label MCP" "FAIL" "binary '$bin' is not installed in the sandbox prefix"
|
|
716
|
+
return 0
|
|
717
|
+
fi
|
|
718
|
+
if [ ! -d "$project" ]; then
|
|
719
|
+
record_status "$label MCP" "FAIL" "project directory is missing: $project"
|
|
720
|
+
return 0
|
|
721
|
+
fi
|
|
722
|
+
if ! start_v2_service "$bin" "$project"; then
|
|
723
|
+
record_status "$label MCP" "FAIL" "V2 service did not become healthy"
|
|
724
|
+
return 0
|
|
725
|
+
fi
|
|
726
|
+
|
|
727
|
+
# `mcp list` is the operation that causes OpenCode V2 to launch the MCP
|
|
728
|
+
# subprocesses. Start it immediately after health, before waiting for the
|
|
729
|
+
# handshakes; waiting first deadlocks forever because those subprocesses are
|
|
730
|
+
# otherwise never loaded.
|
|
731
|
+
list_file="$(mktemp "$SANDBOX_DIR/v2-mcp-list.XXXXXX")"
|
|
732
|
+
(cd "$project" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
|
|
733
|
+
>"$list_file" 2>&1 &
|
|
734
|
+
list_pid=$!
|
|
735
|
+
if ! wait_for_v2_mcp_registrations "$project/.opencode" "$project/.venv/bin/python3"; then
|
|
736
|
+
kill "$list_pid" 2>/dev/null || true
|
|
737
|
+
wait "$list_pid" 2>/dev/null || true
|
|
738
|
+
rm -f "$list_file"
|
|
739
|
+
record_status "$label MCP" "FAIL" "MCP registrations did not match the active sandbox"
|
|
740
|
+
return 0
|
|
741
|
+
fi
|
|
742
|
+
if wait_for_v2_handshakes; then
|
|
743
|
+
:
|
|
744
|
+
else
|
|
745
|
+
kill "$list_pid" 2>/dev/null || true
|
|
746
|
+
wait "$list_pid" 2>/dev/null || true
|
|
747
|
+
rm -f "$list_file"
|
|
748
|
+
record_status "$label MCP" "FAIL" "MCP handshakes did not become ready after mcp list started"
|
|
749
|
+
return 0
|
|
750
|
+
fi
|
|
751
|
+
if wait "$list_pid"; then
|
|
752
|
+
rc=0
|
|
753
|
+
else
|
|
754
|
+
rc=$?
|
|
755
|
+
fi
|
|
756
|
+
out="$(cat "$list_file")"
|
|
757
|
+
rm -f "$list_file"
|
|
758
|
+
connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)' || true)"
|
|
759
|
+
if [ "$rc" -eq 0 ] && [ "$connected_count" -ne 5 ]; then
|
|
760
|
+
# The first V2 beta `mcp list` call is the loader trigger and may return
|
|
761
|
+
# before the service has populated its MCP catalog. Re-query only after
|
|
762
|
+
# the registration and handshake gates above have proved the active five.
|
|
763
|
+
final_file="$(mktemp "$SANDBOX_DIR/v2-mcp-list.XXXXXX")"
|
|
764
|
+
(cd "$project" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
|
|
765
|
+
>"$final_file" 2>&1 &
|
|
766
|
+
final_pid=$!
|
|
767
|
+
if wait "$final_pid"; then
|
|
768
|
+
final_rc=0
|
|
769
|
+
else
|
|
770
|
+
final_rc=$?
|
|
771
|
+
fi
|
|
772
|
+
out="$(cat "$final_file")"
|
|
773
|
+
rm -f "$final_file"
|
|
774
|
+
if [ "$final_rc" -ne 0 ]; then
|
|
775
|
+
rc="$final_rc"
|
|
776
|
+
fi
|
|
777
|
+
fi
|
|
778
|
+
out="$(printf '%s' "$out" | sed -E $'s/\x1B\\[[0-?]*[ -/]*[@-~]//g')"
|
|
779
|
+
printf '%s\n' "$out"
|
|
780
|
+
if [ "$rc" -ne 0 ]; then
|
|
781
|
+
record_status "$label MCP" "FAIL" "mcp list exited $rc (see output above)"
|
|
782
|
+
return 0
|
|
783
|
+
fi
|
|
784
|
+
connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)')" || connected_count=0
|
|
785
|
+
if [ "$connected_count" -eq 5 ] \
|
|
786
|
+
&& ! printf '%s' "$out" | grep -Eiq 'failed|✘' \
|
|
787
|
+
&& printf '%s' "$out" | grep -Fq 'pantheon-code-mode' \
|
|
788
|
+
&& printf '%s' "$out" | grep -Fq 'pantheon-memory' \
|
|
789
|
+
&& printf '%s' "$out" | grep -Fq 'pantheon-persistence' \
|
|
790
|
+
&& printf '%s' "$out" | grep -Fq 'pantheon-resources' \
|
|
791
|
+
&& printf '%s' "$out" | grep -Fq 'pantheon-vision'; then
|
|
792
|
+
record_status "$label MCP" "PASS" "exactly 5 connected MCPs"
|
|
793
|
+
else
|
|
794
|
+
record_status "$label MCP" "FAIL" "expected exactly 5 connected MCPs; found $connected_count or failures reported"
|
|
795
|
+
fi
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
echo "--- Binaries ---"
|
|
799
|
+
check_binary "V2 binary" opencode2
|
|
800
|
+
|
|
801
|
+
echo "--- Building tarball ---"
|
|
802
|
+
cd "$REPO_DIR"
|
|
803
|
+
npm pack --ignore-scripts 2>/dev/null || { echo "ERROR: npm pack failed"; exit 1; }
|
|
804
|
+
TGZ=$(ls -t pantheon-opencode-*.tgz | head -1)
|
|
805
|
+
npm rm -g pantheon-opencode 2>/dev/null || true
|
|
806
|
+
npm install -g "$TGZ"
|
|
807
|
+
if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$TGZ" >/dev/null 2>&1; then rm -f -- "$TGZ"; fi
|
|
808
|
+
|
|
809
|
+
echo "--- Init (headless) ---"
|
|
810
|
+
pantheon-opencode init --headless -y
|
|
811
|
+
rewrite_v2_mcp_config
|
|
812
|
+
|
|
813
|
+
echo "--- MCP Validation ---"
|
|
814
|
+
check_mcp "V2" opencode2 "$SANDBOX_DIR/project-v2"
|
|
815
|
+
|
|
816
|
+
echo "--- Doctor ---"
|
|
817
|
+
if command -v pantheon-opencode >/dev/null 2>&1; then
|
|
818
|
+
set +e
|
|
819
|
+
DOCTOR_OUTPUT="$(pantheon-opencode doctor --target "$SANDBOX_DIR" 2>&1)"
|
|
820
|
+
DOCTOR_RC=$?
|
|
821
|
+
set -e
|
|
822
|
+
if [ "$DOCTOR_RC" -eq 0 ]; then
|
|
823
|
+
record_status "doctor" "PASS" "exit 0"
|
|
824
|
+
else
|
|
825
|
+
record_status "doctor" "FAIL" "doctor exited $DOCTOR_RC"
|
|
826
|
+
fi
|
|
827
|
+
else
|
|
828
|
+
record_status "doctor" "FAIL" "pantheon-opencode is not installed in the sandbox prefix"
|
|
829
|
+
fi
|
|
830
|
+
|
|
831
|
+
echo "--- Gate (b): pantheon://agents CONTENT check ---"
|
|
832
|
+
RESOURCES_SRV="$PANTHEON_GLOBAL/scripts/mcp_resources_server.py"
|
|
833
|
+
if [ ! -f "$RESOURCES_SRV" ]; then
|
|
834
|
+
record_status "pantheon://agents resource" "FAIL" "resources server not found at $RESOURCES_SRV"
|
|
835
|
+
else
|
|
836
|
+
set +e
|
|
837
|
+
AGENTS_CONTENT="$("$SANDBOX_VENV/bin/python" - "$RESOURCES_SRV" <<'PYEOF'
|
|
838
|
+
import asyncio, importlib.util, sys
|
|
839
|
+
from pathlib import Path
|
|
840
|
+
resource_path = Path(sys.argv[1]).resolve()
|
|
841
|
+
sys.path.insert(0, str(resource_path.parent))
|
|
842
|
+
spec = importlib.util.spec_from_file_location("mrs", sys.argv[1])
|
|
843
|
+
mod = importlib.util.module_from_spec(spec)
|
|
844
|
+
spec.loader.exec_module(mod)
|
|
845
|
+
print(asyncio.run(mod.list_agents()))
|
|
846
|
+
PYEOF
|
|
847
|
+
)"
|
|
848
|
+
AGENTS_RC=$?
|
|
849
|
+
set -e
|
|
850
|
+
printf '%s\n' "$AGENTS_CONTENT" | head -8
|
|
851
|
+
if [ "$AGENTS_RC" -ne 0 ]; then
|
|
852
|
+
record_status "pantheon://agents resource" "FAIL" "resource probe exited $AGENTS_RC"
|
|
853
|
+
elif printf '%s\n' "$AGENTS_CONTENT" | grep -qi 'zeus' \
|
|
854
|
+
&& printf '%s\n' "$AGENTS_CONTENT" | grep -qi 'hermes'; then
|
|
855
|
+
record_status "pantheon://agents resource" "PASS" "pantheon://agents content includes zeus and hermes"
|
|
856
|
+
else
|
|
857
|
+
record_status "pantheon://agents resource" "FAIL" "content missing zeus/hermes (connectivity alone is not enough)"
|
|
858
|
+
fi
|
|
859
|
+
fi
|
|
860
|
+
echo ""
|
|
861
|
+
echo "=== VALIDATION COMPLETE ==="
|
|
862
|
+
if [ "$STATUS_FAIL" -gt 0 ] || [ "$STATUS_PASS" -eq 0 ]; then
|
|
863
|
+
FINAL_VERDICT="FAIL"
|
|
864
|
+
else
|
|
865
|
+
FINAL_VERDICT="PASS"
|
|
866
|
+
fi
|
|
867
|
+
printf 'Final verdict: %s\n' "$FINAL_VERDICT"
|
|
868
|
+
if [ "$FINAL_VERDICT" != "PASS" ]; then
|
|
869
|
+
exit 1
|
|
870
|
+
fi
|
|
871
|
+
echo "Next: open the isolated TUI with: $SANDBOX_DIR/start-pantheon.sh"
|
|
872
|
+
RUNTEST
|
|
873
|
+
chmod +x "$SANDBOX_ROOT/run-test.sh"
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
# ── Reset / Prepare ───────────────────────────────────────────────────────────
|
|
877
|
+
|
|
878
|
+
# Resolve existing symlinked directory components and normalize missing path
|
|
879
|
+
# components without requiring the sandbox root to exist yet. If an existing
|
|
880
|
+
# non-directory appears before the final component, resolution fails closed.
|
|
881
|
+
canonicalize_path() {
|
|
882
|
+
local path="$1" resolved="/" component candidate
|
|
883
|
+
[ -n "$path" ] || return 1
|
|
884
|
+
case "$path" in
|
|
885
|
+
/*) ;;
|
|
886
|
+
*) path="$PWD/$path" ;;
|
|
887
|
+
esac
|
|
888
|
+
|
|
889
|
+
while [ -n "$path" ]; do
|
|
890
|
+
case "$path" in
|
|
891
|
+
*/*) component="${path%%/*}"; path="${path#*/}" ;;
|
|
892
|
+
*) component="$path"; path="" ;;
|
|
893
|
+
esac
|
|
894
|
+
case "$component" in
|
|
895
|
+
"" | .) continue ;;
|
|
896
|
+
..)
|
|
897
|
+
if [ "$resolved" != "/" ]; then
|
|
898
|
+
resolved="${resolved%/*}"
|
|
899
|
+
[ -n "$resolved" ] || resolved="/"
|
|
900
|
+
fi
|
|
901
|
+
;;
|
|
902
|
+
*)
|
|
903
|
+
if [ "$resolved" = "/" ]; then
|
|
904
|
+
candidate="/$component"
|
|
905
|
+
else
|
|
906
|
+
candidate="$resolved/$component"
|
|
907
|
+
fi
|
|
908
|
+
if [ -d "$candidate" ]; then
|
|
909
|
+
resolved="$(cd -P -- "$candidate" 2>/dev/null && pwd -P)" || return 1
|
|
910
|
+
elif [ -e "$candidate" ] || [ -L "$candidate" ]; then
|
|
911
|
+
[ -z "$path" ] || return 1
|
|
912
|
+
resolved="$candidate"
|
|
913
|
+
else
|
|
914
|
+
resolved="$candidate"
|
|
915
|
+
fi
|
|
916
|
+
;;
|
|
917
|
+
esac
|
|
918
|
+
done
|
|
919
|
+
|
|
920
|
+
printf '%s\n' "$resolved"
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
# Compare complete path components; a sibling such as project-extra is not
|
|
924
|
+
# inside project. The root directory is a parent of every absolute path.
|
|
925
|
+
path_is_same_or_descendant() {
|
|
926
|
+
local candidate="$1" parent="$2"
|
|
927
|
+
[ "$candidate" = "$parent" ] && return 0
|
|
928
|
+
if [ "$parent" = "/" ]; then
|
|
929
|
+
case "$candidate" in /*) return 0 ;; esac
|
|
930
|
+
fi
|
|
931
|
+
case "$candidate" in "$parent"/*) return 0 ;; esac
|
|
932
|
+
return 1
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
cmd_reset() {
|
|
936
|
+
# Guard BOTH repos this harness can act on. REPO_DIR is where `--prepare`
|
|
937
|
+
# runs `npm pack`; PANTHEON_REPO is the documented override the GENERATED
|
|
938
|
+
# run-test.sh honours, and it may point somewhere else entirely. Refusing
|
|
939
|
+
# only the default left an override able to name a checkout and have the
|
|
940
|
+
# generated script pack a tarball into it.
|
|
941
|
+
local protected canonical_root canonical_home canonical_protected
|
|
942
|
+
canonical_root="$(canonicalize_path "$SANDBOX_ROOT")" \
|
|
943
|
+
|| die "refusing to reset sandbox root that cannot be canonicalized: $SANDBOX_ROOT"
|
|
944
|
+
canonical_home="$(canonicalize_path "$HOME")" \
|
|
945
|
+
|| die "refusing to reset because HOME cannot be canonicalized: $HOME"
|
|
946
|
+
[ "$canonical_root" != "/" ] \
|
|
947
|
+
|| die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (filesystem root)"
|
|
948
|
+
[ "$canonical_root" != "$canonical_home" ] \
|
|
949
|
+
|| die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (HOME)"
|
|
950
|
+
if path_is_same_or_descendant "$canonical_home" "$canonical_root"; then
|
|
951
|
+
die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of HOME $HOME)"
|
|
952
|
+
fi
|
|
953
|
+
|
|
954
|
+
for protected in "$REPO_DIR" "${PANTHEON_REPO:-}"; do
|
|
955
|
+
[ -n "$protected" ] || continue
|
|
956
|
+
canonical_protected="$(canonicalize_path "$protected")" \
|
|
957
|
+
|| die "refusing to reset because protected repo path cannot be canonicalized: $protected"
|
|
958
|
+
if path_is_same_or_descendant "$canonical_root" "$canonical_protected"; then
|
|
959
|
+
die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is equal to or inside the repo $protected)"
|
|
960
|
+
fi
|
|
961
|
+
if path_is_same_or_descendant "$canonical_protected" "$canonical_root"; then
|
|
962
|
+
die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of the repo $protected)"
|
|
963
|
+
fi
|
|
964
|
+
done
|
|
965
|
+
log "Resetting sandbox: $SANDBOX_ROOT"
|
|
966
|
+
rm -rf "$SANDBOX_ROOT"
|
|
967
|
+
log "Sandbox removed."
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
install_binaries() {
|
|
971
|
+
log "--- Installing pantheon-opencode (from repo tarball) ---"
|
|
972
|
+
cd "$REPO_DIR"
|
|
973
|
+
npm pack --ignore-scripts 2>/dev/null || die "npm pack failed"
|
|
974
|
+
local tgz
|
|
975
|
+
tgz=$(ls -t pantheon-opencode-*.tgz | head -1)
|
|
976
|
+
npm rm -g pantheon-opencode 2>/dev/null || true
|
|
977
|
+
npm install -g "$REPO_DIR/$tgz" || die "install of $tgz failed"
|
|
978
|
+
if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$tgz" >/dev/null 2>&1; then rm -f -- "$tgz"; fi
|
|
979
|
+
log "--- Installing OpenCode V2 ($OPENCODE_V2_SPEC) ---"
|
|
980
|
+
npm install -g "$OPENCODE_V2_SPEC" || die "install of $OPENCODE_V2_SPEC failed"
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
prepare_project() {
|
|
984
|
+
local dir pan
|
|
985
|
+
dir="$(project_dir)"
|
|
986
|
+
mkdir -p "$dir"
|
|
987
|
+
pan="$(sandbox_bin_for "pantheon-opencode")" \
|
|
988
|
+
|| die "pantheon-opencode not installed in sandbox prefix — run $0 --prepare first"
|
|
989
|
+
log "--- Regenerating config in $dir ---"
|
|
990
|
+
(cd "$dir" && "$pan" init --project --version "$TARGET_VERSION" --model "$PANTHEON_SANDBOX_MODEL" -y) \
|
|
991
|
+
|| die "init --project --version $TARGET_VERSION failed in $dir"
|
|
992
|
+
rewrite_v2_mcp_config
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
# The installer merges an existing project config. A reused sandbox can
|
|
996
|
+
# therefore contain five otherwise-valid MCP entries whose absolute paths
|
|
997
|
+
# point at a different sandbox or host. Rewrite the managed entries after the
|
|
998
|
+
# merge and validate the complete shape before any OpenCode process starts.
|
|
999
|
+
rewrite_v2_mcp_config() {
|
|
1000
|
+
local runtime_dir="$(project_dir)/.opencode"
|
|
1001
|
+
local python_bin="$(project_dir)/.venv/bin/python3"
|
|
1002
|
+
[ -f "$V2_CONFIG" ] || die "V2 config was not generated: $V2_CONFIG"
|
|
1003
|
+
[ -x "$python_bin" ] || die "V2 MCP Python is missing: $python_bin"
|
|
1004
|
+
|
|
1005
|
+
CONFIG_PATH="$V2_CONFIG" RUNTIME_DIR="$runtime_dir" MCP_PYTHON="$python_bin" \
|
|
1006
|
+
python3 - <<'PY'
|
|
1007
|
+
import json
|
|
1008
|
+
import os
|
|
1009
|
+
import sys
|
|
1010
|
+
from pathlib import Path
|
|
1011
|
+
|
|
1012
|
+
config_path = Path(os.environ["CONFIG_PATH"])
|
|
1013
|
+
runtime_dir = os.environ["RUNTIME_DIR"]
|
|
1014
|
+
python_bin = os.environ["MCP_PYTHON"]
|
|
1015
|
+
managed = {
|
|
1016
|
+
"pantheon-code-mode": "code_mode_server.py",
|
|
1017
|
+
"pantheon-memory": "memory_mcp_server.py",
|
|
1018
|
+
"pantheon-persistence": "mcp_persistence_server.py",
|
|
1019
|
+
"pantheon-resources": "mcp_resources_server.py",
|
|
1020
|
+
"pantheon-vision": "pantheon_vision_server.py",
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
try:
|
|
1024
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
1025
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1026
|
+
raise SystemExit(f"cannot read V2 config {config_path}: {exc}")
|
|
1027
|
+
if not isinstance(config, dict):
|
|
1028
|
+
raise SystemExit(f"V2 config must be an object: {config_path}")
|
|
1029
|
+
mcp = config.setdefault("mcp", {})
|
|
1030
|
+
if not isinstance(mcp, dict):
|
|
1031
|
+
raise SystemExit("V2 config mcp section must be an object")
|
|
1032
|
+
|
|
1033
|
+
for name, script in managed.items():
|
|
1034
|
+
entry = mcp.get(name)
|
|
1035
|
+
if not isinstance(entry, dict):
|
|
1036
|
+
entry = {}
|
|
1037
|
+
# Do not retain command/cwd values from the merged config. These are the
|
|
1038
|
+
# only paths the active project is allowed to use in this sandbox.
|
|
1039
|
+
entry.pop("disabled", None)
|
|
1040
|
+
entry.update(
|
|
1041
|
+
type="local",
|
|
1042
|
+
cwd=runtime_dir,
|
|
1043
|
+
command=[python_bin, f"scripts/{script}"],
|
|
1044
|
+
enabled=True,
|
|
1045
|
+
)
|
|
1046
|
+
mcp[name] = entry
|
|
1047
|
+
|
|
1048
|
+
tmp_path = config_path.with_name(config_path.name + ".sandbox-tmp")
|
|
1049
|
+
try:
|
|
1050
|
+
tmp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
1051
|
+
os.replace(tmp_path, config_path)
|
|
1052
|
+
except OSError as exc:
|
|
1053
|
+
try:
|
|
1054
|
+
tmp_path.unlink()
|
|
1055
|
+
except OSError:
|
|
1056
|
+
pass
|
|
1057
|
+
raise SystemExit(f"cannot rewrite V2 config {config_path}: {exc}")
|
|
1058
|
+
|
|
1059
|
+
try:
|
|
1060
|
+
validated = json.loads(config_path.read_text(encoding="utf-8"))
|
|
1061
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1062
|
+
raise SystemExit(f"cannot validate rewritten V2 config {config_path}: {exc}")
|
|
1063
|
+
for name, script in managed.items():
|
|
1064
|
+
entry = validated.get("mcp", {}).get(name)
|
|
1065
|
+
expected = {
|
|
1066
|
+
"type": "local",
|
|
1067
|
+
"cwd": runtime_dir,
|
|
1068
|
+
"command": [python_bin, f"scripts/{script}"],
|
|
1069
|
+
"enabled": True,
|
|
1070
|
+
}
|
|
1071
|
+
if not isinstance(entry, dict) or any(entry.get(key) != value for key, value in expected.items()):
|
|
1072
|
+
raise SystemExit(f"stale or invalid paths remain for {name} in {config_path}")
|
|
1073
|
+
if not Path(entry["cwd"]).is_dir() or not Path(entry["command"][0]).is_file():
|
|
1074
|
+
raise SystemExit(f"active sandbox path is missing for {name}: {entry}")
|
|
1075
|
+
if not (Path(entry["cwd"]) / entry["command"][1]).is_file():
|
|
1076
|
+
raise SystemExit(f"active sandbox MCP script is missing for {name}: {entry}")
|
|
1077
|
+
PY
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
cmd_prepare() {
|
|
1081
|
+
mkdir -p "$SANDBOX_HOME" "$NPM_PREFIX"
|
|
1082
|
+
write_run_test_sh
|
|
1083
|
+
sandbox_env
|
|
1084
|
+
install_binaries
|
|
1085
|
+
local pan
|
|
1086
|
+
pan="$(sandbox_bin_for "pantheon-opencode")" \
|
|
1087
|
+
|| die "pantheon-opencode not found in sandbox prefix after install"
|
|
1088
|
+
log "--- Headless init (global venv + runtime) ---"
|
|
1089
|
+
"$pan" init --headless -y --model "$PANTHEON_SANDBOX_MODEL" \
|
|
1090
|
+
|| die "pantheon-opencode init --headless failed"
|
|
1091
|
+
prepare_project
|
|
1092
|
+
write_extract_py
|
|
1093
|
+
log "Prepare complete. Sandbox: $SANDBOX_ROOT"
|
|
1094
|
+
log "Next: $0 --run v2 --prompts"
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
# ── JSON text extraction (schema-agnostic) ────────────────────────────────────
|
|
1098
|
+
|
|
1099
|
+
write_extract_py() {
|
|
1100
|
+
cat > "$EXTRACT_PY" <<'PYEOF'
|
|
1101
|
+
"""Collect every string value from opencode run --format json output."""
|
|
1102
|
+
import json
|
|
1103
|
+
import sys
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def walk(obj, out):
|
|
1107
|
+
if isinstance(obj, str):
|
|
1108
|
+
out.append(obj)
|
|
1109
|
+
elif isinstance(obj, dict):
|
|
1110
|
+
for value in obj.values():
|
|
1111
|
+
walk(value, out)
|
|
1112
|
+
elif isinstance(obj, list):
|
|
1113
|
+
for value in obj:
|
|
1114
|
+
walk(value, out)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
raw = sys.stdin.read()
|
|
1118
|
+
chunks = []
|
|
1119
|
+
try:
|
|
1120
|
+
walk(json.loads(raw), chunks)
|
|
1121
|
+
except Exception:
|
|
1122
|
+
for line in raw.splitlines():
|
|
1123
|
+
line = line.strip()
|
|
1124
|
+
if not line:
|
|
1125
|
+
continue
|
|
1126
|
+
try:
|
|
1127
|
+
walk(json.loads(line), chunks)
|
|
1128
|
+
except Exception:
|
|
1129
|
+
chunks.append(line)
|
|
1130
|
+
print("\n".join(chunks))
|
|
1131
|
+
PYEOF
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
# ── Prompt battery ────────────────────────────────────────────────────────────
|
|
1135
|
+
|
|
1136
|
+
PROMPT_IDS=()
|
|
1137
|
+
PROMPT_TEXTS=()
|
|
1138
|
+
PROMPT_MARKERS=()
|
|
1139
|
+
PROMPT_VERIFY=()
|
|
1140
|
+
|
|
1141
|
+
add_prompt() { # id marker ignored verify text
|
|
1142
|
+
PROMPT_IDS+=("$1")
|
|
1143
|
+
PROMPT_MARKERS+=("$2")
|
|
1144
|
+
PROMPT_VERIFY+=("$4")
|
|
1145
|
+
PROMPT_TEXTS+=("$5")
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
build_battery() {
|
|
1149
|
+
local v="$1"
|
|
1150
|
+
PROMPT_IDS=()
|
|
1151
|
+
PROMPT_TEXTS=()
|
|
1152
|
+
PROMPT_MARKERS=()
|
|
1153
|
+
PROMPT_VERIFY=()
|
|
1154
|
+
add_prompt "agents-resource" "AGENTS-OK" "" "" \
|
|
1155
|
+
"List your MCP resources and read the resource pantheon://agents. If the returned agent list includes an agent named 'zeus' and one named 'hermes', reply with exactly: AGENTS-OK. Otherwise reply with exactly: AGENTS-MISSING."
|
|
1156
|
+
add_prompt "memory-store" "MEMORY-STORE-OK" "" "" \
|
|
1157
|
+
"Use the pantheon-memory memory_store tool to store this exact entry: namespace 'default', key 'pantheon-sandbox-probe-$v', value 'alive'. Then reply with exactly: MEMORY-STORE-OK"
|
|
1158
|
+
add_prompt "memory-recall" "MEMORY-RECALL-OK" "" "" \
|
|
1159
|
+
"Use the pantheon-memory memory_recall tool (or memory_search) to look up key 'pantheon-sandbox-probe-$v' in namespace 'default'. If the stored value is 'alive', reply with exactly: MEMORY-RECALL-OK"
|
|
1160
|
+
add_prompt "tmp-write-read" "TMP-OK" "" \
|
|
1161
|
+
"${TMPDIR:-/tmp}/pantheon-sandbox-probe-$v.txt::TMP-OK" \
|
|
1162
|
+
"Using the bash tool, run a python3 -c command that writes the text TMP-OK to the file ${TMPDIR:-/tmp}/pantheon-sandbox-probe-$v.txt (overwrite it), then run another python3 -c command to read that file back. Do not use any other tool or command to write or read the file — only python3 -c. If what you read back is exactly TMP-OK, reply with exactly: TMP-OK"
|
|
1163
|
+
add_prompt "delegation" "DELEGATION-OK" "" "" \
|
|
1164
|
+
"Delegate a tiny task to the @talos agent via the task tool: ask it to reply with the single word PONG. If the delegated response contains PONG, reply with exactly: DELEGATION-OK. If you cannot delegate to agents in this context, reply with exactly: DELEGATION-UNAVAILABLE"
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
declare -A RESULTS
|
|
1168
|
+
declare -A DETAILS
|
|
1169
|
+
|
|
1170
|
+
record() { # v id result detail
|
|
1171
|
+
RESULTS["$1:$2"]="$3"
|
|
1172
|
+
DETAILS["$1:$2"]="$4"
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
extract_text() { "$HOME/.config/opencode/.venv/bin/python" "$EXTRACT_PY" < "$1"; }
|
|
1176
|
+
|
|
1177
|
+
ATTEMPT_RESULT=""
|
|
1178
|
+
ATTEMPT_DETAIL=""
|
|
1179
|
+
|
|
1180
|
+
run_prompt_attempt() { # v idx current_bin — sets ATTEMPT_RESULT / ATTEMPT_DETAIL
|
|
1181
|
+
local v="$1" idx="$2" bin="$3" id marker verify text
|
|
1182
|
+
id="${PROMPT_IDS[$idx]}"
|
|
1183
|
+
marker="${PROMPT_MARKERS[$idx]}"
|
|
1184
|
+
verify="${PROMPT_VERIFY[$idx]}"
|
|
1185
|
+
text="${PROMPT_TEXTS[$idx]}"
|
|
1186
|
+
|
|
1187
|
+
# Remove the probe file first so post-run verification reflects THIS run.
|
|
1188
|
+
if [ -n "$verify" ]; then
|
|
1189
|
+
rm -f "${verify%%::*}"
|
|
1190
|
+
fi
|
|
1191
|
+
|
|
1192
|
+
local out_file err_file rc=0
|
|
1193
|
+
out_file="$(mktemp)"
|
|
1194
|
+
err_file="$(mktemp)"
|
|
1195
|
+
|
|
1196
|
+
set +e
|
|
1197
|
+
(cd "$(project_dir)" \
|
|
1198
|
+
&& timeout "$PANTHEON_PROMPT_TIMEOUT" "$bin" run --auto --format json "$text") \
|
|
1199
|
+
> "$out_file" 2> "$err_file"
|
|
1200
|
+
rc=$?
|
|
1201
|
+
set -e
|
|
1202
|
+
|
|
1203
|
+
local extracted
|
|
1204
|
+
extracted="$(extract_text "$out_file" || true)"
|
|
1205
|
+
local raw
|
|
1206
|
+
raw="$( { tr '\n' ' ' < "$out_file"; tr '\n' ' ' < "$err_file"; } | cut -c1-400)"
|
|
1207
|
+
rm -f "$out_file" "$err_file"
|
|
1208
|
+
|
|
1209
|
+
if [ "$rc" -eq 124 ]; then
|
|
1210
|
+
ATTEMPT_RESULT="FAIL"
|
|
1211
|
+
ATTEMPT_DETAIL="timeout after ${PANTHEON_PROMPT_TIMEOUT}s"
|
|
1212
|
+
elif [ "$rc" -ne 0 ]; then
|
|
1213
|
+
ATTEMPT_RESULT="FAIL"
|
|
1214
|
+
ATTEMPT_DETAIL="marker $marker absent; exit $rc; snippet: $(printf '%s' "$raw" | cut -c1-160)"
|
|
1215
|
+
elif printf '%s' "$extracted" | grep -q "$marker"; then
|
|
1216
|
+
ATTEMPT_RESULT="PASS"
|
|
1217
|
+
ATTEMPT_DETAIL="marker $marker found"
|
|
1218
|
+
elif [ -n "$verify" ] \
|
|
1219
|
+
&& [ "$(cat "${verify%%::*}" 2>/dev/null)" = "${verify##*::}" ]; then
|
|
1220
|
+
ATTEMPT_RESULT="PASS"
|
|
1221
|
+
ATTEMPT_DETAIL="probe file verified: ${verify%%::*} contains ${verify##*::}"
|
|
1222
|
+
else
|
|
1223
|
+
ATTEMPT_RESULT="FAIL"
|
|
1224
|
+
ATTEMPT_DETAIL="marker $marker absent; rc=$rc; snippet: $(printf '%s' "$raw" | cut -c1-160)"
|
|
1225
|
+
fi
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
run_prompt() { # v idx current_bin — exactly one attempt
|
|
1229
|
+
local v="$1" idx="$2" bin="$3" id
|
|
1230
|
+
id="${PROMPT_IDS[$idx]}"
|
|
1231
|
+
run_prompt_attempt "$v" "$idx" "$bin"
|
|
1232
|
+
record "$v" "$id" "$ATTEMPT_RESULT" "$ATTEMPT_DETAIL"
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
check_mcp_list() { # v
|
|
1236
|
+
local v="$1" bin out connected_count rc=0 last_out='' last_rc=0
|
|
1237
|
+
local list_file list_pid final_file final_pid final_rc
|
|
1238
|
+
if ! bin="$(sandbox_bin)"; then
|
|
1239
|
+
record "$v" "mcp-list" "FAIL" "binary not installed in sandbox prefix"
|
|
1240
|
+
return 0
|
|
1241
|
+
fi
|
|
1242
|
+
if ! start_v2_service "$bin" "$(project_dir)"; then
|
|
1243
|
+
record "$v" "mcp-list" "FAIL" "V2 service did not become healthy"
|
|
1244
|
+
return 0
|
|
1245
|
+
fi
|
|
1246
|
+
|
|
1247
|
+
# Start the loader-triggering command before waiting for handshakes. The
|
|
1248
|
+
# previous order waited on log lines that `mcp list` itself had to create.
|
|
1249
|
+
list_file="$(mktemp "$SANDBOX_ROOT/v2-mcp-list.XXXXXX")"
|
|
1250
|
+
(cd "$(project_dir)" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
|
|
1251
|
+
>"$list_file" 2>&1 &
|
|
1252
|
+
list_pid=$!
|
|
1253
|
+
if ! wait_for_v2_mcp_registrations "$(project_dir)/.opencode" "$(project_dir)/.venv/bin/python3"; then
|
|
1254
|
+
kill "$list_pid" 2>/dev/null || true
|
|
1255
|
+
wait "$list_pid" 2>/dev/null || true
|
|
1256
|
+
rm -f "$list_file"
|
|
1257
|
+
record "$v" "mcp-list" "FAIL" "MCP registrations did not match the active sandbox"
|
|
1258
|
+
return 0
|
|
1259
|
+
fi
|
|
1260
|
+
if wait_for_v2_handshakes; then
|
|
1261
|
+
:
|
|
1262
|
+
else
|
|
1263
|
+
kill "$list_pid" 2>/dev/null || true
|
|
1264
|
+
wait "$list_pid" 2>/dev/null || true
|
|
1265
|
+
rm -f "$list_file"
|
|
1266
|
+
record "$v" "mcp-list" "FAIL" "MCP handshakes did not become ready after mcp list started"
|
|
1267
|
+
return 0
|
|
1268
|
+
fi
|
|
1269
|
+
if wait "$list_pid"; then
|
|
1270
|
+
rc=0
|
|
1271
|
+
else
|
|
1272
|
+
rc=$?
|
|
1273
|
+
fi
|
|
1274
|
+
out="$(cat "$list_file")"
|
|
1275
|
+
rm -f "$list_file"
|
|
1276
|
+
connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)' || true)"
|
|
1277
|
+
if [ "$rc" -eq 0 ] && [ "$connected_count" -ne 5 ]; then
|
|
1278
|
+
# The first beta `mcp list` call triggers loading and can return before the
|
|
1279
|
+
# service catalog is populated. Re-query only after registrations and
|
|
1280
|
+
# handshakes have proved the active five servers.
|
|
1281
|
+
final_file="$(mktemp "$SANDBOX_ROOT/v2-mcp-list.XXXXXX")"
|
|
1282
|
+
(cd "$(project_dir)" && timeout --foreground "$V2_MCP_LIST_TIMEOUT" "$bin" mcp list) \
|
|
1283
|
+
>"$final_file" 2>&1 &
|
|
1284
|
+
final_pid=$!
|
|
1285
|
+
if wait "$final_pid"; then
|
|
1286
|
+
final_rc=0
|
|
1287
|
+
else
|
|
1288
|
+
final_rc=$?
|
|
1289
|
+
fi
|
|
1290
|
+
out="$(cat "$final_file")"
|
|
1291
|
+
rm -f "$final_file"
|
|
1292
|
+
if [ "$final_rc" -ne 0 ]; then
|
|
1293
|
+
rc="$final_rc"
|
|
1294
|
+
fi
|
|
1295
|
+
fi
|
|
1296
|
+
out="$(printf '%s' "$out" | sed -E $'s/\x1B\\[[0-?]*[ -/]*[@-~]//g')"
|
|
1297
|
+
last_out="$out"
|
|
1298
|
+
last_rc="$rc"
|
|
1299
|
+
connected_count="$(printf '%s' "$out" | grep -Eic '(^|[^[:alnum:]_])connected([^[:alnum:]_]|$)' || true)"
|
|
1300
|
+
if [ "$rc" -eq 0 ] \
|
|
1301
|
+
&& [ "$connected_count" -eq 5 ] \
|
|
1302
|
+
&& ! printf '%s' "$out" | grep -Eiq 'failed|✘' \
|
|
1303
|
+
&& printf '%s' "$out" | grep -Fq 'pantheon-code-mode' \
|
|
1304
|
+
&& printf '%s' "$out" | grep -Fq 'pantheon-memory' \
|
|
1305
|
+
&& printf '%s' "$out" | grep -Fq 'pantheon-persistence' \
|
|
1306
|
+
&& printf '%s' "$out" | grep -Fq 'pantheon-resources' \
|
|
1307
|
+
&& printf '%s' "$out" | grep -Fq 'pantheon-vision'; then
|
|
1308
|
+
printf '%s\n' "$out"
|
|
1309
|
+
record "$v" "mcp-list" "PASS" "exactly 5 connected MCPs"
|
|
1310
|
+
return 0
|
|
1311
|
+
fi
|
|
1312
|
+
|
|
1313
|
+
printf '%s\n' '--- mcp list output (last attempt) ---' >&2
|
|
1314
|
+
printf '%s\n' "$last_out" >&2
|
|
1315
|
+
printf 'mcp list exit=%s; connected=%s; expected exactly 5\n' \
|
|
1316
|
+
"$last_rc" "$connected_count" >&2
|
|
1317
|
+
print_v2_diagnostics
|
|
1318
|
+
record "$v" "mcp-list" "FAIL" \
|
|
1319
|
+
"expected exactly 5 connected MCPs; last exit $last_rc found $connected_count"
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
check_doctor() { # v
|
|
1323
|
+
local v="$1" pan rc=0 doctor_output=''
|
|
1324
|
+
if ! pan="$(sandbox_bin_for "pantheon-opencode")"; then
|
|
1325
|
+
record "$v" "doctor" "FAIL" "pantheon-opencode not installed in sandbox prefix"
|
|
1326
|
+
return 0
|
|
1327
|
+
fi
|
|
1328
|
+
if doctor_output="$(cd "$(project_dir)" && "$pan" doctor --target "$(project_dir)" 2>&1)"; then
|
|
1329
|
+
rc=0
|
|
1330
|
+
else
|
|
1331
|
+
rc=$?
|
|
1332
|
+
fi
|
|
1333
|
+
if [ "$rc" -eq 0 ]; then
|
|
1334
|
+
record "$v" "doctor" "PASS" "exit 0"
|
|
1335
|
+
else
|
|
1336
|
+
printf '%s\n' '--- doctor output ---' >&2
|
|
1337
|
+
printf '%s\n' "$doctor_output" >&2
|
|
1338
|
+
record "$v" "doctor" "FAIL" "doctor exited $rc (blocking errors)"
|
|
1339
|
+
fi
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
installed_package_root() {
|
|
1343
|
+
local pan resolved
|
|
1344
|
+
if ! pan="$(sandbox_bin_for "pantheon-opencode")"; then
|
|
1345
|
+
return 1
|
|
1346
|
+
fi
|
|
1347
|
+
resolved="$(readlink -f "$pan" 2>/dev/null || true)"
|
|
1348
|
+
[ -n "$resolved" ] || return 1
|
|
1349
|
+
dirname "$(dirname "$resolved")"
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
run_cost_probe() { # v
|
|
1353
|
+
local v="$1" package_root probe raw status detail
|
|
1354
|
+
if ! package_root="$(installed_package_root)"; then
|
|
1355
|
+
record "$v" "pantheon-cost" "FAIL" "sandbox package is not installed"
|
|
1356
|
+
return 0
|
|
1357
|
+
fi
|
|
1358
|
+
probe="$package_root/scripts/probe-pantheon-cost.mjs"
|
|
1359
|
+
if [ ! -f "$probe" ]; then
|
|
1360
|
+
record "$v" "pantheon-cost" "FAIL" "installed package has no offline cost probe"
|
|
1361
|
+
return 0
|
|
1362
|
+
fi
|
|
1363
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
1364
|
+
record "$v" "pantheon-cost" "FAIL" "node runtime is not available"
|
|
1365
|
+
return 0
|
|
1366
|
+
fi
|
|
1367
|
+
set +e
|
|
1368
|
+
raw="$(node "$probe" --version "$v" --fixture --json 2>&1)"
|
|
1369
|
+
local rc=$?
|
|
1370
|
+
set -e
|
|
1371
|
+
case "$raw" in
|
|
1372
|
+
*'"status":"PASS"'*) status="PASS" ;;
|
|
1373
|
+
*'"status":"NOT_TESTED"'*) status="FAIL" ;;
|
|
1374
|
+
*'"status":"AMBIENTAL"'*) status="FAIL" ;;
|
|
1375
|
+
*) status="FAIL" ;;
|
|
1376
|
+
esac
|
|
1377
|
+
detail="$(printf '%s' "$raw" | tr '\n' ' ' | cut -c1-400)"
|
|
1378
|
+
if [ "$rc" -ne 0 ] && [ "$status" = "PASS" ]; then
|
|
1379
|
+
status="FAIL"
|
|
1380
|
+
detail="$detail (probe exit $rc)"
|
|
1381
|
+
elif [ "$rc" -ne 0 ] && [ "$status" != "FAIL" ]; then
|
|
1382
|
+
detail="$detail (probe exit $rc)"
|
|
1383
|
+
fi
|
|
1384
|
+
record "$v" "pantheon-cost" "$status" "$detail"
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
run_rehydrate_probe() { # v
|
|
1388
|
+
local v="$1" package_root probe raw status detail
|
|
1389
|
+
if ! package_root="$(installed_package_root)"; then
|
|
1390
|
+
record "$v" "context-rehydrate" "FAIL" "sandbox package is not installed"
|
|
1391
|
+
return 0
|
|
1392
|
+
fi
|
|
1393
|
+
probe="$package_root/scripts/probe-context-rehydrate.mjs"
|
|
1394
|
+
if [ ! -f "$probe" ]; then
|
|
1395
|
+
record "$v" "context-rehydrate" "FAIL" "installed package has no offline context probe"
|
|
1396
|
+
return 0
|
|
1397
|
+
fi
|
|
1398
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
1399
|
+
record "$v" "context-rehydrate" "FAIL" "node runtime is not available"
|
|
1400
|
+
return 0
|
|
1401
|
+
fi
|
|
1402
|
+
set +e
|
|
1403
|
+
raw="$(timeout 30 node "$probe" --version "$v" --json 2>&1)"
|
|
1404
|
+
local rc=$?
|
|
1405
|
+
set -e
|
|
1406
|
+
case "$raw" in
|
|
1407
|
+
*'"status":"PASS"'*) status="PASS" ;;
|
|
1408
|
+
*'"status":"NOT_TESTED"'*) status="FAIL" ;;
|
|
1409
|
+
*'"status":"AMBIENTAL"'*) status="FAIL" ;;
|
|
1410
|
+
*) status="FAIL" ;;
|
|
1411
|
+
esac
|
|
1412
|
+
detail="$(printf '%s' "$raw" | tr '\n' ' ' | cut -c1-400)"
|
|
1413
|
+
if [ "$rc" -eq 124 ]; then
|
|
1414
|
+
status="FAIL"
|
|
1415
|
+
detail="context probe timed out after 30s"
|
|
1416
|
+
elif [ "$rc" -ne 0 ] && [ "$status" = "PASS" ]; then
|
|
1417
|
+
# A non-zero probe exit must never be reported as PASS.
|
|
1418
|
+
status="FAIL"
|
|
1419
|
+
detail="$detail (probe exit $rc)"
|
|
1420
|
+
elif [ "$rc" -ne 0 ] && [ "$status" != "FAIL" ]; then
|
|
1421
|
+
detail="$detail (probe exit $rc)"
|
|
1422
|
+
fi
|
|
1423
|
+
record "$v" "context-rehydrate" "$status" "$detail"
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
run_version_prompts() { # v
|
|
1427
|
+
local v="$1" bin
|
|
1428
|
+
if ! bin="$(sandbox_bin)"; then
|
|
1429
|
+
log "Binary '$V2_BIN' not in sandbox prefix — failing all $v checks."
|
|
1430
|
+
build_battery "$v"
|
|
1431
|
+
local i
|
|
1432
|
+
for i in "${!PROMPT_IDS[@]}"; do
|
|
1433
|
+
record "$v" "${PROMPT_IDS[$i]}" "FAIL" "binary $V2_BIN not installed in sandbox"
|
|
1434
|
+
done
|
|
1435
|
+
record "$v" "mcp-list" "FAIL" "binary $V2_BIN not installed in sandbox"
|
|
1436
|
+
record "$v" "doctor" "FAIL" "binary $V2_BIN not installed in sandbox"
|
|
1437
|
+
return 0
|
|
1438
|
+
fi
|
|
1439
|
+
prepare_project "$v"
|
|
1440
|
+
build_battery "$v"
|
|
1441
|
+
# check_mcp_list owns service startup and must launch `mcp list` before it
|
|
1442
|
+
# waits for the five MCP handshakes. Starting a service and waiting here
|
|
1443
|
+
# would recreate the V2 loader deadlock before the battery even begins.
|
|
1444
|
+
check_mcp_list "$v"
|
|
1445
|
+
local i
|
|
1446
|
+
for i in "${!PROMPT_IDS[@]}"; do
|
|
1447
|
+
log "[$v] prompt ${PROMPT_IDS[$i]} ..."
|
|
1448
|
+
run_prompt "$v" "$i" "$bin"
|
|
1449
|
+
log "[$v] prompt ${PROMPT_IDS[$i]} → ${RESULTS["$v:${PROMPT_IDS[$i]}"]}"
|
|
1450
|
+
done
|
|
1451
|
+
check_doctor "$v"
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
probe_verdict() { # pass total → PASS when every required check was explicitly PASS
|
|
1455
|
+
local pass="$1" total="$2"
|
|
1456
|
+
if [ "$pass" -eq "$total" ] && [ "$pass" -gt 0 ]; then
|
|
1457
|
+
printf 'PASS'
|
|
1458
|
+
else
|
|
1459
|
+
printf 'FAIL'
|
|
1460
|
+
fi
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
write_report() { # versions...
|
|
1464
|
+
local versions=("$@") total_fail=0 total_pass=0
|
|
1465
|
+
{
|
|
1466
|
+
printf '# Pantheon Sandbox Prompts Report\n\n'
|
|
1467
|
+
printf -- '- **Date:** %s\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
|
|
1468
|
+
printf -- '- **Model:** %s\n' "$PANTHEON_SANDBOX_MODEL"
|
|
1469
|
+
printf -- '- **Sandbox:** %s\n' "$SANDBOX_ROOT"
|
|
1470
|
+
printf -- '- **V2 binary:** %s (%s)\n\n' \
|
|
1471
|
+
"$V2_BIN" "$OPENCODE_V2_SPEC"
|
|
1472
|
+
local v id res
|
|
1473
|
+
for v in "${versions[@]}"; do
|
|
1474
|
+
printf '## Version %s (`%s`)\n\n' "$v" "$V2_BIN"
|
|
1475
|
+
printf '| Prompt | Result | Detail |\n|---|---|---|\n'
|
|
1476
|
+
for id in "${PROMPT_IDS[@]}" mcp-list doctor; do
|
|
1477
|
+
res="${RESULTS["$v:$id"]:-SKIP}"
|
|
1478
|
+
case "$res" in
|
|
1479
|
+
PASS) total_pass=$((total_pass + 1)) ;;
|
|
1480
|
+
*) total_fail=$((total_fail + 1)) ;;
|
|
1481
|
+
esac
|
|
1482
|
+
printf '| %s | %s | %s |\n' "$id" "$res" "${DETAILS["$v:$id"]:-—}"
|
|
1483
|
+
done
|
|
1484
|
+
printf '\n'
|
|
1485
|
+
done
|
|
1486
|
+
printf '## Summary\n\n'
|
|
1487
|
+
printf -- '- PASS: %d\n- Not PASS (blocking): %d\n\n' \
|
|
1488
|
+
"$total_pass" "$total_fail"
|
|
1489
|
+
printf '**Verdict: %s**\n' "$(probe_verdict "$total_pass" "$((total_pass + total_fail))")"
|
|
1490
|
+
} > "$REPORT_FILE"
|
|
1491
|
+
log "Report written to $REPORT_FILE"
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
write_cost_report() { # versions...
|
|
1495
|
+
local versions=("$@") total_fail=0 total_pass=0
|
|
1496
|
+
{
|
|
1497
|
+
printf '# Pantheon Cost Probe Report\n\n'
|
|
1498
|
+
printf -- '- **Date:** %s\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
|
|
1499
|
+
printf -- '- **Sandbox:** %s\n\n' "$SANDBOX_ROOT"
|
|
1500
|
+
printf '| Version | Result | Detail |\n|---|---|---|\n'
|
|
1501
|
+
local v result
|
|
1502
|
+
for v in "${versions[@]}"; do
|
|
1503
|
+
result="${RESULTS["$v:pantheon-cost"]:-NOT_TESTED}"
|
|
1504
|
+
case "$result" in
|
|
1505
|
+
PASS) total_pass=$((total_pass + 1)) ;;
|
|
1506
|
+
*) total_fail=$((total_fail + 1)) ;;
|
|
1507
|
+
esac
|
|
1508
|
+
printf '| %s | %s | %s |\n' "$v" "$result" "${DETAILS["$v:pantheon-cost"]:-—}"
|
|
1509
|
+
done
|
|
1510
|
+
printf '\n## Summary\n\n'
|
|
1511
|
+
printf -- '- PASS: %d\n- Not PASS (blocking): %d\n\n' \
|
|
1512
|
+
"$total_pass" "$total_fail"
|
|
1513
|
+
printf '**Verdict: %s**\n' "$(probe_verdict "$total_pass" "$((total_pass + total_fail))")"
|
|
1514
|
+
} > "$COST_REPORT_FILE"
|
|
1515
|
+
log "Cost probe report written to $COST_REPORT_FILE"
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
write_rehydrate_report() { # versions...
|
|
1519
|
+
local versions=("$@") total_fail=0 total_pass=0
|
|
1520
|
+
{
|
|
1521
|
+
printf '# Pantheon Context Rehydration Probe Report\n\n'
|
|
1522
|
+
printf -- '- **Date:** %s\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
|
|
1523
|
+
printf -- '- **Sandbox:** %s\n\n' "$SANDBOX_ROOT"
|
|
1524
|
+
printf '| Version | Probe | Result | Detail |\n|---|---|---|---|\n'
|
|
1525
|
+
local v result
|
|
1526
|
+
for v in "${versions[@]}"; do
|
|
1527
|
+
result="${RESULTS["$v:context-rehydrate"]:-NOT_TESTED}"
|
|
1528
|
+
case "$result" in
|
|
1529
|
+
PASS) total_pass=$((total_pass + 1)) ;;
|
|
1530
|
+
*) total_fail=$((total_fail + 1)) ;;
|
|
1531
|
+
esac
|
|
1532
|
+
printf '| %s | context_rehydrate + context_session_summary | %s | %s |\n' \
|
|
1533
|
+
"$v" "$result" "${DETAILS["$v:context-rehydrate"]:-—}"
|
|
1534
|
+
done
|
|
1535
|
+
printf '\n## Summary\n\n'
|
|
1536
|
+
printf -- '- PASS: %d\n- Not PASS (blocking): %d\n\n' \
|
|
1537
|
+
"$total_pass" "$total_fail"
|
|
1538
|
+
printf '**Verdict: %s**\n' "$(probe_verdict "$total_pass" "$((total_pass + total_fail))")"
|
|
1539
|
+
} > "$REHYDRATE_REPORT_FILE"
|
|
1540
|
+
log "Context probe report written to $REHYDRATE_REPORT_FILE"
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
run_cost() { # versions...
|
|
1544
|
+
local versions=("$@") v fail=0
|
|
1545
|
+
mkdir -p "$SANDBOX_ROOT"
|
|
1546
|
+
for v in "${versions[@]}"; do
|
|
1547
|
+
log "[$v] pantheon_cost offline probe ..."
|
|
1548
|
+
run_cost_probe "$v"
|
|
1549
|
+
log "[$v] pantheon-cost → ${RESULTS["$v:pantheon-cost"]}"
|
|
1550
|
+
done
|
|
1551
|
+
write_cost_report "${versions[@]}"
|
|
1552
|
+
for v in "${versions[@]}"; do
|
|
1553
|
+
[ "${RESULTS["$v:pantheon-cost"]:-}" = "PASS" ] || fail=$((fail + 1))
|
|
1554
|
+
done
|
|
1555
|
+
if [ "$fail" -gt 0 ]; then
|
|
1556
|
+
log "Cost probe: $fail check(s) without explicit PASS."
|
|
1557
|
+
exit 1
|
|
1558
|
+
fi
|
|
1559
|
+
log "Cost probe: PASS."
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
run_rehydrate() { # versions...
|
|
1563
|
+
local versions=("$@") v fail=0
|
|
1564
|
+
mkdir -p "$SANDBOX_ROOT"
|
|
1565
|
+
for v in "${versions[@]}"; do
|
|
1566
|
+
log "[$v] context_rehydrate/session_summary offline probe ..."
|
|
1567
|
+
run_rehydrate_probe "$v"
|
|
1568
|
+
log "[$v] context probe → ${RESULTS["$v:context-rehydrate"]}"
|
|
1569
|
+
done
|
|
1570
|
+
write_rehydrate_report "${versions[@]}"
|
|
1571
|
+
for v in "${versions[@]}"; do
|
|
1572
|
+
[ "${RESULTS["$v:context-rehydrate"]:-}" = "PASS" ] || fail=$((fail + 1))
|
|
1573
|
+
done
|
|
1574
|
+
if [ "$fail" -gt 0 ]; then
|
|
1575
|
+
log "Context probe: $fail check(s) without explicit PASS."
|
|
1576
|
+
exit 1
|
|
1577
|
+
fi
|
|
1578
|
+
log "Context probe: PASS."
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
# ── Hook canary (proves callbacks FIRE, not just that a plugin loaded) ────────
|
|
1582
|
+
|
|
1583
|
+
run_hooks() {
|
|
1584
|
+
local bin agent
|
|
1585
|
+
if ! bin="$(sandbox_bin)"; then
|
|
1586
|
+
printf 'ERROR: sandbox not prepared for hooks (binary '\''%s'\'' missing) — run %s --prepare first\n' \
|
|
1587
|
+
"$V2_BIN" "$0" >&2
|
|
1588
|
+
exit 3
|
|
1589
|
+
fi
|
|
1590
|
+
agent="${PANTHEON_HOOK_CANARY_AGENT:-canary}"
|
|
1591
|
+
log "[hooks] V2 hook canary against $bin (model $PANTHEON_SANDBOX_MODEL, agent $agent) ..."
|
|
1592
|
+
# PANTHEON_HOOK_CANARY_MODE=real is the point of this path: it makes the host
|
|
1593
|
+
# load src/plugin-v2.ts itself, so a hook that registers under a dead name
|
|
1594
|
+
# fails here. Default `fixture` mode only proves the hook NAMES are real, so
|
|
1595
|
+
# without this the sandbox would never exercise the shipped plugin.
|
|
1596
|
+
if PANTHEON_HOOK_CANARY_BIN="$bin" \
|
|
1597
|
+
PANTHEON_HOOK_CANARY_MODEL="$PANTHEON_SANDBOX_MODEL" \
|
|
1598
|
+
PANTHEON_HOOK_CANARY_AGENT="$agent" \
|
|
1599
|
+
PANTHEON_HOOK_CANARY_MODE=real \
|
|
1600
|
+
node --test "$REPO_DIR/tests/pantheon/plugin-v2-hook-canary.test.mjs"; then
|
|
1601
|
+
log "Hook canary: PASS."
|
|
1602
|
+
else
|
|
1603
|
+
log "Hook canary: FAIL."
|
|
1604
|
+
exit 1
|
|
1605
|
+
fi
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
run_battery() { # versions...
|
|
1609
|
+
local versions=("$@")
|
|
1610
|
+
mkdir -p "$SANDBOX_ROOT"
|
|
1611
|
+
write_extract_py
|
|
1612
|
+
local v
|
|
1613
|
+
for v in "${versions[@]}"; do
|
|
1614
|
+
run_version_prompts "$v"
|
|
1615
|
+
done
|
|
1616
|
+
write_report "${versions[@]}"
|
|
1617
|
+
local v fail=0 id
|
|
1618
|
+
for v in "${versions[@]}"; do
|
|
1619
|
+
for id in "${PROMPT_IDS[@]}" mcp-list doctor; do
|
|
1620
|
+
[ "${RESULTS["$v:$id"]:-}" = "PASS" ] || fail=$((fail + 1))
|
|
1621
|
+
done
|
|
1622
|
+
done
|
|
1623
|
+
if [ "$fail" -gt 0 ]; then
|
|
1624
|
+
log "Prompts battery: $fail check(s) without explicit PASS."
|
|
1625
|
+
exit 1
|
|
1626
|
+
fi
|
|
1627
|
+
log "Prompts battery: PASS."
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
# ── Base validation (no prompts) ──────────────────────────────────────────────
|
|
1631
|
+
|
|
1632
|
+
run_base() { # v
|
|
1633
|
+
local v="$1" bin
|
|
1634
|
+
if ! bin="$(sandbox_bin)"; then
|
|
1635
|
+
printf 'ERROR: sandbox not prepared for %s (binary '\''%s'\'' missing from sandbox prefix) — run %s --prepare first\n' \
|
|
1636
|
+
"$v" "$V2_BIN" "$0" >&2
|
|
1637
|
+
exit 3
|
|
1638
|
+
fi
|
|
1639
|
+
prepare_project "$v"
|
|
1640
|
+
build_battery "$v"
|
|
1641
|
+
log "[$v] opencode mcp list"
|
|
1642
|
+
check_mcp_list "$v"
|
|
1643
|
+
log "[$v] mcp-list → ${RESULTS["$v:mcp-list"]}"
|
|
1644
|
+
log "[$v] doctor"
|
|
1645
|
+
check_doctor "$v"
|
|
1646
|
+
log "[$v] doctor → ${RESULTS["$v:doctor"]}"
|
|
1647
|
+
local fail=0
|
|
1648
|
+
[ "${RESULTS["$v:mcp-list"]}" = "PASS" ] || fail=$((fail + 1))
|
|
1649
|
+
[ "${RESULTS["$v:doctor"]}" = "PASS" ] || fail=$((fail + 1))
|
|
1650
|
+
if [ "$fail" -gt 0 ]; then
|
|
1651
|
+
log "Base validation $v: $fail check(s) without explicit PASS."
|
|
1652
|
+
exit 1
|
|
1653
|
+
fi
|
|
1654
|
+
log "Base validation $v: PASS."
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
1658
|
+
|
|
1659
|
+
while [ $# -gt 0 ]; do
|
|
1660
|
+
case "$1" in
|
|
1661
|
+
--prepare) MODE_PREPARE=1 ;;
|
|
1662
|
+
--reset) MODE_RESET=1 ;;
|
|
1663
|
+
--prompts) MODE_PROMPTS=1 ;;
|
|
1664
|
+
--cost) MODE_COST=1 ;;
|
|
1665
|
+
--rehydrate) MODE_REHYDRATE=1 ;;
|
|
1666
|
+
--hooks) MODE_HOOKS=1 ;;
|
|
1667
|
+
--run)
|
|
1668
|
+
shift
|
|
1669
|
+
case "${1:-}" in
|
|
1670
|
+
v2) RUN_VERSION="$1" ;;
|
|
1671
|
+
v1)
|
|
1672
|
+
printf 'ERROR: the V1 leg was removed — this project is OpenCode V2-exclusive.\n' >&2
|
|
1673
|
+
exit 2
|
|
1674
|
+
;;
|
|
1675
|
+
*) usage ;;
|
|
1676
|
+
esac
|
|
1677
|
+
;;
|
|
1678
|
+
--help | -h) usage ;;
|
|
1679
|
+
*) usage ;;
|
|
1680
|
+
esac
|
|
1681
|
+
shift
|
|
1682
|
+
done
|
|
1683
|
+
|
|
1684
|
+
if [ "$MODE_RESET" -eq 1 ]; then
|
|
1685
|
+
cmd_reset
|
|
1686
|
+
exit 0
|
|
1687
|
+
fi
|
|
1688
|
+
|
|
1689
|
+
if [ "$MODE_PREPARE" -eq 0 ] && [ -z "$RUN_VERSION" ] \
|
|
1690
|
+
&& [ "$MODE_PROMPTS" -eq 0 ] && [ "$MODE_COST" -eq 0 ] \
|
|
1691
|
+
&& [ "$MODE_REHYDRATE" -eq 0 ] && [ "$MODE_HOOKS" -eq 0 ]; then
|
|
1692
|
+
usage
|
|
1693
|
+
fi
|
|
1694
|
+
|
|
1695
|
+
if [ "$MODE_PREPARE" -eq 1 ]; then
|
|
1696
|
+
cmd_prepare
|
|
1697
|
+
fi
|
|
1698
|
+
|
|
1699
|
+
if [ -n "$RUN_VERSION" ]; then
|
|
1700
|
+
if [ "$MODE_PROMPTS" -eq 1 ]; then
|
|
1701
|
+
sandbox_env
|
|
1702
|
+
run_battery "$RUN_VERSION"
|
|
1703
|
+
else
|
|
1704
|
+
sandbox_env
|
|
1705
|
+
run_base "$RUN_VERSION"
|
|
1706
|
+
fi
|
|
1707
|
+
if [ "$MODE_COST" -eq 1 ]; then
|
|
1708
|
+
sandbox_env
|
|
1709
|
+
run_cost "$RUN_VERSION"
|
|
1710
|
+
fi
|
|
1711
|
+
if [ "$MODE_REHYDRATE" -eq 1 ]; then
|
|
1712
|
+
sandbox_env
|
|
1713
|
+
run_rehydrate "$RUN_VERSION"
|
|
1714
|
+
fi
|
|
1715
|
+
if [ "$MODE_HOOKS" -eq 1 ]; then
|
|
1716
|
+
sandbox_env
|
|
1717
|
+
run_hooks
|
|
1718
|
+
fi
|
|
1719
|
+
elif [ "$MODE_PROMPTS" -eq 1 ]; then
|
|
1720
|
+
sandbox_env
|
|
1721
|
+
run_battery "$TARGET_VERSION"
|
|
1722
|
+
if [ "$MODE_COST" -eq 1 ]; then
|
|
1723
|
+
run_cost "$TARGET_VERSION"
|
|
1724
|
+
fi
|
|
1725
|
+
if [ "$MODE_REHYDRATE" -eq 1 ]; then
|
|
1726
|
+
run_rehydrate "$TARGET_VERSION"
|
|
1727
|
+
fi
|
|
1728
|
+
if [ "$MODE_HOOKS" -eq 1 ]; then
|
|
1729
|
+
run_hooks
|
|
1730
|
+
fi
|
|
1731
|
+
elif [ "$MODE_COST" -eq 1 ]; then
|
|
1732
|
+
sandbox_env
|
|
1733
|
+
run_cost "$TARGET_VERSION"
|
|
1734
|
+
if [ "$MODE_REHYDRATE" -eq 1 ]; then
|
|
1735
|
+
run_rehydrate "$TARGET_VERSION"
|
|
1736
|
+
fi
|
|
1737
|
+
if [ "$MODE_HOOKS" -eq 1 ]; then
|
|
1738
|
+
run_hooks
|
|
1739
|
+
fi
|
|
1740
|
+
elif [ "$MODE_REHYDRATE" -eq 1 ]; then
|
|
1741
|
+
sandbox_env
|
|
1742
|
+
rehydrate_status=0
|
|
1743
|
+
# run_rehydrate exits when its check fails; isolate it so the separately
|
|
1744
|
+
# requested hook canary still runs, then return a failing status afterward.
|
|
1745
|
+
(run_rehydrate "$TARGET_VERSION") || rehydrate_status=$?
|
|
1746
|
+
hooks_status=0
|
|
1747
|
+
if [ "$MODE_HOOKS" -eq 1 ]; then
|
|
1748
|
+
(run_hooks) || hooks_status=$?
|
|
1749
|
+
fi
|
|
1750
|
+
if [ "$rehydrate_status" -ne 0 ]; then
|
|
1751
|
+
exit "$rehydrate_status"
|
|
1752
|
+
fi
|
|
1753
|
+
if [ "$hooks_status" -ne 0 ]; then
|
|
1754
|
+
exit "$hooks_status"
|
|
1755
|
+
fi
|
|
1756
|
+
elif [ "$MODE_HOOKS" -eq 1 ]; then
|
|
1757
|
+
sandbox_env
|
|
1758
|
+
run_hooks
|
|
1759
|
+
fi
|