pantheon-opencode 1.6.0-beta.7 → 1.6.0-beta.9

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.
Files changed (66) hide show
  1. package/AGENTS.md +58 -15
  2. package/CHANGELOG.md +61 -94
  3. package/README.md +81 -103
  4. package/README.pt-BR.md +19 -37
  5. package/commands/pantheon-audit.md +7 -1
  6. package/commands/pantheon-consolidate.md +1 -1
  7. package/commands/pantheon-focus.md +17 -0
  8. package/package.json +1 -2
  9. package/plugin.json +2 -2
  10. package/pyproject.toml +1 -1
  11. package/scripts/build-agents-md.mjs +2 -3
  12. package/scripts/doctor.mjs +22 -104
  13. package/scripts/hooks/scan-secrets.sh +97 -0
  14. package/scripts/install/config-migration.mjs +9 -42
  15. package/scripts/install/interactive.mjs +1 -1
  16. package/scripts/install/opencode.mjs +174 -77
  17. package/scripts/install/plugin.mjs +43 -9
  18. package/scripts/install/strings.mjs +0 -8
  19. package/scripts/probe-pantheon-cost.mjs +3 -16
  20. package/scripts/sync-artifacts.mjs +1 -0
  21. package/scripts/sync-tui.mjs +24 -3
  22. package/scripts/test-opencode-v2-sandbox.sh +203 -786
  23. package/scripts/validate-sandbox-install.mjs +138 -0
  24. package/skills-lock.json +4 -5
  25. package/src/agents/aphrodite.md +2 -9
  26. package/src/agents/demeter.md +0 -9
  27. package/src/agents/gaia.md +1 -0
  28. package/src/agents/hephaestus.md +0 -9
  29. package/src/agents/hermes.md +2 -9
  30. package/src/agents/mnemosyne.md +18 -53
  31. package/src/agents/prometheus.md +0 -9
  32. package/src/agents/themis.md +0 -1
  33. package/src/agents/zeus.md +180 -19
  34. package/src/instructions/agent-return-format.instructions.md +48 -3
  35. package/src/instructions/memory-protocol.instructions.md +8 -9
  36. package/src/instructions/zeus-anti-stall.instructions.md +54 -6
  37. package/src/instructions/zeus-council-synthesis.instructions.md +71 -0
  38. package/src/instructions/zeus-memory-operations.instructions.md +4 -4
  39. package/src/instructions/zeus-timeout-retry.instructions.md +81 -7
  40. package/src/pantheon/v2-delegation-enforce.ts +2 -1
  41. package/src/plugin-v2.ts +251 -168
  42. package/src/plugins/pantheon-hooks.ts +35 -82
  43. package/src/plugins/tui/dist/tui-v2.d.ts +9 -0
  44. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  45. package/src/plugins/tui/dist/tui-v2.js +151 -0
  46. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  47. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  48. package/src/plugins/tui/dist/tui.js +29 -53
  49. package/src/plugins/tui/index-v2.ts +6 -0
  50. package/src/plugins/tui/package-lock.json +5411 -1292
  51. package/src/plugins/tui/package.json +10 -7
  52. package/src/plugins/tui/src/index-v2.tsx +108 -0
  53. package/src/plugins/tui/src/index.tsx +28 -63
  54. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  55. package/src/plugins/tui/tsdown.config.ts +2 -2
  56. package/src/plugins/tui/tui-v2.ts +2 -0
  57. package/src/plugins/tui/tui.ts +3 -0
  58. package/src/routing.yml +4 -2
  59. package/src/skills/README.md +7 -12
  60. package/src/skills/agent-coordination/SKILL.md +83 -13
  61. package/src/skills/auto-continue/SKILL.md +13 -14
  62. package/src/skills/context-compression/SKILL.md +26 -879
  63. package/src/skills/session-goal/SKILL.md +14 -5
  64. package/src/pantheon/secret-scanner.ts +0 -489
  65. package/src/plugins/tui/README.md +0 -14
  66. package/src/skills/council-synthesis/SKILL.md +0 -29
@@ -34,21 +34,15 @@
34
34
  # Env overrides:
35
35
  # PANTHEON_SANDBOX_ROOT Sandbox root (default: ~/pantheon-sandbox)
36
36
  # OPENCODE_V2_SPEC npm spec providing the V2 binary
37
- # (default: @opencode-ai/cli@beta). NOTE: this is an
37
+ # (default: @opencode/cli@latest). NOTE: this is an
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). 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).
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.
52
46
  # PANTHEON_SANDBOX_MODEL Model used by init/prompts
53
47
  # (default: opencode-go/mimo-v2.5)
54
48
  # PANTHEON_PROMPT_TIMEOUT Per-prompt timeout in seconds (default: 300)
@@ -57,20 +51,19 @@
57
51
  # 2 = usage error, 3 = sandbox not prepared.
58
52
  set -euo pipefail
59
53
 
60
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
54
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
61
55
  # Derived from this script's own location so the sandbox is always built from
62
56
  # the checkout that CONTAINS this harness. It must never be inferred from a
63
57
  # sibling directory: a sibling may be a different checkout (or a different
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"
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)"
68
61
 
69
62
  SANDBOX_ROOT="${PANTHEON_SANDBOX_ROOT:-$HOME/pantheon-sandbox}"
70
63
  SANDBOX_HOME="$SANDBOX_ROOT/home"
71
64
  NPM_PREFIX="$SANDBOX_HOME/.npm-global"
72
65
 
73
- OPENCODE_V2_SPEC="${OPENCODE_V2_SPEC:-@opencode-ai/cli@beta}"
66
+ OPENCODE_V2_SPEC="${OPENCODE_V2_SPEC:-@opencode/cli@latest}"
74
67
  PANTHEON_SANDBOX_MODEL="${PANTHEON_SANDBOX_MODEL:-opencode-go/mimo-v2.5}"
75
68
  PANTHEON_PROMPT_TIMEOUT="${PANTHEON_PROMPT_TIMEOUT:-300}"
76
69
 
@@ -79,11 +72,8 @@ V2_BIN="${OPENCODE_V2_BIN:-opencode2}"
79
72
  # OpenCode V2's CLI talks to its background service for `mcp list`. Keep the
80
73
  # probe hermetic: an inherited config, database, or PORT can otherwise make
81
74
  # the command query a different service and report a false result.
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
75
+ V2_CONFIG="${PANTHEON_V2_CONFIG:-$SANDBOX_ROOT/project-v2/opencode.json}"
76
+ V2_DB="${PANTHEON_V2_DB:-$SANDBOX_ROOT/opencode-v2.db}"
87
77
  V2_PORT="${PANTHEON_V2_PORT:-49376}"
88
78
  V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
89
79
  V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
@@ -108,7 +98,6 @@ MODE_COST=0
108
98
  MODE_REHYDRATE=0
109
99
  MODE_HOOKS=0
110
100
  RUN_VERSION=""
111
- SANDBOX_ENV_READY=0
112
101
 
113
102
  usage() {
114
103
  sed -n '2,45p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
@@ -119,60 +108,8 @@ log() { printf '%s\n' "$*"; }
119
108
  warn() { printf 'WARN: %s\n' "$*" >&2; }
120
109
  die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
121
110
 
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
-
141
111
  project_dir() { printf '%s/project-%s' "$SANDBOX_ROOT" "$TARGET_VERSION"; }
142
112
 
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
-
176
113
  # Resolve a binary STRICTLY inside the sandbox npm prefix — never fall back to
177
114
  # the dev PATH, otherwise a non-prepared sandbox would silently test the host
178
115
  # installation.
@@ -192,45 +129,10 @@ sandbox_bin() { # echoes the V2 binary path; rc 1 if missing
192
129
  # ── Sandbox environment isolation ─────────────────────────────────────────────
193
130
 
194
131
  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
224
132
  export HOME="$SANDBOX_HOME"
225
133
  export PATH="$NPM_PREFIX/bin:$HOME/.config/opencode/.venv/bin:$PATH"
226
134
  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"
232
135
  export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
233
- export XDG_CACHE_HOME="$SANDBOX_HOME/.cache"
234
136
  # Do not inherit an OpenChamber/developer-machine config selector. The V2
235
137
  # project config is created by prepare_project and is the only config this
236
138
  # gate is allowed to inspect.
@@ -238,11 +140,8 @@ sandbox_env() {
238
140
  export OPENCODE_DB="$V2_DB"
239
141
  export PANTHEON_V2_PORT="$V2_PORT"
240
142
  export PORT="$V2_PORT"
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
143
+ unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE
144
+ mkdir -p "$SANDBOX_ROOT" "$(dirname "$V2_DB")" "$(dirname "$V2_SERVICE_STATE")"
246
145
  }
247
146
 
248
147
  stop_v2_service() {
@@ -431,7 +330,6 @@ start_v2_service() { # binary project
431
330
  if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
432
331
  return 0
433
332
  fi
434
- validate_v2_port
435
333
 
436
334
  # Never attach to an unrelated service on the dedicated port. A successful
437
335
  # health probe before our process starts is an environmental collision, not
@@ -440,7 +338,6 @@ start_v2_service() { # binary project
440
338
  "http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
441
339
  die "dedicated V2 port ${V2_PORT} is already in use; refusing to reuse an unrelated service"
442
340
  fi
443
- assert_v2_port_available
444
341
 
445
342
  rm -f "$V2_SERVER_LOG" "$V2_SERVICE_STATE" "$HOME/.local/share/opencode/log/opencode.log"
446
343
  (cd "$project" && "$bin" serve --hostname "$V2_LOOPBACK_HOST" --port "$V2_PORT" --service) \
@@ -475,67 +372,49 @@ trap stop_v2_service EXIT
475
372
 
476
373
  # ── Gate (b): sandbox run-test.sh with pantheon://agents content validation ──
477
374
 
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
-
494
375
  write_run_test_sh() {
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'
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'
514
385
  #!/usr/bin/env bash
515
386
  # Generated by pantheon-opencode scripts/test-opencode-v2-sandbox.sh --prepare
516
387
  # Validates the global install: the V2 binary, exactly 5 connected MCPs, doctor
517
388
  # 0 errors, AND the CONTENT of pantheon://agents (zeus/hermes present).
518
389
  set -euo pipefail
519
390
 
520
- SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd -P)"
391
+ SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd)"
521
392
  SANDBOX_HOME="$SANDBOX_DIR/home"
522
393
  NPM_PREFIX="$SANDBOX_HOME/.npm-global"
523
394
  PANTHEON_GLOBAL="$SANDBOX_HOME/.config/opencode"
524
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
+
525
410
  export HOME="$SANDBOX_HOME"
526
411
  export PATH="$NPM_PREFIX/bin:$SANDBOX_VENV/bin:$PATH"
527
412
  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"
533
413
  export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
534
- export XDG_CACHE_HOME="$SANDBOX_HOME/.cache"
535
414
  # The V2 CLI resolves `mcp list` through its service. Keep generated sandbox
536
415
  # runs isolated from any config/database/port inherited from the host.
537
416
  V2_CONFIG="$SANDBOX_DIR/project-v2/opencode.json"
538
- V2_DB="$SANDBOX_DIR/opencode-v2.db"
417
+ V2_DB="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-v2.db}"
539
418
  V2_PORT="${PANTHEON_V2_PORT:-49376}"
540
419
  V2_LOOPBACK_HOST="${PANTHEON_V2_HOST:-127.0.0.1}"
541
420
  V2_HANDSHAKE_TIMEOUT="${PANTHEON_V2_HANDSHAKE_TIMEOUT:-60}"
@@ -543,153 +422,12 @@ V2_MCP_LIST_TIMEOUT="${PANTHEON_V2_MCP_LIST_TIMEOUT:-15}"
543
422
  V2_SERVER_LOG="$SANDBOX_DIR/v2-server.log"
544
423
  V2_SERVICE_STATE="$SANDBOX_HOME/.local/state/opencode/service.json"
545
424
  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
-
626
425
  export OPENCODE_CONFIG_DIR="$(dirname "$V2_CONFIG")"
627
426
  export OPENCODE_DB="$V2_DB"
628
427
  export PANTHEON_V2_PORT="$V2_PORT"
629
428
  export PORT="$V2_PORT"
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"
429
+ unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE
430
+ mkdir -p "$(dirname "$V2_SERVICE_STATE")"
693
431
 
694
432
  stop_v2_service() {
695
433
  if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
@@ -732,8 +470,6 @@ wait_for_v2_service_registration() {
732
470
  python3 - <<'PY'
733
471
  import json
734
472
  import os
735
- import stat
736
- import tempfile
737
473
  from pathlib import Path
738
474
 
739
475
  path = Path(os.environ["SERVICE_STATE"])
@@ -823,13 +559,11 @@ start_v2_service() {
823
559
  if [ -n "$V2_SERVER_PID" ] && kill -0 "$V2_SERVER_PID" 2>/dev/null; then
824
560
  return 0
825
561
  fi
826
- validate_v2_port || return 1
827
562
  if curl --fail --silent --show-error --max-time 1 \
828
563
  "http://$V2_LOOPBACK_HOST:${V2_PORT}/global/health" >/dev/null 2>&1; then
829
564
  echo "ERROR: dedicated V2 port $V2_PORT is already in use" >&2
830
565
  return 1
831
566
  fi
832
- assert_v2_port_available || return 1
833
567
  rm -f "$V2_SERVER_LOG" "$V2_SERVICE_STATE" "$HOME/.local/share/opencode/log/opencode.log"
834
568
  (cd "$project" && "$bin" serve --hostname "$V2_LOOPBACK_HOST" --port "$V2_PORT" --service) \
835
569
  >"$V2_SERVER_LOG" 2>&1 &
@@ -903,8 +637,6 @@ rewrite_v2_mcp_config() {
903
637
  python3 - <<'PY'
904
638
  import json
905
639
  import os
906
- import stat
907
- import tempfile
908
640
  from pathlib import Path
909
641
 
910
642
  config_path = Path(os.environ["CONFIG_PATH"])
@@ -931,20 +663,9 @@ for name, script in managed.items():
931
663
  entry.pop("disabled", None)
932
664
  entry.update(type="local", cwd=runtime_dir, command=[python_bin, f"scripts/{script}"], enabled=True)
933
665
  mcp[name] = entry
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
666
+ tmp_path = config_path.with_name(config_path.name + ".sandbox-tmp")
667
+ tmp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
668
+ os.replace(tmp_path, config_path)
948
669
  validated = json.loads(config_path.read_text(encoding="utf-8"))
949
670
  for name, script in managed.items():
950
671
  entry = validated.get("mcp", {}).get(name)
@@ -1015,7 +736,8 @@ check_mcp() { # label binary project
1015
736
  if ! wait_for_v2_mcp_registrations "$project/.opencode" "$project/.venv/bin/python3"; then
1016
737
  kill "$list_pid" 2>/dev/null || true
1017
738
  wait "$list_pid" 2>/dev/null || true
1018
- rm -f "$list_file"
739
+ echo "--- mcp list output (preserved at $list_file) ---" >&2
740
+ cat "$list_file" >&2 || true
1019
741
  record_status "$label MCP" "FAIL" "MCP registrations did not match the active sandbox"
1020
742
  return 0
1021
743
  fi
@@ -1024,7 +746,8 @@ check_mcp() { # label binary project
1024
746
  else
1025
747
  kill "$list_pid" 2>/dev/null || true
1026
748
  wait "$list_pid" 2>/dev/null || true
1027
- rm -f "$list_file"
749
+ echo "--- mcp list output (preserved at $list_file) ---" >&2
750
+ cat "$list_file" >&2 || true
1028
751
  record_status "$label MCP" "FAIL" "MCP handshakes did not become ready after mcp list started"
1029
752
  return 0
1030
753
  fi
@@ -1078,44 +801,22 @@ check_mcp() { # label binary project
1078
801
  echo "--- Binaries ---"
1079
802
  check_binary "V2 binary" opencode2
1080
803
 
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
-
1113
804
  echo "--- Building tarball ---"
1114
- install_pantheon_package
805
+ cd "$REPO_DIR"
806
+ npm pack --ignore-scripts 2>/dev/null || { echo "ERROR: npm pack failed"; exit 1; }
807
+ TGZ=$(ls -t pantheon-opencode-*.tgz | head -1)
808
+ npm rm -g pantheon-opencode 2>/dev/null || true
809
+ npm install -g "$TGZ"
810
+ if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$TGZ" >/dev/null 2>&1; then rm -f -- "$TGZ"; fi
1115
811
 
1116
812
  echo "--- Init (headless) ---"
1117
813
  pantheon-opencode init --headless -y
1118
814
  rewrite_v2_mcp_config
815
+ node "$REPO_DIR/scripts/validate-sandbox-install.mjs" \
816
+ --repo "$REPO_DIR" \
817
+ --install "$NPM_PREFIX/lib/node_modules/pantheon-opencode" \
818
+ --project-config "$V2_CONFIG" \
819
+ --config "$PANTHEON_GLOBAL/opencode.json"
1119
820
 
1120
821
  echo "--- MCP Validation ---"
1121
822
  check_mcp "V2" opencode2 "$SANDBOX_DIR/project-v2"
@@ -1177,33 +878,18 @@ if [ "$FINAL_VERDICT" != "PASS" ]; then
1177
878
  fi
1178
879
  echo "Next: open the isolated TUI with: $SANDBOX_DIR/start-pantheon.sh"
1179
880
  RUNTEST
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
- }
881
+ chmod +x "$SANDBOX_ROOT/run-test.sh"
1187
882
 
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'
883
+ cat > "$SANDBOX_ROOT/start-pantheon.sh" <<'STARTPANTHEON'
1198
884
  #!/usr/bin/env bash
885
+ # Generated by pantheon-opencode scripts/test-opencode-v2-sandbox.sh --prepare
886
+ # Launches the installed OpenCode beta with only this sandbox's HOME/config/db.
1199
887
  set -euo pipefail
1200
888
 
1201
- SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd -P)"
889
+ SANDBOX_DIR="$(cd "$(dirname "$0")" && pwd)"
1202
890
  SANDBOX_HOME="$SANDBOX_DIR/home"
1203
891
  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}"
892
+ SANDBOX_VENV="$SANDBOX_HOME/.config/opencode/.venv"
1207
893
  V2_PORT="${PANTHEON_V2_PORT:-49376}"
1208
894
  if [[ ! "$V2_PORT" =~ ^[0-9]{1,5}$ ]]; then
1209
895
  echo "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')" >&2
@@ -1214,151 +900,118 @@ if [ "$V2_PORT" -lt 1 ] || [ "$V2_PORT" -gt 65535 ]; then
1214
900
  echo "ERROR: PANTHEON_V2_PORT must be an integer from 1 through 65535 (got '$V2_PORT')" >&2
1215
901
  exit 1
1216
902
  fi
1217
- if ! python3 - "$V2_HOST" "$V2_PORT" <<'PY'
1218
- import ipaddress
903
+ if ! python3 - "$V2_PORT" "$NPM_PREFIX" <<'CHECKPORT'
904
+ import errno
905
+ import os
1219
906
  import socket
1220
907
  import sys
908
+ from urllib.request import urlopen
1221
909
 
1222
- host, raw_port = sys.argv[1], sys.argv[2]
910
+ port = int(sys.argv[1])
911
+ cli_prefix = os.path.realpath(
912
+ os.path.join(sys.argv[2], "lib", "node_modules", "@opencode", "cli")
913
+ )
1223
914
  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
915
  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
916
+ # A just-stopped service can leave a harmless TIME_WAIT socket behind.
917
+ # SO_REUSEADDR permits the new service to bind that port without
918
+ # masking a live listener or an unrelated server.
919
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
920
+ probe.bind(("127.0.0.1", port))
921
+ except OSError as exc:
922
+ if exc.errno != errno.EADDRINUSE:
923
+ print(f"ERROR: cannot reserve dedicated V2 port 127.0.0.1:{port}: {exc}", file=sys.stderr)
924
+ raise SystemExit(1)
925
+
926
+ # OpenCode intentionally keeps its managed service alive after the TUI exits.
927
+ # Reuse it only when /proc proves the listener is this sandbox's CLI binary.
928
+ inodes = set()
929
+ for table in ("/proc/net/tcp", "/proc/net/tcp6"):
930
+ try:
931
+ with open(table, encoding="ascii") as entries:
932
+ next(entries, None)
933
+ for line in entries:
934
+ fields = line.split()
935
+ if len(fields) >= 10 and int(fields[1].rsplit(":", 1)[1], 16) == port and fields[3] == "0A":
936
+ inodes.add(fields[9])
937
+ except OSError:
938
+ pass
939
+
940
+ listener_pids = set()
941
+ for pid in (entry for entry in os.listdir("/proc") if entry.isdigit()):
942
+ fd_dir = f"/proc/{pid}/fd"
943
+ try:
944
+ for fd in os.listdir(fd_dir):
945
+ try:
946
+ target = os.readlink(os.path.join(fd_dir, fd))
947
+ except OSError:
948
+ continue
949
+ if target.startswith("socket:[") and target[8:-1] in inodes:
950
+ listener_pids.add(pid)
951
+ break
952
+ except OSError:
953
+ continue
954
+
955
+ owns_listener = False
956
+ for pid in listener_pids:
957
+ try:
958
+ with open(f"/proc/{pid}/cmdline", "rb") as command_line:
959
+ args = [arg.decode(errors="replace") for arg in command_line.read().split(b"\0") if arg]
960
+ except OSError:
961
+ continue
962
+ executable = os.path.realpath(args[0]) if args else ""
963
+ if (
964
+ executable.startswith(cli_prefix + os.sep)
965
+ and "serve" in args
966
+ and "--service" in args
967
+ and len(listener_pids) == 1
968
+ ):
969
+ owns_listener = True
970
+ break
971
+
972
+ healthy = False
973
+ if owns_listener:
974
+ try:
975
+ scheme = "http"
976
+ loopback = ".".join(("127", "0", "0", "1"))
977
+ with urlopen(f"{scheme}://{loopback}:{port}/global/health", timeout=2) as response:
978
+ healthy = response.status == 200
979
+ except Exception:
980
+ pass
981
+
982
+ if not healthy:
983
+ print(
984
+ f"ERROR: dedicated V2 port 127.0.0.1:{port} is occupied by a different or unverifiable service; no service was stopped",
985
+ file=sys.stderr,
986
+ )
987
+ raise SystemExit(1)
988
+ print(f"Reusing the existing beta sandbox OpenCode service on 127.0.0.1:{port} (PID {next(iter(listener_pids))}).")
989
+ CHECKPORT
1233
990
  then
1234
991
  exit 1
1235
992
  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
993
 
1239
994
  export HOME="$SANDBOX_HOME"
1240
- export PATH="$NPM_PREFIX/bin:$SANDBOX_HOME/.config/opencode/.venv/bin:$PATH"
995
+ export PATH="$NPM_PREFIX/bin:$SANDBOX_VENV/bin:$PATH"
1241
996
  export npm_config_prefix="$NPM_PREFIX"
1242
- export npm_config_cache="$SANDBOX_HOME/.npm-cache"
1243
997
  export XDG_CONFIG_HOME="$SANDBOX_HOME/.config"
1244
998
  export XDG_CONFIG_DIRS="$SANDBOX_HOME/.config/xdg"
1245
999
  export XDG_DATA_HOME="$SANDBOX_HOME/.local/share"
1246
1000
  export XDG_DATA_DIRS="$SANDBOX_HOME/.local/share/xdg"
1247
1001
  export XDG_STATE_HOME="$SANDBOX_HOME/.local/state"
1248
1002
  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"
1003
+ export OPENCODE_CONFIG_DIR="$SANDBOX_DIR/project-v2"
1004
+ export OPENCODE_DB="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-v2.db}"
1252
1005
  export PANTHEON_V2_PORT="$V2_PORT"
1253
- export PORT="$V2_PORT"
1254
- unset PANTHEON_V2_CONFIG PANTHEON_V2_DB
1006
+ export PORT="$PANTHEON_V2_PORT"
1255
1007
  unset OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE \
1256
1008
  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
1009
 
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"
1010
+ "$NPM_PREFIX/bin/opencode2" service set port "$V2_PORT"
1011
+ cd "$SANDBOX_DIR/project-v2"
1012
+ exec "$NPM_PREFIX/bin/opencode2" "$@"
1013
+ STARTPANTHEON
1014
+ chmod +x "$SANDBOX_ROOT/start-pantheon.sh"
1362
1015
  }
1363
1016
 
1364
1017
  # ── Reset / Prepare ───────────────────────────────────────────────────────────
@@ -1408,12 +1061,6 @@ canonicalize_path() {
1408
1061
  printf '%s\n' "$resolved"
1409
1062
  }
1410
1063
 
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
-
1417
1064
  # Compare complete path components; a sibling such as project-extra is not
1418
1065
  # inside project. The root directory is a parent of every absolute path.
1419
1066
  path_is_same_or_descendant() {
@@ -1426,267 +1073,65 @@ path_is_same_or_descendant() {
1426
1073
  return 1
1427
1074
  }
1428
1075
 
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
1076
+ cmd_reset() {
1077
+ # Guard BOTH repos this harness can act on. REPO_DIR is where `--prepare`
1078
+ # runs `npm pack`; PANTHEON_REPO is the documented override the GENERATED
1079
+ # run-test.sh honours, and it may point somewhere else entirely. Refusing
1080
+ # only the default left an override able to name a checkout and have the
1081
+ # generated script pack a tarball into it.
1082
+ local protected canonical_root canonical_home canonical_protected
1560
1083
  canonical_root="$(canonicalize_path "$SANDBOX_ROOT")" \
1561
- || die "refusing to $action sandbox root that cannot be canonicalized: $SANDBOX_ROOT"
1084
+ || die "refusing to reset sandbox root that cannot be canonicalized: $SANDBOX_ROOT"
1562
1085
  canonical_home="$(canonicalize_path "$HOME")" \
1563
- || die "refusing to $action sandbox because HOME cannot be canonicalized: $HOME"
1086
+ || die "refusing to reset because HOME cannot be canonicalized: $HOME"
1564
1087
  [ "$canonical_root" != "/" ] \
1565
- || die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (filesystem root)"
1088
+ || die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (filesystem root)"
1566
1089
  [ "$canonical_root" != "$canonical_home" ] \
1567
- || die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (HOME)"
1090
+ || die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (HOME)"
1568
1091
  if path_is_same_or_descendant "$canonical_home" "$canonical_root"; then
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
1092
+ die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of HOME $HOME)"
1607
1093
  fi
1608
1094
 
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
1616
- [ -n "$protected" ] || continue
1617
- canonical_protected="$(canonicalize_path "$protected")" \
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
1095
  for protected in "$REPO_DIR" "${PANTHEON_REPO:-}"; do
1626
1096
  [ -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"
1097
+ canonical_protected="$(canonicalize_path "$protected")" \
1098
+ || die "refusing to reset because protected repo path cannot be canonicalized: $protected"
1629
1099
  if path_is_same_or_descendant "$canonical_root" "$canonical_protected"; then
1630
- die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (it is equal to or inside the repo $protected)"
1100
+ die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is equal to or inside the repo $protected)"
1631
1101
  fi
1632
1102
  if path_is_same_or_descendant "$canonical_protected" "$canonical_root"; then
1633
- die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of the repo $protected)"
1103
+ die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of the repo $protected)"
1634
1104
  fi
1635
1105
  done
1636
- }
1637
-
1638
- cmd_reset() {
1639
- guard_sandbox_root reset
1640
1106
  log "Resetting sandbox: $SANDBOX_ROOT"
1641
- rm -rf -- "$SANDBOX_ROOT"
1107
+ rm -rf "$SANDBOX_ROOT"
1642
1108
  log "Sandbox removed."
1643
1109
  }
1644
1110
 
1645
1111
  install_binaries() {
1646
1112
  log "--- Installing pantheon-opencode (from repo tarball) ---"
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"
1113
+ cd "$REPO_DIR"
1114
+ npm pack --ignore-scripts 2>/dev/null || die "npm pack failed"
1115
+ local tgz
1116
+ tgz=$(ls -t pantheon-opencode-*.tgz | head -1)
1665
1117
  npm rm -g pantheon-opencode 2>/dev/null || true
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"
1118
+ npm install -g "$REPO_DIR/$tgz" || die "install of $tgz failed"
1119
+ if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$tgz" >/dev/null 2>&1; then rm -f -- "$tgz"; fi
1673
1120
  log "--- Installing OpenCode V2 ($OPENCODE_V2_SPEC) ---"
1674
1121
  npm install -g "$OPENCODE_V2_SPEC" || die "install of $OPENCODE_V2_SPEC failed"
1675
1122
  }
1676
1123
 
1677
1124
  prepare_project() {
1678
1125
  local dir pan
1679
- canonicalize_sandbox_root
1680
1126
  dir="$(project_dir)"
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"
1127
+ mkdir -p "$dir"
1684
1128
  pan="$(sandbox_bin_for "pantheon-opencode")" \
1685
1129
  || die "pantheon-opencode not installed in sandbox prefix — run $0 --prepare first"
1686
1130
  log "--- Regenerating config in $dir ---"
1687
1131
  (cd "$dir" && "$pan" init --project --version "$TARGET_VERSION" --model "$PANTHEON_SANDBOX_MODEL" -y) \
1688
1132
  || die "init --project --version $TARGET_VERSION failed in $dir"
1689
1133
  rewrite_v2_mcp_config
1134
+ validate_sandbox_install
1690
1135
  }
1691
1136
 
1692
1137
  # The installer merges an existing project config. A reused sandbox can
@@ -1694,11 +1139,8 @@ prepare_project() {
1694
1139
  # point at a different sandbox or host. Rewrite the managed entries after the
1695
1140
  # merge and validate the complete shape before any OpenCode process starts.
1696
1141
  rewrite_v2_mcp_config() {
1697
- canonicalize_sandbox_root
1698
1142
  local runtime_dir="$(project_dir)/.opencode"
1699
1143
  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"
1702
1144
  [ -f "$V2_CONFIG" ] || die "V2 config was not generated: $V2_CONFIG"
1703
1145
  [ -x "$python_bin" ] || die "V2 MCP Python is missing: $python_bin"
1704
1146
 
@@ -1706,9 +1148,7 @@ rewrite_v2_mcp_config() {
1706
1148
  python3 - <<'PY'
1707
1149
  import json
1708
1150
  import os
1709
- import stat
1710
1151
  import sys
1711
- import tempfile
1712
1152
  from pathlib import Path
1713
1153
 
1714
1154
  config_path = Path(os.environ["CONFIG_PATH"])
@@ -1747,22 +1187,15 @@ for name, script in managed.items():
1747
1187
  )
1748
1188
  mcp[name] = entry
1749
1189
 
1750
- tmp_path = None
1190
+ tmp_path = config_path.with_name(config_path.name + ".sandbox-tmp")
1751
1191
  try:
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))
1192
+ tmp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
1759
1193
  os.replace(tmp_path, config_path)
1760
1194
  except OSError as exc:
1761
- if tmp_path is not None:
1762
- try:
1763
- tmp_path.unlink()
1764
- except OSError:
1765
- pass
1195
+ try:
1196
+ tmp_path.unlink()
1197
+ except OSError:
1198
+ pass
1766
1199
  raise SystemExit(f"cannot rewrite V2 config {config_path}: {exc}")
1767
1200
 
1768
1201
  try:
@@ -1786,14 +1219,19 @@ for name, script in managed.items():
1786
1219
  PY
1787
1220
  }
1788
1221
 
1222
+ validate_sandbox_install() {
1223
+ node "$REPO_DIR/scripts/validate-sandbox-install.mjs" \
1224
+ --repo "$REPO_DIR" \
1225
+ --install "$NPM_PREFIX/lib/node_modules/pantheon-opencode" \
1226
+ --project-config "$V2_CONFIG" \
1227
+ --config "$SANDBOX_HOME/.config/opencode/opencode.json" \
1228
+ || die "sandbox package/config validation failed"
1229
+ }
1230
+
1789
1231
  cmd_prepare() {
1790
- validate_v2_port
1791
- guard_sandbox_root use
1792
- canonicalize_sandbox_root
1793
- sandbox_env
1794
- cleanup_sandbox_tmp
1232
+ mkdir -p "$SANDBOX_HOME" "$NPM_PREFIX"
1795
1233
  write_run_test_sh
1796
- write_sandbox_entrypoints
1234
+ sandbox_env
1797
1235
  install_binaries
1798
1236
  local pan
1799
1237
  pan="$(sandbox_bin_for "pantheon-opencode")" \
@@ -1810,14 +1248,7 @@ cmd_prepare() {
1810
1248
  # ── JSON text extraction (schema-agnostic) ────────────────────────────────────
1811
1249
 
1812
1250
  write_extract_py() {
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'
1251
+ cat > "$EXTRACT_PY" <<'PYEOF'
1821
1252
  """Collect every string value from opencode run --format json output."""
1822
1253
  import json
1823
1254
  import sys
@@ -1849,12 +1280,6 @@ except Exception:
1849
1280
  chunks.append(line)
1850
1281
  print("\n".join(chunks))
1851
1282
  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"
1858
1283
  }
1859
1284
 
1860
1285
  # ── Prompt battery ────────────────────────────────────────────────────────────
@@ -1872,7 +1297,7 @@ add_prompt() { # id marker ignored verify text
1872
1297
  }
1873
1298
 
1874
1299
  build_battery() {
1875
- local v="$1" probe_file probe_shell_arg
1300
+ local v="$1"
1876
1301
  PROMPT_IDS=()
1877
1302
  PROMPT_TEXTS=()
1878
1303
  PROMPT_MARKERS=()
@@ -1883,11 +1308,9 @@ build_battery() {
1883
1308
  "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"
1884
1309
  add_prompt "memory-recall" "MEMORY-RECALL-OK" "" "" \
1885
1310
  "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"
1888
1311
  add_prompt "tmp-write-read" "TMP-OK" "" \
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"
1312
+ "${TMPDIR:-/tmp}/pantheon-sandbox-probe-$v.txt::TMP-OK" \
1313
+ "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"
1891
1314
  add_prompt "delegation" "DELEGATION-OK" "" "" \
1892
1315
  "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"
1893
1316
  }
@@ -1951,9 +1374,6 @@ run_prompt_attempt() { # v idx current_bin — sets ATTEMPT_RESULT / ATTEMPT_DET
1951
1374
  ATTEMPT_RESULT="FAIL"
1952
1375
  ATTEMPT_DETAIL="marker $marker absent; rc=$rc; snippet: $(printf '%s' "$raw" | cut -c1-160)"
1953
1376
  fi
1954
- if [ -n "$verify" ]; then
1955
- rm -f "${verify%%::*}"
1956
- fi
1957
1377
  }
1958
1378
 
1959
1379
  run_prompt() { # v idx current_bin — exactly one attempt
@@ -2413,7 +1833,6 @@ while [ $# -gt 0 ]; do
2413
1833
  done
2414
1834
 
2415
1835
  if [ "$MODE_RESET" -eq 1 ]; then
2416
- resolve_harness_repo
2417
1836
  cmd_reset
2418
1837
  exit 0
2419
1838
  fi
@@ -2424,8 +1843,6 @@ if [ "$MODE_PREPARE" -eq 0 ] && [ -z "$RUN_VERSION" ] \
2424
1843
  usage
2425
1844
  fi
2426
1845
 
2427
- resolve_harness_repo
2428
-
2429
1846
  if [ "$MODE_PREPARE" -eq 1 ]; then
2430
1847
  cmd_prepare
2431
1848
  fi