pantheon-opencode 1.6.0-beta.5 → 1.6.0-beta.7
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/AGENTS.md +27 -820
- package/CHANGELOG.md +130 -0
- package/README.md +205 -30
- package/README.pt-BR.md +47 -23
- package/package.json +10 -5
- package/plugin.json +1 -1
- package/pyproject.toml +3 -1
- package/scripts/build-agents-md.mjs +162 -28
- package/scripts/doctor.mjs +104 -22
- package/scripts/install/agents-md.mjs +2 -2
- package/scripts/install/config-migration.mjs +154 -7
- package/scripts/install/opencode.mjs +202 -40
- package/scripts/install/shared.mjs +115 -7
- package/scripts/install/strings.mjs +8 -0
- package/scripts/probe-pantheon-cost.mjs +16 -3
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +2 -2
- package/scripts/test-opencode-v2-sandbox.sh +820 -86
- package/skills-lock.json +6 -5
- package/src/agents/aphrodite.md +17 -28
- package/src/agents/athena.md +1 -0
- package/src/agents/demeter.md +8 -18
- package/src/agents/hephaestus.md +10 -11
- package/src/agents/hermes.md +15 -30
- package/src/agents/iris.md +1 -0
- package/src/agents/nyx.md +1 -0
- package/src/agents/prometheus.md +16 -10
- package/src/agents/talos.md +4 -1
- package/src/agents/themis.md +5 -3
- package/src/agents/zeus.md +17 -280
- package/src/instructions/agent-return-format.instructions.md +4 -50
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -121
- package/src/instructions/zeus-anti-stall.instructions.md +8 -111
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +9 -130
- package/src/pantheon/secret-scanner.ts +489 -0
- package/src/pantheon/v2-delegation-enforce.ts +86 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-unsupported.mjs +0 -23
- package/src/plugin-v2.ts +254 -102
- package/src/plugins/pantheon-hooks.ts +82 -35
- package/src/plugins/tui/README.md +14 -0
- package/src/plugins/tui/dist/tui.d.ts +11 -8
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +102 -62
- package/src/plugins/tui/package-lock.json +30 -33
- package/src/plugins/tui/package.json +5 -1
- package/src/plugins/tui/src/index.tsx +163 -107
- package/src/plugins/tui/tsdown.config.ts +17 -0
- package/src/routing.yml +12 -9
- package/src/skills/README.md +9 -4
- package/src/skills/agent-coordination/SKILL.md +13 -83
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +38 -28
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/council-synthesis/SKILL.md +29 -0
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/scripts/hooks/scan-secrets.sh +0 -97
- package/src/instructions/zeus-council-synthesis.instructions.md +0 -299
|
@@ -38,11 +38,17 @@
|
|
|
38
38
|
# install spec only — it is not a dependency of this
|
|
39
39
|
# package, and no code imports it.
|
|
40
40
|
# PANTHEON_REPO Repository the sandbox is built from (default: the
|
|
41
|
-
# checkout this script lives in).
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
41
|
+
# checkout this script lives in). Must be an existing
|
|
42
|
+
# Pantheon checkout; used consistently by prepare,
|
|
43
|
+
# generated run-test.sh and --reset guards.
|
|
44
|
+
# PANTHEON_V2_PORT Dedicated loopback port (default: 49376). The
|
|
45
|
+
# harness fails if it is invalid or already bound;
|
|
46
|
+
# it never stops or reuses an existing service.
|
|
47
|
+
# PANTHEON_V2_HOST IPv4 loopback IP for the V2 service (default:
|
|
48
|
+
# 127.0.0.1); non-loopback addresses are rejected.
|
|
49
|
+
# PANTHEON_V2_MCP_LIST_TIMEOUT
|
|
50
|
+
# Per-call `opencode mcp list` timeout in seconds
|
|
51
|
+
# (default: 15).
|
|
46
52
|
# PANTHEON_SANDBOX_MODEL Model used by init/prompts
|
|
47
53
|
# (default: opencode-go/mimo-v2.5)
|
|
48
54
|
# PANTHEON_PROMPT_TIMEOUT Per-prompt timeout in seconds (default: 300)
|
|
@@ -51,13 +57,14 @@
|
|
|
51
57
|
# 2 = usage error, 3 = sandbox not prepared.
|
|
52
58
|
set -euo pipefail
|
|
53
59
|
|
|
54
|
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
60
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
55
61
|
# Derived from this script's own location so the sandbox is always built from
|
|
56
62
|
# the checkout that CONTAINS this harness. It must never be inferred from a
|
|
57
63
|
# sibling directory: a sibling may be a different checkout (or a different
|
|
58
|
-
# branch) with unrelated uncommitted work
|
|
59
|
-
#
|
|
60
|
-
|
|
64
|
+
# branch) with unrelated uncommitted work. Package tarballs are written only to
|
|
65
|
+
# an isolated temporary directory inside the selected sandbox.
|
|
66
|
+
SCRIPT_REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
67
|
+
REPO_DIR="$SCRIPT_REPO_DIR"
|
|
61
68
|
|
|
62
69
|
SANDBOX_ROOT="${PANTHEON_SANDBOX_ROOT:-$HOME/pantheon-sandbox}"
|
|
63
70
|
SANDBOX_HOME="$SANDBOX_ROOT/home"
|
|
@@ -72,8 +79,11 @@ V2_BIN="${OPENCODE_V2_BIN:-opencode2}"
|
|
|
72
79
|
# OpenCode V2's CLI talks to its background service for `mcp list`. Keep the
|
|
73
80
|
# probe hermetic: an inherited config, database, or PORT can otherwise make
|
|
74
81
|
# the command query a different service and report a false result.
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
# These locations are intentionally fixed inside the sandbox. External config
|
|
83
|
+
# and database overrides could make prepare/run write outside the safe root.
|
|
84
|
+
V2_CONFIG="$SANDBOX_ROOT/project-v2/opencode.json"
|
|
85
|
+
V2_DB="$SANDBOX_ROOT/opencode-v2.db"
|
|
86
|
+
unset PANTHEON_V2_CONFIG PANTHEON_V2_DB
|
|
77
87
|
V2_PORT="${PANTHEON_V2_PORT:-49376}"
|
|
78
88
|
V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
|
|
79
89
|
V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
|
|
@@ -98,6 +108,7 @@ MODE_COST=0
|
|
|
98
108
|
MODE_REHYDRATE=0
|
|
99
109
|
MODE_HOOKS=0
|
|
100
110
|
RUN_VERSION=""
|
|
111
|
+
SANDBOX_ENV_READY=0
|
|
101
112
|
|
|
102
113
|
usage() {
|
|
103
114
|
sed -n '2,45p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
@@ -108,8 +119,60 @@ log() { printf '%s\n' "$*"; }
|
|
|
108
119
|
warn() { printf 'WARN: %s\n' "$*" >&2; }
|
|
109
120
|
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
|
110
121
|
|
|
122
|
+
resolve_harness_repo() {
|
|
123
|
+
local requested_repo="${PANTHEON_REPO:-$SCRIPT_REPO_DIR}"
|
|
124
|
+
[ -n "$requested_repo" ] \
|
|
125
|
+
|| die "PANTHEON_REPO must not be empty"
|
|
126
|
+
case "$requested_repo" in
|
|
127
|
+
/*) ;;
|
|
128
|
+
*) die "PANTHEON_REPO must be an absolute path (got: $requested_repo)" ;;
|
|
129
|
+
esac
|
|
130
|
+
[ -d "$requested_repo" ] \
|
|
131
|
+
|| die "PANTHEON_REPO path does not exist or is not a directory: $requested_repo"
|
|
132
|
+
REPO_DIR="$(cd -P -- "$requested_repo" 2>/dev/null && pwd -P)" \
|
|
133
|
+
|| die "cannot resolve PANTHEON_REPO path: $requested_repo"
|
|
134
|
+
[ -f "$REPO_DIR/package.json" ] \
|
|
135
|
+
|| die "PANTHEON_REPO is not a Pantheon package checkout (package.json missing): $REPO_DIR"
|
|
136
|
+
node -e 'if (require(process.argv[1]).name !== "pantheon-opencode") process.exit(1)' \
|
|
137
|
+
"$REPO_DIR/package.json" \
|
|
138
|
+
|| die "PANTHEON_REPO package name must be pantheon-opencode: $REPO_DIR"
|
|
139
|
+
}
|
|
140
|
+
|
|
111
141
|
project_dir() { printf '%s/project-%s' "$SANDBOX_ROOT" "$TARGET_VERSION"; }
|
|
112
142
|
|
|
143
|
+
validate_v2_port() {
|
|
144
|
+
local port_number
|
|
145
|
+
[[ "$V2_PORT" =~ ^[0-9]{1,5}$ ]] \
|
|
146
|
+
|| die "PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')"
|
|
147
|
+
port_number=$((10#$V2_PORT))
|
|
148
|
+
[ "$port_number" -ge 1 ] && [ "$port_number" -le 65535 ] \
|
|
149
|
+
|| die "PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')"
|
|
150
|
+
V2_PORT="$port_number"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
assert_v2_port_available() {
|
|
154
|
+
validate_v2_port
|
|
155
|
+
if ! python3 - "$V2_LOOPBACK_HOST" "$V2_PORT" <<'PY'
|
|
156
|
+
import ipaddress
|
|
157
|
+
import socket
|
|
158
|
+
import sys
|
|
159
|
+
|
|
160
|
+
host, raw_port = sys.argv[1], sys.argv[2]
|
|
161
|
+
try:
|
|
162
|
+
address = ipaddress.ip_address(host)
|
|
163
|
+
if not address.is_loopback or address.version != 4:
|
|
164
|
+
raise ValueError("host must be an IPv4 loopback IP address")
|
|
165
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
166
|
+
probe.bind((host, int(raw_port)))
|
|
167
|
+
except (OSError, ValueError) as exc:
|
|
168
|
+
print(f"cannot bind dedicated V2 port {host}:{raw_port}: {exc}", file=sys.stderr)
|
|
169
|
+
raise SystemExit(1)
|
|
170
|
+
PY
|
|
171
|
+
then
|
|
172
|
+
die "dedicated V2 port $V2_PORT is already in use or unavailable; refusing to reuse or stop an unrelated service"
|
|
173
|
+
fi
|
|
174
|
+
}
|
|
175
|
+
|
|
113
176
|
# Resolve a binary STRICTLY inside the sandbox npm prefix — never fall back to
|
|
114
177
|
# the dev PATH, otherwise a non-prepared sandbox would silently test the host
|
|
115
178
|
# installation.
|
|
@@ -129,10 +192,45 @@ sandbox_bin() { # echoes the V2 binary path; rc 1 if missing
|
|
|
129
192
|
# ── Sandbox environment isolation ─────────────────────────────────────────────
|
|
130
193
|
|
|
131
194
|
sandbox_env() {
|
|
195
|
+
# Combined modes can call this more than once in one shell. After the first
|
|
196
|
+
# setup, HOME intentionally lives below SANDBOX_ROOT; re-running the full
|
|
197
|
+
# overlap guard would mistake that supported isolation layout for an unsafe
|
|
198
|
+
# ancestor relationship.
|
|
199
|
+
if [ "$SANDBOX_ENV_READY" -eq 1 ]; then
|
|
200
|
+
return 0
|
|
201
|
+
fi
|
|
202
|
+
|
|
203
|
+
guard_sandbox_root use
|
|
204
|
+
canonicalize_sandbox_root
|
|
205
|
+
validate_v2_port
|
|
206
|
+
mkdir -p -- "$SANDBOX_ROOT"
|
|
207
|
+
sandbox_path_guard "$SANDBOX_ROOT" dir-check
|
|
208
|
+
sandbox_path_guard "$SANDBOX_HOME" dir-create
|
|
209
|
+
sandbox_path_guard "$NPM_PREFIX" dir-create
|
|
210
|
+
sandbox_path_guard "$SANDBOX_HOME/.npm-cache" dir-create
|
|
211
|
+
sandbox_path_guard "$SANDBOX_HOME/.config" dir-create
|
|
212
|
+
sandbox_path_guard "$SANDBOX_HOME/.config/opencode" dir-create
|
|
213
|
+
sandbox_path_guard "$SANDBOX_HOME/.config/xdg" dir-create
|
|
214
|
+
sandbox_path_guard "$SANDBOX_HOME/.local/share" dir-create
|
|
215
|
+
sandbox_path_guard "$SANDBOX_HOME/.local/share/opencode" dir-create
|
|
216
|
+
sandbox_path_guard "$SANDBOX_HOME/.local/share/xdg" dir-create
|
|
217
|
+
sandbox_path_guard "$SANDBOX_HOME/.local/state" dir-create
|
|
218
|
+
sandbox_path_guard "$SANDBOX_HOME/.local/state/opencode" dir-create
|
|
219
|
+
sandbox_path_guard "$SANDBOX_HOME/.cache" dir-create
|
|
220
|
+
sandbox_path_guard "$SANDBOX_HOME/.cache/opencode" dir-create
|
|
221
|
+
sandbox_path_guard "$SANDBOX_HOME/.opencode" dir-create
|
|
222
|
+
sandbox_path_guard "$V2_CONFIG" file-allow-missing-parent
|
|
223
|
+
sandbox_path_guard "$V2_DB" file-allow-missing-parent
|
|
132
224
|
export HOME="$SANDBOX_HOME"
|
|
133
225
|
export PATH="$NPM_PREFIX/bin:$HOME/.config/opencode/.venv/bin:$PATH"
|
|
134
226
|
export npm_config_prefix="$NPM_PREFIX"
|
|
227
|
+
export npm_config_cache="$SANDBOX_HOME/.npm-cache"
|
|
228
|
+
export XDG_CONFIG_HOME="$SANDBOX_HOME/.config"
|
|
229
|
+
export XDG_CONFIG_DIRS="$SANDBOX_HOME/.config/xdg"
|
|
230
|
+
export XDG_DATA_HOME="$SANDBOX_HOME/.local/share"
|
|
231
|
+
export XDG_DATA_DIRS="$SANDBOX_HOME/.local/share/xdg"
|
|
135
232
|
export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
|
|
233
|
+
export XDG_CACHE_HOME="$SANDBOX_HOME/.cache"
|
|
136
234
|
# Do not inherit an OpenChamber/developer-machine config selector. The V2
|
|
137
235
|
# project config is created by prepare_project and is the only config this
|
|
138
236
|
# gate is allowed to inspect.
|
|
@@ -140,8 +238,11 @@ sandbox_env() {
|
|
|
140
238
|
export OPENCODE_DB="$V2_DB"
|
|
141
239
|
export PANTHEON_V2_PORT="$V2_PORT"
|
|
142
240
|
export PORT="$V2_PORT"
|
|
143
|
-
unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE
|
|
144
|
-
|
|
241
|
+
unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE \
|
|
242
|
+
OPENCODE_DATA_DIR OPENCODE_STATE_DIR OPENCODE_CACHE_DIR OPENCODE_STORAGE_PATH
|
|
243
|
+
sandbox_path_guard "$V2_SERVICE_STATE" file-allow-missing-parent
|
|
244
|
+
sandbox_path_guard "$(dirname "$V2_SERVICE_STATE")" dir-create
|
|
245
|
+
SANDBOX_ENV_READY=1
|
|
145
246
|
}
|
|
146
247
|
|
|
147
248
|
stop_v2_service() {
|
|
@@ -330,6 +431,7 @@ start_v2_service() { # binary project
|
|
|
330
431
|
if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
331
432
|
return 0
|
|
332
433
|
fi
|
|
434
|
+
validate_v2_port
|
|
333
435
|
|
|
334
436
|
# Never attach to an unrelated service on the dedicated port. A successful
|
|
335
437
|
# health probe before our process starts is an environmental collision, not
|
|
@@ -338,6 +440,7 @@ start_v2_service() { # binary project
|
|
|
338
440
|
"http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
|
|
339
441
|
die "dedicated V2 port ${V2_PORT} is already in use; refusing to reuse an unrelated service"
|
|
340
442
|
fi
|
|
443
|
+
assert_v2_port_available
|
|
341
444
|
|
|
342
445
|
rm -f "$V2_SERVER_LOG" "$V2_SERVICE_STATE" "$HOME/.local/share/opencode/log/opencode.log"
|
|
343
446
|
(cd "$project" && "$bin" serve --hostname "$V2_LOOPBACK_HOST" --port "$V2_PORT" --service) \
|
|
@@ -372,61 +475,221 @@ trap stop_v2_service EXIT
|
|
|
372
475
|
|
|
373
476
|
# ── Gate (b): sandbox run-test.sh with pantheon://agents content validation ──
|
|
374
477
|
|
|
478
|
+
# Node's compile cache is disposable, but tmp/ can also contain runtime state
|
|
479
|
+
# and human handoffs. Expire only hash-named cache files owned by this user after
|
|
480
|
+
# 30 days; never recurse through links or prune any other tmp/ entry.
|
|
481
|
+
cleanup_sandbox_tmp() {
|
|
482
|
+
local cache_dir="$SANDBOX_ROOT/tmp/node-compile-cache" cache_file cache_name owner
|
|
483
|
+
[ -d "$SANDBOX_ROOT/tmp" ] && [ ! -L "$SANDBOX_ROOT/tmp" ] || return 0
|
|
484
|
+
[ -d "$cache_dir" ] && [ ! -L "$cache_dir" ] || return 0
|
|
485
|
+
owner="$(id -un)" || return 0
|
|
486
|
+
while IFS= read -r -d '' cache_file; do
|
|
487
|
+
cache_name="${cache_file##*/}"
|
|
488
|
+
if [[ "$cache_name" =~ ^[[:xdigit:]]{8}$ ]]; then
|
|
489
|
+
rm -f -- "$cache_file"
|
|
490
|
+
fi
|
|
491
|
+
done < <(find "$cache_dir" -xdev -type f -user "$owner" -mtime +30 -print0)
|
|
492
|
+
}
|
|
493
|
+
|
|
375
494
|
write_run_test_sh() {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
# the
|
|
382
|
-
#
|
|
383
|
-
|
|
384
|
-
|
|
495
|
+
canonicalize_sandbox_root
|
|
496
|
+
mkdir -p -- "$SANDBOX_ROOT"
|
|
497
|
+
sandbox_path_guard "$SANDBOX_ROOT" dir-check
|
|
498
|
+
sandbox_path_guard "$SANDBOX_ROOT/.repo-dir" replace-file
|
|
499
|
+
sandbox_path_guard "$SANDBOX_ROOT/run-test.sh" replace-file
|
|
500
|
+
# Keep the path in a regular data file instead of shell source. Write it
|
|
501
|
+
# atomically so an interrupted prepare cannot leave a truncated .repo-dir.
|
|
502
|
+
local repo_file_tmp run_test_tmp
|
|
503
|
+
repo_file_tmp="$(mktemp "$SANDBOX_ROOT/.repo-dir.XXXXXX")" \
|
|
504
|
+
|| die "cannot create temporary .repo-dir"
|
|
505
|
+
if ! printf '%s\n' "$REPO_DIR" > "$repo_file_tmp"; then
|
|
506
|
+
rm -f -- "$repo_file_tmp"
|
|
507
|
+
die "cannot write temporary .repo-dir"
|
|
508
|
+
fi
|
|
509
|
+
atomic_replace_file "$repo_file_tmp" "$SANDBOX_ROOT/.repo-dir" 0600 \
|
|
510
|
+
|| die "cannot atomically replace .repo-dir"
|
|
511
|
+
run_test_tmp="$(mktemp "$SANDBOX_ROOT/.run-test.sh.XXXXXX")" \
|
|
512
|
+
|| die "cannot create temporary run-test.sh"
|
|
513
|
+
if ! cat > "$run_test_tmp" <<'RUNTEST'
|
|
385
514
|
#!/usr/bin/env bash
|
|
386
515
|
# Generated by pantheon-opencode scripts/test-opencode-v2-sandbox.sh --prepare
|
|
387
516
|
# Validates the global install: the V2 binary, exactly 5 connected MCPs, doctor
|
|
388
517
|
# 0 errors, AND the CONTENT of pantheon://agents (zeus/hermes present).
|
|
389
518
|
set -euo pipefail
|
|
390
519
|
|
|
391
|
-
SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
520
|
+
SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
392
521
|
SANDBOX_HOME="$SANDBOX_DIR/home"
|
|
393
522
|
NPM_PREFIX="$SANDBOX_HOME/.npm-global"
|
|
394
523
|
PANTHEON_GLOBAL="$SANDBOX_HOME/.config/opencode"
|
|
395
524
|
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
525
|
export HOME="$SANDBOX_HOME"
|
|
411
526
|
export PATH="$NPM_PREFIX/bin:$SANDBOX_VENV/bin:$PATH"
|
|
412
527
|
export npm_config_prefix="$NPM_PREFIX"
|
|
528
|
+
export npm_config_cache="$SANDBOX_HOME/.npm-cache"
|
|
529
|
+
export XDG_CONFIG_HOME="$SANDBOX_HOME/.config"
|
|
530
|
+
export XDG_CONFIG_DIRS="$SANDBOX_HOME/.config/xdg"
|
|
531
|
+
export XDG_DATA_HOME="$SANDBOX_HOME/.local/share"
|
|
532
|
+
export XDG_DATA_DIRS="$SANDBOX_HOME/.local/share/xdg"
|
|
413
533
|
export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
|
|
534
|
+
export XDG_CACHE_HOME="$SANDBOX_HOME/.cache"
|
|
414
535
|
# The V2 CLI resolves `mcp list` through its service. Keep generated sandbox
|
|
415
536
|
# runs isolated from any config/database/port inherited from the host.
|
|
416
537
|
V2_CONFIG="$SANDBOX_DIR/project-v2/opencode.json"
|
|
417
|
-
V2_DB="$
|
|
538
|
+
V2_DB="$SANDBOX_DIR/opencode-v2.db"
|
|
418
539
|
V2_PORT="${PANTHEON_V2_PORT:-49376}"
|
|
419
540
|
V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
|
|
420
541
|
V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
|
|
542
|
+
V2_MCP_LIST_TIMEOUT="${PANTHEON_V2_MCP_LIST_TIMEOUT:-15}"
|
|
421
543
|
V2_SERVER_LOG="$SANDBOX_DIR/v2-server.log"
|
|
422
544
|
V2_SERVICE_STATE="$SANDBOX_HOME/.local/state/opencode/service.json"
|
|
423
545
|
V2_SERVER_PID=""
|
|
546
|
+
|
|
547
|
+
resolve_sandbox_repo() {
|
|
548
|
+
local requested_repo resolved_repo repo_file
|
|
549
|
+
if [ -n "${PANTHEON_REPO:-}" ]; then
|
|
550
|
+
requested_repo="$PANTHEON_REPO"
|
|
551
|
+
else
|
|
552
|
+
repo_file="$SANDBOX_DIR/.repo-dir"
|
|
553
|
+
[ -f "$repo_file" ] && [ ! -L "$repo_file" ] \
|
|
554
|
+
|| { printf 'ERROR: sandbox .repo-dir is missing or not a regular file: %s\n' "$repo_file" >&2; return 1; }
|
|
555
|
+
IFS= read -r requested_repo < "$repo_file" \
|
|
556
|
+
|| { printf 'ERROR: sandbox .repo-dir is empty or unreadable: %s\n' "$repo_file" >&2; return 1; }
|
|
557
|
+
fi
|
|
558
|
+
[ -n "$requested_repo" ] \
|
|
559
|
+
|| { printf 'ERROR: repository path is empty (PANTHEON_REPO/.repo-dir)\n' >&2; return 1; }
|
|
560
|
+
case "$requested_repo" in
|
|
561
|
+
/*) ;;
|
|
562
|
+
*) printf 'ERROR: repository path must be absolute: %s\n' "$requested_repo" >&2; return 1 ;;
|
|
563
|
+
esac
|
|
564
|
+
[ -d "$requested_repo" ] \
|
|
565
|
+
|| { printf 'ERROR: repository path does not exist or is not a directory: %s\n' "$requested_repo" >&2; return 1; }
|
|
566
|
+
resolved_repo="$(cd -P -- "$requested_repo" 2>/dev/null && pwd -P)" \
|
|
567
|
+
|| { printf 'ERROR: cannot resolve repository path: %s\n' "$requested_repo" >&2; return 1; }
|
|
568
|
+
[ -f "$resolved_repo/package.json" ] \
|
|
569
|
+
|| { printf 'ERROR: repository package.json is missing: %s\n' "$resolved_repo" >&2; return 1; }
|
|
570
|
+
node -e 'if (require(process.argv[1]).name !== "pantheon-opencode") process.exit(1)' \
|
|
571
|
+
"$resolved_repo/package.json" \
|
|
572
|
+
|| { printf 'ERROR: repository package name must be pantheon-opencode: %s\n' "$resolved_repo" >&2; return 1; }
|
|
573
|
+
printf '%s\n' "$resolved_repo"
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
path_is_same_or_descendant() {
|
|
577
|
+
local candidate="$1" parent="$2"
|
|
578
|
+
[ "$candidate" = "$parent" ] && return 0
|
|
579
|
+
[ "$parent" = "/" ] && [[ "$candidate" = /* ]] && return 0
|
|
580
|
+
case "$candidate" in "$parent"/*) return 0 ;; esac
|
|
581
|
+
return 1
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
validate_v2_port() {
|
|
585
|
+
local port_number
|
|
586
|
+
[[ "$V2_PORT" =~ ^[0-9]{1,5}$ ]] \
|
|
587
|
+
|| { printf "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '%s')\n" "$V2_PORT" >&2; return 1; }
|
|
588
|
+
port_number=$((10#$V2_PORT))
|
|
589
|
+
[ "$port_number" -ge 1 ] && [ "$port_number" -le 65535 ] \
|
|
590
|
+
|| { printf "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '%s')\n" "$V2_PORT" >&2; return 1; }
|
|
591
|
+
V2_PORT="$port_number"
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
assert_v2_port_available() {
|
|
595
|
+
validate_v2_port || return 1
|
|
596
|
+
python3 - "$V2_LOOPBACK_HOST" "$V2_PORT" <<'PY'
|
|
597
|
+
import ipaddress
|
|
598
|
+
import socket
|
|
599
|
+
import sys
|
|
600
|
+
|
|
601
|
+
host, raw_port = sys.argv[1], sys.argv[2]
|
|
602
|
+
try:
|
|
603
|
+
address = ipaddress.ip_address(host)
|
|
604
|
+
if not address.is_loopback or address.version != 4:
|
|
605
|
+
raise ValueError("host must be an IPv4 loopback IP address")
|
|
606
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
607
|
+
probe.bind((host, int(raw_port)))
|
|
608
|
+
except (OSError, ValueError) as exc:
|
|
609
|
+
print(f"ERROR: dedicated V2 port {host}:{raw_port} is already in use or unavailable; refusing to reuse or stop an unrelated service: {exc}", file=sys.stderr)
|
|
610
|
+
raise SystemExit(1)
|
|
611
|
+
PY
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
REPO_DIR="$(resolve_sandbox_repo)" || exit 1
|
|
615
|
+
if path_is_same_or_descendant "$SANDBOX_DIR" "$REPO_DIR" \
|
|
616
|
+
|| path_is_same_or_descendant "$REPO_DIR" "$SANDBOX_DIR"; then
|
|
617
|
+
printf 'ERROR: sandbox path overlaps the selected repository; refusing to package: sandbox=%s repo=%s\n' \
|
|
618
|
+
"$SANDBOX_DIR" "$REPO_DIR" >&2
|
|
619
|
+
exit 1
|
|
620
|
+
fi
|
|
621
|
+
validate_v2_port || exit 1
|
|
622
|
+
echo "=== Pantheon Sandbox Test (OpenCode V2) ==="
|
|
623
|
+
echo "Sandbox: $SANDBOX_DIR"
|
|
624
|
+
echo "Repo: $REPO_DIR"
|
|
625
|
+
|
|
424
626
|
export OPENCODE_CONFIG_DIR="$(dirname "$V2_CONFIG")"
|
|
425
627
|
export OPENCODE_DB="$V2_DB"
|
|
426
628
|
export PANTHEON_V2_PORT="$V2_PORT"
|
|
427
629
|
export PORT="$V2_PORT"
|
|
428
|
-
unset
|
|
429
|
-
|
|
630
|
+
unset PANTHEON_V2_CONFIG PANTHEON_V2_DB
|
|
631
|
+
unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE \
|
|
632
|
+
OPENCODE_DATA_DIR OPENCODE_STATE_DIR OPENCODE_CACHE_DIR OPENCODE_STORAGE_PATH
|
|
633
|
+
|
|
634
|
+
validate_sandbox_paths() {
|
|
635
|
+
SANDBOX_DIR="$SANDBOX_DIR" python3 - <<'PY'
|
|
636
|
+
import os
|
|
637
|
+
import stat
|
|
638
|
+
import sys
|
|
639
|
+
from pathlib import Path
|
|
640
|
+
|
|
641
|
+
root = Path(os.environ["SANDBOX_DIR"])
|
|
642
|
+
if root.resolve(strict=True) != root:
|
|
643
|
+
raise SystemExit(f"ERROR: sandbox root is not canonical: {root}")
|
|
644
|
+
|
|
645
|
+
def validate(path, directory):
|
|
646
|
+
try:
|
|
647
|
+
relative = path.relative_to(root)
|
|
648
|
+
except ValueError:
|
|
649
|
+
raise SystemExit(f"ERROR: sandbox path escaped root: {path}")
|
|
650
|
+
if any(part in ("", ".", "..") for part in relative.parts):
|
|
651
|
+
raise SystemExit(f"ERROR: unsafe sandbox path component: {path}")
|
|
652
|
+
current = root
|
|
653
|
+
for index, part in enumerate(relative.parts):
|
|
654
|
+
current = current / part
|
|
655
|
+
try:
|
|
656
|
+
info = current.lstat()
|
|
657
|
+
except FileNotFoundError:
|
|
658
|
+
return
|
|
659
|
+
if stat.S_ISLNK(info.st_mode):
|
|
660
|
+
raise SystemExit(f"ERROR: sandbox path contains symlink: {current}")
|
|
661
|
+
is_final = index == len(relative.parts) - 1
|
|
662
|
+
if (not is_final or directory) and not stat.S_ISDIR(info.st_mode):
|
|
663
|
+
raise SystemExit(f"ERROR: sandbox path component is not a directory: {current}")
|
|
664
|
+
if is_final and not directory and not stat.S_ISREG(info.st_mode):
|
|
665
|
+
raise SystemExit(f"ERROR: sandbox output is not a regular file: {current}")
|
|
666
|
+
if current.resolve(strict=True) != current:
|
|
667
|
+
raise SystemExit(f"ERROR: sandbox path resolves outside its lexical location: {current}")
|
|
668
|
+
|
|
669
|
+
for directory in (
|
|
670
|
+
root / "home",
|
|
671
|
+
root / "home/.npm-cache",
|
|
672
|
+
root / "home/.config",
|
|
673
|
+
root / "home/.config/opencode",
|
|
674
|
+
root / "home/.config/xdg",
|
|
675
|
+
root / "home/.local/share",
|
|
676
|
+
root / "home/.local/share/opencode",
|
|
677
|
+
root / "home/.local/share/xdg",
|
|
678
|
+
root / "home/.local/state",
|
|
679
|
+
root / "home/.local/state/opencode",
|
|
680
|
+
root / "home/.cache",
|
|
681
|
+
root / "home/.cache/opencode",
|
|
682
|
+
root / "home/.opencode",
|
|
683
|
+
root / "project-v2",
|
|
684
|
+
):
|
|
685
|
+
validate(directory, True)
|
|
686
|
+
for output in (root / "project-v2/opencode.json", root / "opencode-v2.db"):
|
|
687
|
+
validate(output, False)
|
|
688
|
+
PY
|
|
689
|
+
}
|
|
690
|
+
validate_sandbox_paths
|
|
691
|
+
sandbox_path_parent="$(dirname "$V2_SERVICE_STATE")"
|
|
692
|
+
mkdir -p "$sandbox_path_parent"
|
|
430
693
|
|
|
431
694
|
stop_v2_service() {
|
|
432
695
|
if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
@@ -469,6 +732,8 @@ wait_for_v2_service_registration() {
|
|
|
469
732
|
python3 - <<'PY'
|
|
470
733
|
import json
|
|
471
734
|
import os
|
|
735
|
+
import stat
|
|
736
|
+
import tempfile
|
|
472
737
|
from pathlib import Path
|
|
473
738
|
|
|
474
739
|
path = Path(os.environ["SERVICE_STATE"])
|
|
@@ -558,11 +823,13 @@ start_v2_service() {
|
|
|
558
823
|
if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
|
|
559
824
|
return 0
|
|
560
825
|
fi
|
|
826
|
+
validate_v2_port || return 1
|
|
561
827
|
if curl --fail --silent --show-error --max-time 1 \
|
|
562
828
|
"http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
|
|
563
829
|
echo "ERROR: dedicated V2 port $V2_PORT is already in use" >&2
|
|
564
830
|
return 1
|
|
565
831
|
fi
|
|
832
|
+
assert_v2_port_available || return 1
|
|
566
833
|
rm -f "$V2_SERVER_LOG" "$V2_SERVICE_STATE" "$HOME/.local/share/opencode/log/opencode.log"
|
|
567
834
|
(cd "$project" && "$bin" serve --hostname "$V2_LOOPBACK_HOST" --port "$V2_PORT" --service) \
|
|
568
835
|
>"$V2_SERVER_LOG" 2>&1 &
|
|
@@ -636,6 +903,8 @@ rewrite_v2_mcp_config() {
|
|
|
636
903
|
python3 - <<'PY'
|
|
637
904
|
import json
|
|
638
905
|
import os
|
|
906
|
+
import stat
|
|
907
|
+
import tempfile
|
|
639
908
|
from pathlib import Path
|
|
640
909
|
|
|
641
910
|
config_path = Path(os.environ["CONFIG_PATH"])
|
|
@@ -662,9 +931,20 @@ for name, script in managed.items():
|
|
|
662
931
|
entry.pop("disabled", None)
|
|
663
932
|
entry.update(type="local", cwd=runtime_dir, command=[python_bin, f"scripts/{script}"], enabled=True)
|
|
664
933
|
mcp[name] = entry
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
934
|
+
temp_fd, temp_name = tempfile.mkstemp(
|
|
935
|
+
prefix=f".{config_path.name}.", suffix=".tmp", dir=config_path.parent
|
|
936
|
+
)
|
|
937
|
+
tmp_path = Path(temp_name)
|
|
938
|
+
try:
|
|
939
|
+
with os.fdopen(temp_fd, "w", encoding="utf-8") as stream:
|
|
940
|
+
stream.write(json.dumps(config, indent=2) + "\n")
|
|
941
|
+
os.chmod(tmp_path, stat.S_IMODE(config_path.stat().st_mode))
|
|
942
|
+
os.replace(tmp_path, config_path)
|
|
943
|
+
finally:
|
|
944
|
+
try:
|
|
945
|
+
tmp_path.unlink()
|
|
946
|
+
except FileNotFoundError:
|
|
947
|
+
pass
|
|
668
948
|
validated = json.loads(config_path.read_text(encoding="utf-8"))
|
|
669
949
|
for name, script in managed.items():
|
|
670
950
|
entry = validated.get("mcp", {}).get(name)
|
|
@@ -798,13 +1078,40 @@ check_mcp() { # label binary project
|
|
|
798
1078
|
echo "--- Binaries ---"
|
|
799
1079
|
check_binary "V2 binary" opencode2
|
|
800
1080
|
|
|
1081
|
+
install_pantheon_package() {
|
|
1082
|
+
local pack_dir pack_json filename tgz
|
|
1083
|
+
pack_dir="$(mktemp -d "$SANDBOX_DIR/.pantheon-npm-pack.XXXXXX")" \
|
|
1084
|
+
|| { echo "ERROR: cannot create isolated npm pack directory" >&2; exit 1; }
|
|
1085
|
+
pack_json="$pack_dir/pack.json"
|
|
1086
|
+
if ! (cd "$REPO_DIR" && npm pack --ignore-scripts --json --pack-destination "$pack_dir") > "$pack_json"; then
|
|
1087
|
+
find "$pack_dir" -maxdepth 1 -type f -name 'pantheon-opencode-*.tgz' -delete
|
|
1088
|
+
rm -f -- "$pack_json"
|
|
1089
|
+
rmdir -- "$pack_dir" 2>/dev/null || true
|
|
1090
|
+
echo "ERROR: npm pack failed" >&2
|
|
1091
|
+
exit 1
|
|
1092
|
+
fi
|
|
1093
|
+
if ! filename="$(node -e 'const p=JSON.parse(require("fs").readFileSync(0,"utf8")); if (!Array.isArray(p) || p.length !== 1 || typeof p[0].filename !== "string") process.exit(1); process.stdout.write(p[0].filename)' < "$pack_json")" \
|
|
1094
|
+
|| [[ "$filename" != pantheon-opencode-*.tgz || "$filename" == */* || ! -f "$pack_dir/$filename" ]]; then
|
|
1095
|
+
find "$pack_dir" -maxdepth 1 -type f -name 'pantheon-opencode-*.tgz' -delete
|
|
1096
|
+
rm -f -- "$pack_json"
|
|
1097
|
+
rmdir -- "$pack_dir" 2>/dev/null || true
|
|
1098
|
+
echo "ERROR: npm pack returned an invalid tarball filename" >&2
|
|
1099
|
+
exit 1
|
|
1100
|
+
fi
|
|
1101
|
+
tgz="$pack_dir/$filename"
|
|
1102
|
+
npm rm -g pantheon-opencode 2>/dev/null || true
|
|
1103
|
+
if ! npm install -g "$tgz"; then
|
|
1104
|
+
rm -f -- "$tgz" "$pack_json"
|
|
1105
|
+
rmdir -- "$pack_dir" 2>/dev/null || true
|
|
1106
|
+
echo "ERROR: install of isolated tarball failed: $tgz" >&2
|
|
1107
|
+
exit 1
|
|
1108
|
+
fi
|
|
1109
|
+
rm -f -- "$tgz" "$pack_json"
|
|
1110
|
+
rmdir -- "$pack_dir"
|
|
1111
|
+
}
|
|
1112
|
+
|
|
801
1113
|
echo "--- Building tarball ---"
|
|
802
|
-
|
|
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
|
|
1114
|
+
install_pantheon_package
|
|
808
1115
|
|
|
809
1116
|
echo "--- Init (headless) ---"
|
|
810
1117
|
pantheon-opencode init --headless -y
|
|
@@ -870,7 +1177,188 @@ if [ "$FINAL_VERDICT" != "PASS" ]; then
|
|
|
870
1177
|
fi
|
|
871
1178
|
echo "Next: open the isolated TUI with: $SANDBOX_DIR/start-pantheon.sh"
|
|
872
1179
|
RUNTEST
|
|
873
|
-
|
|
1180
|
+
then
|
|
1181
|
+
rm -f -- "$run_test_tmp"
|
|
1182
|
+
die "cannot write temporary run-test.sh"
|
|
1183
|
+
fi
|
|
1184
|
+
atomic_replace_file "$run_test_tmp" "$SANDBOX_ROOT/run-test.sh" 0755 \
|
|
1185
|
+
|| die "cannot atomically replace run-test.sh"
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
write_sandbox_entrypoints() {
|
|
1189
|
+
canonicalize_sandbox_root
|
|
1190
|
+
mkdir -p -- "$SANDBOX_ROOT"
|
|
1191
|
+
sandbox_path_guard "$SANDBOX_ROOT" dir-check
|
|
1192
|
+
sandbox_path_guard "$SANDBOX_ROOT/start-pantheon.sh" replace-file
|
|
1193
|
+
sandbox_path_guard "$SANDBOX_ROOT/README.md" replace-file
|
|
1194
|
+
local launcher_tmp readme_tmp
|
|
1195
|
+
launcher_tmp="$(mktemp "$SANDBOX_ROOT/.start-pantheon.sh.XXXXXX")" \
|
|
1196
|
+
|| die "cannot create temporary start-pantheon.sh"
|
|
1197
|
+
if ! cat > "$launcher_tmp" <<'START'
|
|
1198
|
+
#!/usr/bin/env bash
|
|
1199
|
+
set -euo pipefail
|
|
1200
|
+
|
|
1201
|
+
SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
1202
|
+
SANDBOX_HOME="$SANDBOX_DIR/home"
|
|
1203
|
+
NPM_PREFIX="$SANDBOX_HOME/.npm-global"
|
|
1204
|
+
V2_BIN="$NPM_PREFIX/bin/opencode2"
|
|
1205
|
+
PROJECT_DIR="$SANDBOX_DIR/project-v2"
|
|
1206
|
+
V2_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
|
|
1207
|
+
V2_PORT="${PANTHEON_V2_PORT:-49376}"
|
|
1208
|
+
if [[ ! "$V2_PORT" =~ ^[0-9]{1,5}$ ]]; then
|
|
1209
|
+
echo "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')" >&2
|
|
1210
|
+
exit 1
|
|
1211
|
+
fi
|
|
1212
|
+
V2_PORT=$((10#$V2_PORT))
|
|
1213
|
+
if [ "$V2_PORT" -lt 1 ] || [ "$V2_PORT" -gt 65535 ]; then
|
|
1214
|
+
echo "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')" >&2
|
|
1215
|
+
exit 1
|
|
1216
|
+
fi
|
|
1217
|
+
if ! python3 - "$V2_HOST" "$V2_PORT" <<'PY'
|
|
1218
|
+
import ipaddress
|
|
1219
|
+
import socket
|
|
1220
|
+
import sys
|
|
1221
|
+
|
|
1222
|
+
host, raw_port = sys.argv[1], sys.argv[2]
|
|
1223
|
+
try:
|
|
1224
|
+
address = ipaddress.ip_address(host)
|
|
1225
|
+
if not address.is_loopback or address.version != 4:
|
|
1226
|
+
raise ValueError("host must be an IPv4 loopback IP address")
|
|
1227
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
1228
|
+
probe.bind((host, int(raw_port)))
|
|
1229
|
+
except (OSError, ValueError) as exc:
|
|
1230
|
+
print(f"ERROR: dedicated V2 port {host}:{raw_port} is already in use or unavailable; no service was stopped: {exc}", file=sys.stderr)
|
|
1231
|
+
raise SystemExit(1)
|
|
1232
|
+
PY
|
|
1233
|
+
then
|
|
1234
|
+
exit 1
|
|
1235
|
+
fi
|
|
1236
|
+
[ -x "$V2_BIN" ] || { echo "ERROR: V2 binary is missing: $V2_BIN" >&2; exit 3; }
|
|
1237
|
+
[ -d "$PROJECT_DIR" ] || { echo "ERROR: V2 project is missing: $PROJECT_DIR (run --prepare first)" >&2; exit 3; }
|
|
1238
|
+
|
|
1239
|
+
export HOME="$SANDBOX_HOME"
|
|
1240
|
+
export PATH="$NPM_PREFIX/bin:$SANDBOX_HOME/.config/opencode/.venv/bin:$PATH"
|
|
1241
|
+
export npm_config_prefix="$NPM_PREFIX"
|
|
1242
|
+
export npm_config_cache="$SANDBOX_HOME/.npm-cache"
|
|
1243
|
+
export XDG_CONFIG_HOME="$SANDBOX_HOME/.config"
|
|
1244
|
+
export XDG_CONFIG_DIRS="$SANDBOX_HOME/.config/xdg"
|
|
1245
|
+
export XDG_DATA_HOME="$SANDBOX_HOME/.local/share"
|
|
1246
|
+
export XDG_DATA_DIRS="$SANDBOX_HOME/.local/share/xdg"
|
|
1247
|
+
export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
|
|
1248
|
+
export XDG_CACHE_HOME="$SANDBOX_HOME/.cache"
|
|
1249
|
+
V2_CONFIG="$SANDBOX_DIR/project-v2/opencode.json"
|
|
1250
|
+
export OPENCODE_CONFIG_DIR="$PROJECT_DIR"
|
|
1251
|
+
export OPENCODE_DB="$SANDBOX_DIR/opencode-v2.db"
|
|
1252
|
+
export PANTHEON_V2_PORT="$V2_PORT"
|
|
1253
|
+
export PORT="$V2_PORT"
|
|
1254
|
+
unset PANTHEON_V2_CONFIG PANTHEON_V2_DB
|
|
1255
|
+
unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE \
|
|
1256
|
+
OPENCODE_DATA_DIR OPENCODE_STATE_DIR OPENCODE_CACHE_DIR OPENCODE_STORAGE_PATH
|
|
1257
|
+
python3 - "$SANDBOX_DIR" <<'PY'
|
|
1258
|
+
import stat
|
|
1259
|
+
import sys
|
|
1260
|
+
from pathlib import Path
|
|
1261
|
+
|
|
1262
|
+
root = Path(sys.argv[1])
|
|
1263
|
+
if root.resolve(strict=True) != root:
|
|
1264
|
+
raise SystemExit(f"ERROR: sandbox root is not canonical: {root}")
|
|
1265
|
+
|
|
1266
|
+
def validate(path, directory):
|
|
1267
|
+
try:
|
|
1268
|
+
relative = path.relative_to(root)
|
|
1269
|
+
except ValueError:
|
|
1270
|
+
raise SystemExit(f"ERROR: sandbox path escaped root: {path}")
|
|
1271
|
+
if any(part in ("", ".", "..") for part in relative.parts):
|
|
1272
|
+
raise SystemExit(f"ERROR: unsafe sandbox path component: {path}")
|
|
1273
|
+
current = root
|
|
1274
|
+
for index, part in enumerate(relative.parts):
|
|
1275
|
+
current = current / part
|
|
1276
|
+
try:
|
|
1277
|
+
info = current.lstat()
|
|
1278
|
+
except FileNotFoundError:
|
|
1279
|
+
return
|
|
1280
|
+
if stat.S_ISLNK(info.st_mode):
|
|
1281
|
+
raise SystemExit(f"ERROR: sandbox path contains symlink: {current}")
|
|
1282
|
+
final = index == len(relative.parts) - 1
|
|
1283
|
+
if (not final or directory) and not stat.S_ISDIR(info.st_mode):
|
|
1284
|
+
raise SystemExit(f"ERROR: sandbox path component is not a directory: {current}")
|
|
1285
|
+
if final and not directory and not stat.S_ISREG(info.st_mode):
|
|
1286
|
+
raise SystemExit(f"ERROR: sandbox output is not a regular file: {current}")
|
|
1287
|
+
if current.resolve(strict=True) != current:
|
|
1288
|
+
raise SystemExit(f"ERROR: sandbox path resolves outside its lexical location: {current}")
|
|
1289
|
+
|
|
1290
|
+
for directory in (
|
|
1291
|
+
root / "home",
|
|
1292
|
+
root / "home/.npm-cache",
|
|
1293
|
+
root / "home/.config",
|
|
1294
|
+
root / "home/.config/opencode",
|
|
1295
|
+
root / "home/.config/xdg",
|
|
1296
|
+
root / "home/.local/share",
|
|
1297
|
+
root / "home/.local/share/opencode",
|
|
1298
|
+
root / "home/.local/share/xdg",
|
|
1299
|
+
root / "home/.local/state",
|
|
1300
|
+
root / "home/.local/state/opencode",
|
|
1301
|
+
root / "home/.cache",
|
|
1302
|
+
root / "home/.cache/opencode",
|
|
1303
|
+
root / "home/.opencode",
|
|
1304
|
+
root / "project-v2",
|
|
1305
|
+
):
|
|
1306
|
+
validate(directory, True)
|
|
1307
|
+
for output in (root / "project-v2/opencode.json", root / "opencode-v2.db"):
|
|
1308
|
+
validate(output, False)
|
|
1309
|
+
PY
|
|
1310
|
+
cd "$PROJECT_DIR"
|
|
1311
|
+
exec "$V2_BIN"
|
|
1312
|
+
START
|
|
1313
|
+
then
|
|
1314
|
+
rm -f -- "$launcher_tmp"
|
|
1315
|
+
die "cannot write temporary start-pantheon.sh"
|
|
1316
|
+
fi
|
|
1317
|
+
atomic_replace_file "$launcher_tmp" "$SANDBOX_ROOT/start-pantheon.sh" 0755 \
|
|
1318
|
+
|| die "cannot atomically replace start-pantheon.sh"
|
|
1319
|
+
|
|
1320
|
+
readme_tmp="$(mktemp "$SANDBOX_ROOT/.README.md.XXXXXX")" \
|
|
1321
|
+
|| die "cannot create temporary sandbox README"
|
|
1322
|
+
if ! cat > "$readme_tmp" <<'SANDBOXREADME'
|
|
1323
|
+
# Pantheon isolated OpenCode V2 sandbox
|
|
1324
|
+
|
|
1325
|
+
This folder is generated by `scripts/test-opencode-v2-sandbox.sh --prepare` and
|
|
1326
|
+
uses its own HOME, npm prefix, project config, database, and Python environment.
|
|
1327
|
+
It does not use the developer's OpenCode configuration.
|
|
1328
|
+
|
|
1329
|
+
## Use
|
|
1330
|
+
|
|
1331
|
+
- Validate the installation: `bash ~/pantheon-sandbox/run-test.sh`
|
|
1332
|
+
- Open the isolated TUI: `bash ~/pantheon-sandbox/start-pantheon.sh`
|
|
1333
|
+
- Recreate/update the sandbox: `scripts/test-opencode-v2-sandbox.sh --prepare`
|
|
1334
|
+
- Reset only after reviewing the sandbox path: `scripts/test-opencode-v2-sandbox.sh --reset`
|
|
1335
|
+
|
|
1336
|
+
## Environment overrides
|
|
1337
|
+
|
|
1338
|
+
- `PANTHEON_SANDBOX_ROOT`: sandbox location (default `~/pantheon-sandbox`).
|
|
1339
|
+
- `PANTHEON_REPO`: existing Pantheon checkout used consistently for packaging;
|
|
1340
|
+
a missing, dangling, or non-Pantheon path fails before `npm pack`.
|
|
1341
|
+
- `PANTHEON_V2_PORT`: dedicated loopback port (default `49376`). The scripts
|
|
1342
|
+
reject invalid or occupied ports and never stop or reuse an existing service.
|
|
1343
|
+
- `PANTHEON_V2_HOST`: IPv4 loopback IP (default `127.0.0.1`); other addresses
|
|
1344
|
+
are rejected to avoid exposing the sandbox service on external interfaces.
|
|
1345
|
+
- `PANTHEON_V2_MCP_LIST_TIMEOUT`: each `opencode mcp list` timeout in seconds
|
|
1346
|
+
(default `15`).
|
|
1347
|
+
- `OPENCODE_V2_SPEC`: OpenCode V2 npm install spec (default `@opencode-ai/cli@beta`).
|
|
1348
|
+
|
|
1349
|
+
## Temporary files
|
|
1350
|
+
|
|
1351
|
+
No blanket `tmp/` cleanup is performed. On `--prepare`, only hash-named files
|
|
1352
|
+
owned by the current user under `tmp/node-compile-cache/` older than 30 days may
|
|
1353
|
+
expire. Runtime state, unknown files, and handoff/evidence files are retained.
|
|
1354
|
+
Do not remove `tmp/` recursively.
|
|
1355
|
+
SANDBOXREADME
|
|
1356
|
+
then
|
|
1357
|
+
rm -f -- "$readme_tmp"
|
|
1358
|
+
die "cannot write temporary sandbox README"
|
|
1359
|
+
fi
|
|
1360
|
+
atomic_replace_file "$readme_tmp" "$SANDBOX_ROOT/README.md" 0644 \
|
|
1361
|
+
|| die "cannot atomically replace sandbox README"
|
|
874
1362
|
}
|
|
875
1363
|
|
|
876
1364
|
# ── Reset / Prepare ───────────────────────────────────────────────────────────
|
|
@@ -920,6 +1408,12 @@ canonicalize_path() {
|
|
|
920
1408
|
printf '%s\n' "$resolved"
|
|
921
1409
|
}
|
|
922
1410
|
|
|
1411
|
+
canonicalize_existing_directory() {
|
|
1412
|
+
local path="$1"
|
|
1413
|
+
[ -n "$path" ] && [ -d "$path" ] || return 1
|
|
1414
|
+
(cd -P -- "$path" 2>/dev/null && pwd -P)
|
|
1415
|
+
}
|
|
1416
|
+
|
|
923
1417
|
# Compare complete path components; a sibling such as project-extra is not
|
|
924
1418
|
# inside project. The root directory is a parent of every absolute path.
|
|
925
1419
|
path_is_same_or_descendant() {
|
|
@@ -932,58 +1426,261 @@ path_is_same_or_descendant() {
|
|
|
932
1426
|
return 1
|
|
933
1427
|
}
|
|
934
1428
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1429
|
+
canonicalize_sandbox_root() {
|
|
1430
|
+
local canonical_root
|
|
1431
|
+
canonical_root="$(canonicalize_path "$SANDBOX_ROOT")" \
|
|
1432
|
+
|| die "cannot canonicalize sandbox root: $SANDBOX_ROOT"
|
|
1433
|
+
SANDBOX_ROOT="$canonical_root"
|
|
1434
|
+
SANDBOX_HOME="$SANDBOX_ROOT/home"
|
|
1435
|
+
NPM_PREFIX="$SANDBOX_HOME/.npm-global"
|
|
1436
|
+
V2_CONFIG="$SANDBOX_ROOT/project-v2/opencode.json"
|
|
1437
|
+
V2_DB="$SANDBOX_ROOT/opencode-v2.db"
|
|
1438
|
+
V2_SERVER_LOG="$SANDBOX_ROOT/v2-server.log"
|
|
1439
|
+
V2_SERVICE_STATE="$SANDBOX_HOME/.local/state/opencode/service.json"
|
|
1440
|
+
REPORT_FILE="$SANDBOX_ROOT/prompts-report.md"
|
|
1441
|
+
COST_REPORT_FILE="$SANDBOX_ROOT/pantheon-cost-report.md"
|
|
1442
|
+
REHYDRATE_REPORT_FILE="$SANDBOX_ROOT/context-rehydrate-report.md"
|
|
1443
|
+
EXTRACT_PY="$SANDBOX_ROOT/.prompt-extract-json.py"
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
# Validate/create paths one component at a time. Existing symlinks are rejected
|
|
1447
|
+
# rather than followed, and every existing component must resolve to its lexical
|
|
1448
|
+
# location below the canonical sandbox root.
|
|
1449
|
+
sandbox_path_guard() {
|
|
1450
|
+
local target="$1" mode="$2"
|
|
1451
|
+
SANDBOX_ROOT="$SANDBOX_ROOT" python3 - "$target" "$mode" <<'PY'
|
|
1452
|
+
import os
|
|
1453
|
+
import stat
|
|
1454
|
+
import sys
|
|
1455
|
+
from pathlib import Path
|
|
1456
|
+
|
|
1457
|
+
root = Path(os.environ["SANDBOX_ROOT"])
|
|
1458
|
+
target = Path(sys.argv[1])
|
|
1459
|
+
mode = sys.argv[2]
|
|
1460
|
+
|
|
1461
|
+
def fail(message):
|
|
1462
|
+
print(f"ERROR: unsafe sandbox path {target}: {message}", file=sys.stderr)
|
|
1463
|
+
raise SystemExit(1)
|
|
1464
|
+
|
|
1465
|
+
if not root.is_absolute() or not target.is_absolute():
|
|
1466
|
+
fail("paths must be absolute")
|
|
1467
|
+
try:
|
|
1468
|
+
relative = target.relative_to(root)
|
|
1469
|
+
except ValueError:
|
|
1470
|
+
fail("path is outside the canonical sandbox root")
|
|
1471
|
+
parts = relative.parts
|
|
1472
|
+
if any(part in ("", ".", "..") for part in parts):
|
|
1473
|
+
fail("dot or empty path components are not allowed")
|
|
1474
|
+
|
|
1475
|
+
try:
|
|
1476
|
+
root_stat = root.lstat()
|
|
1477
|
+
except OSError as exc:
|
|
1478
|
+
fail(f"sandbox root is missing: {exc}")
|
|
1479
|
+
if stat.S_ISLNK(root_stat.st_mode) or not stat.S_ISDIR(root_stat.st_mode):
|
|
1480
|
+
fail("sandbox root must be a real directory")
|
|
1481
|
+
if root.resolve(strict=True) != root:
|
|
1482
|
+
fail("sandbox root is not canonical")
|
|
1483
|
+
|
|
1484
|
+
if mode.startswith("dir-"):
|
|
1485
|
+
current = root
|
|
1486
|
+
for part in parts:
|
|
1487
|
+
current = current / part
|
|
1488
|
+
try:
|
|
1489
|
+
info = current.lstat()
|
|
1490
|
+
except FileNotFoundError:
|
|
1491
|
+
if mode != "dir-create":
|
|
1492
|
+
fail("directory does not exist")
|
|
1493
|
+
try:
|
|
1494
|
+
current.mkdir()
|
|
1495
|
+
except OSError as exc:
|
|
1496
|
+
fail(f"cannot create directory: {exc}")
|
|
1497
|
+
info = current.lstat()
|
|
1498
|
+
except OSError as exc:
|
|
1499
|
+
fail(f"cannot inspect path: {exc}")
|
|
1500
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
|
1501
|
+
fail("directory component is a symlink or not a directory")
|
|
1502
|
+
if current.resolve(strict=True) != current:
|
|
1503
|
+
fail("directory component resolves outside its lexical path")
|
|
1504
|
+
raise SystemExit(0)
|
|
1505
|
+
|
|
1506
|
+
if mode not in ("file-check", "file-allow-missing-parent", "replace-file"):
|
|
1507
|
+
fail(f"unknown validation mode {mode}")
|
|
1508
|
+
|
|
1509
|
+
current = root
|
|
1510
|
+
for part in parts[:-1]:
|
|
1511
|
+
current = current / part
|
|
1512
|
+
try:
|
|
1513
|
+
info = current.lstat()
|
|
1514
|
+
except FileNotFoundError:
|
|
1515
|
+
if mode == "file-allow-missing-parent":
|
|
1516
|
+
raise SystemExit(0)
|
|
1517
|
+
fail("parent directory does not exist")
|
|
1518
|
+
except OSError as exc:
|
|
1519
|
+
fail(f"cannot inspect parent: {exc}")
|
|
1520
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
|
1521
|
+
fail("parent component is a symlink or not a directory")
|
|
1522
|
+
if current.resolve(strict=True) != current:
|
|
1523
|
+
fail("parent component resolves outside its lexical path")
|
|
1524
|
+
|
|
1525
|
+
if not parts:
|
|
1526
|
+
fail("expected a file path below the sandbox root")
|
|
1527
|
+
leaf = current / parts[-1]
|
|
1528
|
+
try:
|
|
1529
|
+
info = leaf.lstat()
|
|
1530
|
+
except FileNotFoundError:
|
|
1531
|
+
raise SystemExit(0)
|
|
1532
|
+
except OSError as exc:
|
|
1533
|
+
fail(f"cannot inspect file: {exc}")
|
|
1534
|
+
if mode != "replace-file" and stat.S_ISLNK(info.st_mode):
|
|
1535
|
+
fail("file is a symlink")
|
|
1536
|
+
if mode != "replace-file" and not stat.S_ISREG(info.st_mode):
|
|
1537
|
+
fail("file is not a regular file")
|
|
1538
|
+
PY
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
atomic_replace_file() {
|
|
1542
|
+
local temporary="$1" destination="$2" mode="$3"
|
|
1543
|
+
if ! chmod "$mode" "$temporary"; then
|
|
1544
|
+
rm -f -- "$temporary"
|
|
1545
|
+
return 1
|
|
1546
|
+
fi
|
|
1547
|
+
if ! mv -fT -- "$temporary" "$destination"; then
|
|
1548
|
+
rm -f -- "$temporary"
|
|
1549
|
+
return 1
|
|
1550
|
+
fi
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
guard_sandbox_root() {
|
|
1554
|
+
# Both destructive reset and prepare are forbidden from overlapping HOME or
|
|
1555
|
+
# any package checkout. This prevents packing generated sandbox files into a
|
|
1556
|
+
# repo and prevents reset from deleting a repo or one of its ancestors.
|
|
1557
|
+
local action="${1:-use}" protected canonical_root canonical_home canonical_protected
|
|
1558
|
+
local config_home data_home state_home cache_home
|
|
1559
|
+
local path_list path_entry
|
|
942
1560
|
canonical_root="$(canonicalize_path "$SANDBOX_ROOT")" \
|
|
943
|
-
|| die "refusing to
|
|
1561
|
+
|| die "refusing to $action sandbox root that cannot be canonicalized: $SANDBOX_ROOT"
|
|
944
1562
|
canonical_home="$(canonicalize_path "$HOME")" \
|
|
945
|
-
|| die "refusing to
|
|
1563
|
+
|| die "refusing to $action sandbox because HOME cannot be canonicalized: $HOME"
|
|
946
1564
|
[ "$canonical_root" != "/" ] \
|
|
947
|
-
|| die "refusing to
|
|
1565
|
+
|| die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (filesystem root)"
|
|
948
1566
|
[ "$canonical_root" != "$canonical_home" ] \
|
|
949
|
-
|| die "refusing to
|
|
1567
|
+
|| die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (HOME)"
|
|
950
1568
|
if path_is_same_or_descendant "$canonical_home" "$canonical_root"; then
|
|
951
|
-
die "refusing to
|
|
1569
|
+
die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of HOME $HOME)"
|
|
1570
|
+
fi
|
|
1571
|
+
|
|
1572
|
+
config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
|
|
1573
|
+
data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
|
|
1574
|
+
state_home="${XDG_STATE_HOME:-$HOME/.local/state}"
|
|
1575
|
+
cache_home="${XDG_CACHE_HOME:-$HOME/.cache}"
|
|
1576
|
+
for path_list in "${XDG_CONFIG_DIRS:-}" "${XDG_DATA_DIRS:-}"; do
|
|
1577
|
+
while [ -n "$path_list" ]; do
|
|
1578
|
+
case "$path_list" in
|
|
1579
|
+
*:*) path_entry="${path_list%%:*}"; path_list="${path_list#*:}" ;;
|
|
1580
|
+
*) path_entry="$path_list"; path_list="" ;;
|
|
1581
|
+
esac
|
|
1582
|
+
[ -n "$path_entry" ] || continue
|
|
1583
|
+
for protected in "$path_entry" "$path_entry/opencode"; do
|
|
1584
|
+
canonical_protected="$(canonicalize_path "$protected")" \
|
|
1585
|
+
|| die "refusing to $action because global OpenCode path cannot be resolved: $protected"
|
|
1586
|
+
if path_is_same_or_descendant "$canonical_root" "$canonical_protected" \
|
|
1587
|
+
|| path_is_same_or_descendant "$canonical_protected" "$canonical_root"; then
|
|
1588
|
+
die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (overlaps global OpenCode path $protected)"
|
|
1589
|
+
fi
|
|
1590
|
+
done
|
|
1591
|
+
done
|
|
1592
|
+
done
|
|
1593
|
+
|
|
1594
|
+
# OPENCODE_CONFIG and OPENCODE_DB are file overrides. canonicalize_path
|
|
1595
|
+
# resolves symlinked directories but deliberately preserves a final file
|
|
1596
|
+
# symlink (including a dangling one), so its lexical path may not overlap a
|
|
1597
|
+
# sandbox that contains the symlink target. Reset removes the whole sandbox;
|
|
1598
|
+
# reject these final symlinks before canonical-path overlap checks can miss
|
|
1599
|
+
# an in-sandbox target. This is intentionally conservative and avoids having
|
|
1600
|
+
# to interpret absolute, relative, or dot-dot symlink targets here.
|
|
1601
|
+
if [ "$action" = reset ]; then
|
|
1602
|
+
for protected in "${OPENCODE_CONFIG:-}" "${OPENCODE_DB:-}"; do
|
|
1603
|
+
[ -n "$protected" ] || continue
|
|
1604
|
+
[ ! -L "$protected" ] \
|
|
1605
|
+
|| die "refusing to reset because OpenCode file override is a symbolic link: $protected"
|
|
1606
|
+
done
|
|
952
1607
|
fi
|
|
953
1608
|
|
|
954
|
-
for protected in
|
|
1609
|
+
for protected in \
|
|
1610
|
+
"$HOME/.config/opencode" "$HOME/.opencode" \
|
|
1611
|
+
"$config_home/opencode" "$data_home/opencode" \
|
|
1612
|
+
"$state_home/opencode" "$cache_home/opencode" \
|
|
1613
|
+
"${OPENCODE_CONFIG_DIR:-}" "${OPENCODE_DATA_DIR:-}" \
|
|
1614
|
+
"${OPENCODE_STATE_DIR:-}" "${OPENCODE_CACHE_DIR:-}" "${OPENCODE_STORAGE_PATH:-}" \
|
|
1615
|
+
"${OPENCODE_CONFIG:-}" "${OPENCODE_DB:-}"; do
|
|
955
1616
|
[ -n "$protected" ] || continue
|
|
956
1617
|
canonical_protected="$(canonicalize_path "$protected")" \
|
|
957
|
-
|| die "refusing to
|
|
1618
|
+
|| die "refusing to $action because global OpenCode path cannot be resolved: $protected"
|
|
1619
|
+
if path_is_same_or_descendant "$canonical_root" "$canonical_protected" \
|
|
1620
|
+
|| path_is_same_or_descendant "$canonical_protected" "$canonical_root"; then
|
|
1621
|
+
die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (overlaps global OpenCode path $protected)"
|
|
1622
|
+
fi
|
|
1623
|
+
done
|
|
1624
|
+
|
|
1625
|
+
for protected in "$REPO_DIR" "${PANTHEON_REPO:-}"; do
|
|
1626
|
+
[ -n "$protected" ] || continue
|
|
1627
|
+
canonical_protected="$(canonicalize_existing_directory "$protected")" \
|
|
1628
|
+
|| die "refusing to $action sandbox because protected repo path is missing or cannot be resolved: $protected"
|
|
958
1629
|
if path_is_same_or_descendant "$canonical_root" "$canonical_protected"; then
|
|
959
|
-
die "refusing to
|
|
1630
|
+
die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (it is equal to or inside the repo $protected)"
|
|
960
1631
|
fi
|
|
961
1632
|
if path_is_same_or_descendant "$canonical_protected" "$canonical_root"; then
|
|
962
|
-
die "refusing to
|
|
1633
|
+
die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of the repo $protected)"
|
|
963
1634
|
fi
|
|
964
1635
|
done
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
cmd_reset() {
|
|
1639
|
+
guard_sandbox_root reset
|
|
965
1640
|
log "Resetting sandbox: $SANDBOX_ROOT"
|
|
966
|
-
rm -rf "$SANDBOX_ROOT"
|
|
1641
|
+
rm -rf -- "$SANDBOX_ROOT"
|
|
967
1642
|
log "Sandbox removed."
|
|
968
1643
|
}
|
|
969
1644
|
|
|
970
1645
|
install_binaries() {
|
|
971
1646
|
log "--- Installing pantheon-opencode (from repo tarball) ---"
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1647
|
+
local pack_dir pack_json filename tgz
|
|
1648
|
+
pack_dir="$(mktemp -d "$SANDBOX_ROOT/.pantheon-npm-pack.XXXXXX")" \
|
|
1649
|
+
|| die "cannot create isolated npm pack directory"
|
|
1650
|
+
pack_json="$pack_dir/pack.json"
|
|
1651
|
+
if ! (cd "$REPO_DIR" && npm pack --ignore-scripts --json --pack-destination "$pack_dir") > "$pack_json"; then
|
|
1652
|
+
find "$pack_dir" -maxdepth 1 -type f -name 'pantheon-opencode-*.tgz' -delete
|
|
1653
|
+
rm -f -- "$pack_json"
|
|
1654
|
+
rmdir -- "$pack_dir" 2>/dev/null || true
|
|
1655
|
+
die "npm pack failed"
|
|
1656
|
+
fi
|
|
1657
|
+
if ! filename="$(node -e 'const p=JSON.parse(require("fs").readFileSync(0,"utf8")); if (!Array.isArray(p) || p.length !== 1 || typeof p[0].filename !== "string") process.exit(1); process.stdout.write(p[0].filename)' < "$pack_json")" \
|
|
1658
|
+
|| [[ "$filename" != pantheon-opencode-*.tgz || "$filename" == */* || ! -f "$pack_dir/$filename" ]]; then
|
|
1659
|
+
find "$pack_dir" -maxdepth 1 -type f -name 'pantheon-opencode-*.tgz' -delete
|
|
1660
|
+
rm -f -- "$pack_json"
|
|
1661
|
+
rmdir -- "$pack_dir" 2>/dev/null || true
|
|
1662
|
+
die "npm pack returned an invalid tarball filename"
|
|
1663
|
+
fi
|
|
1664
|
+
tgz="$pack_dir/$filename"
|
|
976
1665
|
npm rm -g pantheon-opencode 2>/dev/null || true
|
|
977
|
-
npm install -g "$
|
|
978
|
-
|
|
1666
|
+
if ! npm install -g "$tgz"; then
|
|
1667
|
+
rm -f -- "$tgz" "$pack_json"
|
|
1668
|
+
rmdir -- "$pack_dir" 2>/dev/null || true
|
|
1669
|
+
die "install of isolated tarball failed: $tgz"
|
|
1670
|
+
fi
|
|
1671
|
+
rm -f -- "$tgz" "$pack_json"
|
|
1672
|
+
rmdir -- "$pack_dir" || die "could not clean isolated npm pack directory: $pack_dir"
|
|
979
1673
|
log "--- Installing OpenCode V2 ($OPENCODE_V2_SPEC) ---"
|
|
980
1674
|
npm install -g "$OPENCODE_V2_SPEC" || die "install of $OPENCODE_V2_SPEC failed"
|
|
981
1675
|
}
|
|
982
1676
|
|
|
983
1677
|
prepare_project() {
|
|
984
1678
|
local dir pan
|
|
1679
|
+
canonicalize_sandbox_root
|
|
985
1680
|
dir="$(project_dir)"
|
|
986
|
-
|
|
1681
|
+
sandbox_path_guard "$dir" dir-create
|
|
1682
|
+
sandbox_path_guard "$V2_CONFIG" file-allow-missing-parent \
|
|
1683
|
+
|| die "unsafe project config path: $V2_CONFIG"
|
|
987
1684
|
pan="$(sandbox_bin_for "pantheon-opencode")" \
|
|
988
1685
|
|| die "pantheon-opencode not installed in sandbox prefix — run $0 --prepare first"
|
|
989
1686
|
log "--- Regenerating config in $dir ---"
|
|
@@ -997,8 +1694,11 @@ prepare_project() {
|
|
|
997
1694
|
# point at a different sandbox or host. Rewrite the managed entries after the
|
|
998
1695
|
# merge and validate the complete shape before any OpenCode process starts.
|
|
999
1696
|
rewrite_v2_mcp_config() {
|
|
1697
|
+
canonicalize_sandbox_root
|
|
1000
1698
|
local runtime_dir="$(project_dir)/.opencode"
|
|
1001
1699
|
local python_bin="$(project_dir)/.venv/bin/python3"
|
|
1700
|
+
sandbox_path_guard "$V2_CONFIG" file-check \
|
|
1701
|
+
|| die "V2 config path is missing or unsafe: $V2_CONFIG"
|
|
1002
1702
|
[ -f "$V2_CONFIG" ] || die "V2 config was not generated: $V2_CONFIG"
|
|
1003
1703
|
[ -x "$python_bin" ] || die "V2 MCP Python is missing: $python_bin"
|
|
1004
1704
|
|
|
@@ -1006,7 +1706,9 @@ rewrite_v2_mcp_config() {
|
|
|
1006
1706
|
python3 - <<'PY'
|
|
1007
1707
|
import json
|
|
1008
1708
|
import os
|
|
1709
|
+
import stat
|
|
1009
1710
|
import sys
|
|
1711
|
+
import tempfile
|
|
1010
1712
|
from pathlib import Path
|
|
1011
1713
|
|
|
1012
1714
|
config_path = Path(os.environ["CONFIG_PATH"])
|
|
@@ -1045,15 +1747,22 @@ for name, script in managed.items():
|
|
|
1045
1747
|
)
|
|
1046
1748
|
mcp[name] = entry
|
|
1047
1749
|
|
|
1048
|
-
tmp_path =
|
|
1750
|
+
tmp_path = None
|
|
1049
1751
|
try:
|
|
1050
|
-
|
|
1752
|
+
temp_fd, temp_name = tempfile.mkstemp(
|
|
1753
|
+
prefix=f".{config_path.name}.", suffix=".tmp", dir=config_path.parent
|
|
1754
|
+
)
|
|
1755
|
+
tmp_path = Path(temp_name)
|
|
1756
|
+
with os.fdopen(temp_fd, "w", encoding="utf-8") as stream:
|
|
1757
|
+
stream.write(json.dumps(config, indent=2) + "\n")
|
|
1758
|
+
os.chmod(tmp_path, stat.S_IMODE(config_path.stat().st_mode))
|
|
1051
1759
|
os.replace(tmp_path, config_path)
|
|
1052
1760
|
except OSError as exc:
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1761
|
+
if tmp_path is not None:
|
|
1762
|
+
try:
|
|
1763
|
+
tmp_path.unlink()
|
|
1764
|
+
except OSError:
|
|
1765
|
+
pass
|
|
1057
1766
|
raise SystemExit(f"cannot rewrite V2 config {config_path}: {exc}")
|
|
1058
1767
|
|
|
1059
1768
|
try:
|
|
@@ -1078,9 +1787,13 @@ PY
|
|
|
1078
1787
|
}
|
|
1079
1788
|
|
|
1080
1789
|
cmd_prepare() {
|
|
1081
|
-
|
|
1082
|
-
|
|
1790
|
+
validate_v2_port
|
|
1791
|
+
guard_sandbox_root use
|
|
1792
|
+
canonicalize_sandbox_root
|
|
1083
1793
|
sandbox_env
|
|
1794
|
+
cleanup_sandbox_tmp
|
|
1795
|
+
write_run_test_sh
|
|
1796
|
+
write_sandbox_entrypoints
|
|
1084
1797
|
install_binaries
|
|
1085
1798
|
local pan
|
|
1086
1799
|
pan="$(sandbox_bin_for "pantheon-opencode")" \
|
|
@@ -1097,7 +1810,14 @@ cmd_prepare() {
|
|
|
1097
1810
|
# ── JSON text extraction (schema-agnostic) ────────────────────────────────────
|
|
1098
1811
|
|
|
1099
1812
|
write_extract_py() {
|
|
1100
|
-
|
|
1813
|
+
canonicalize_sandbox_root
|
|
1814
|
+
mkdir -p -- "$SANDBOX_ROOT"
|
|
1815
|
+
sandbox_path_guard "$SANDBOX_ROOT" dir-check
|
|
1816
|
+
sandbox_path_guard "$EXTRACT_PY" replace-file
|
|
1817
|
+
local extract_tmp
|
|
1818
|
+
extract_tmp="$(mktemp "$SANDBOX_ROOT/.prompt-extract-json.py.XXXXXX")" \
|
|
1819
|
+
|| die "cannot create temporary prompt extractor"
|
|
1820
|
+
if ! cat > "$extract_tmp" <<'PYEOF'
|
|
1101
1821
|
"""Collect every string value from opencode run --format json output."""
|
|
1102
1822
|
import json
|
|
1103
1823
|
import sys
|
|
@@ -1129,6 +1849,12 @@ except Exception:
|
|
|
1129
1849
|
chunks.append(line)
|
|
1130
1850
|
print("\n".join(chunks))
|
|
1131
1851
|
PYEOF
|
|
1852
|
+
then
|
|
1853
|
+
rm -f -- "$extract_tmp"
|
|
1854
|
+
die "cannot write temporary prompt extractor"
|
|
1855
|
+
fi
|
|
1856
|
+
atomic_replace_file "$extract_tmp" "$EXTRACT_PY" 0644 \
|
|
1857
|
+
|| die "cannot atomically replace prompt extractor"
|
|
1132
1858
|
}
|
|
1133
1859
|
|
|
1134
1860
|
# ── Prompt battery ────────────────────────────────────────────────────────────
|
|
@@ -1146,7 +1872,7 @@ add_prompt() { # id marker ignored verify text
|
|
|
1146
1872
|
}
|
|
1147
1873
|
|
|
1148
1874
|
build_battery() {
|
|
1149
|
-
local v="$1"
|
|
1875
|
+
local v="$1" probe_file probe_shell_arg
|
|
1150
1876
|
PROMPT_IDS=()
|
|
1151
1877
|
PROMPT_TEXTS=()
|
|
1152
1878
|
PROMPT_MARKERS=()
|
|
@@ -1157,9 +1883,11 @@ build_battery() {
|
|
|
1157
1883
|
"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
1884
|
add_prompt "memory-recall" "MEMORY-RECALL-OK" "" "" \
|
|
1159
1885
|
"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"
|
|
1886
|
+
probe_file="$SANDBOX_ROOT/pantheon-sandbox-probe-$v.txt"
|
|
1887
|
+
printf -v probe_shell_arg '%q' "$probe_file"
|
|
1160
1888
|
add_prompt "tmp-write-read" "TMP-OK" "" \
|
|
1161
|
-
"$
|
|
1162
|
-
"Using the bash tool, run a python3 -c command that writes the text TMP-OK to the file $
|
|
1889
|
+
"$probe_file::TMP-OK" \
|
|
1890
|
+
"Using the bash tool, run a python3 -c command that writes the text TMP-OK to the file at $probe_shell_arg (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
1891
|
add_prompt "delegation" "DELEGATION-OK" "" "" \
|
|
1164
1892
|
"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
1893
|
}
|
|
@@ -1223,6 +1951,9 @@ run_prompt_attempt() { # v idx current_bin — sets ATTEMPT_RESULT / ATTEMPT_DET
|
|
|
1223
1951
|
ATTEMPT_RESULT="FAIL"
|
|
1224
1952
|
ATTEMPT_DETAIL="marker $marker absent; rc=$rc; snippet: $(printf '%s' "$raw" | cut -c1-160)"
|
|
1225
1953
|
fi
|
|
1954
|
+
if [ -n "$verify" ]; then
|
|
1955
|
+
rm -f "${verify%%::*}"
|
|
1956
|
+
fi
|
|
1226
1957
|
}
|
|
1227
1958
|
|
|
1228
1959
|
run_prompt() { # v idx current_bin — exactly one attempt
|
|
@@ -1682,6 +2413,7 @@ while [ $# -gt 0 ]; do
|
|
|
1682
2413
|
done
|
|
1683
2414
|
|
|
1684
2415
|
if [ "$MODE_RESET" -eq 1 ]; then
|
|
2416
|
+
resolve_harness_repo
|
|
1685
2417
|
cmd_reset
|
|
1686
2418
|
exit 0
|
|
1687
2419
|
fi
|
|
@@ -1692,6 +2424,8 @@ if [ "$MODE_PREPARE" -eq 0 ] && [ -z "$RUN_VERSION" ] \
|
|
|
1692
2424
|
usage
|
|
1693
2425
|
fi
|
|
1694
2426
|
|
|
2427
|
+
resolve_harness_repo
|
|
2428
|
+
|
|
1695
2429
|
if [ "$MODE_PREPARE" -eq 1 ]; then
|
|
1696
2430
|
cmd_prepare
|
|
1697
2431
|
fi
|