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.
Files changed (62) hide show
  1. package/AGENTS.md +27 -820
  2. package/CHANGELOG.md +130 -0
  3. package/README.md +205 -30
  4. package/README.pt-BR.md +47 -23
  5. package/package.json +10 -5
  6. package/plugin.json +1 -1
  7. package/pyproject.toml +3 -1
  8. package/scripts/build-agents-md.mjs +162 -28
  9. package/scripts/doctor.mjs +104 -22
  10. package/scripts/install/agents-md.mjs +2 -2
  11. package/scripts/install/config-migration.mjs +154 -7
  12. package/scripts/install/opencode.mjs +202 -40
  13. package/scripts/install/shared.mjs +115 -7
  14. package/scripts/install/strings.mjs +8 -0
  15. package/scripts/probe-pantheon-cost.mjs +16 -3
  16. package/scripts/run-node-tests.mjs +189 -0
  17. package/scripts/sync-artifacts.mjs +2 -2
  18. package/scripts/test-opencode-v2-sandbox.sh +820 -86
  19. package/skills-lock.json +6 -5
  20. package/src/agents/aphrodite.md +17 -28
  21. package/src/agents/athena.md +1 -0
  22. package/src/agents/demeter.md +8 -18
  23. package/src/agents/hephaestus.md +10 -11
  24. package/src/agents/hermes.md +15 -30
  25. package/src/agents/iris.md +1 -0
  26. package/src/agents/nyx.md +1 -0
  27. package/src/agents/prometheus.md +16 -10
  28. package/src/agents/talos.md +4 -1
  29. package/src/agents/themis.md +5 -3
  30. package/src/agents/zeus.md +17 -280
  31. package/src/instructions/agent-return-format.instructions.md +4 -50
  32. package/src/instructions/backend-standards.instructions.md +2 -0
  33. package/src/instructions/frontend-standards.instructions.md +3 -1
  34. package/src/instructions/memory-protocol.instructions.md +12 -121
  35. package/src/instructions/zeus-anti-stall.instructions.md +8 -111
  36. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  37. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  38. package/src/instructions/zeus-timeout-retry.instructions.md +9 -130
  39. package/src/pantheon/secret-scanner.ts +489 -0
  40. package/src/pantheon/v2-delegation-enforce.ts +86 -0
  41. package/src/pantheon/v2-execute-after.ts +186 -0
  42. package/src/pantheon/v2-unsupported.mjs +0 -23
  43. package/src/plugin-v2.ts +254 -102
  44. package/src/plugins/pantheon-hooks.ts +82 -35
  45. package/src/plugins/tui/README.md +14 -0
  46. package/src/plugins/tui/dist/tui.d.ts +11 -8
  47. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  48. package/src/plugins/tui/dist/tui.js +102 -62
  49. package/src/plugins/tui/package-lock.json +30 -33
  50. package/src/plugins/tui/package.json +5 -1
  51. package/src/plugins/tui/src/index.tsx +163 -107
  52. package/src/plugins/tui/tsdown.config.ts +17 -0
  53. package/src/routing.yml +12 -9
  54. package/src/skills/README.md +9 -4
  55. package/src/skills/agent-coordination/SKILL.md +13 -83
  56. package/src/skills/artifact-management/SKILL.md +12 -5
  57. package/src/skills/auto-continue/SKILL.md +38 -28
  58. package/src/skills/code-review-checklist/SKILL.md +5 -5
  59. package/src/skills/council-synthesis/SKILL.md +29 -0
  60. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  61. package/scripts/hooks/scan-secrets.sh +0 -97
  62. 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). 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.
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, and --prepare runs `npm pack` inside
59
- # it, writing a tarball into that tree.
60
- REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
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
- V2_CONFIG="${PANTHEON_V2_CONFIG:-$SANDBOX_ROOT/project-v2/opencode.json}"
76
- V2_DB="${PANTHEON_V2_DB:-$SANDBOX_ROOT/opencode-v2.db}"
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
- mkdir -p "$SANDBOX_ROOT" "$(dirname "$V2_DB")" "$(dirname "$V2_SERVICE_STATE")"
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
- # 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'
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="${PANTHEON_V2_DB:-$SANDBOX_DIR/opencode-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 OPENCODE_CONFIG OPENCODE_CONFIG_CONTENT OPENCODE_CONFIG_PROJECT_DISABLE
429
- mkdir -p "$(dirname "$V2_SERVICE_STATE")"
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
- tmp_path = config_path.with_name(config_path.name + ".sandbox-tmp")
666
- tmp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
667
- os.replace(tmp_path, config_path)
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
- cd "$REPO_DIR"
803
- npm pack --ignore-scripts 2>/dev/null || { echo "ERROR: npm pack failed"; exit 1; }
804
- TGZ=$(ls -t pantheon-opencode-*.tgz | head -1)
805
- npm rm -g pantheon-opencode 2>/dev/null || true
806
- npm install -g "$TGZ"
807
- if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$TGZ" >/dev/null 2>&1; then rm -f -- "$TGZ"; fi
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
- chmod +x "$SANDBOX_ROOT/run-test.sh"
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
- cmd_reset() {
936
- # Guard BOTH repos this harness can act on. REPO_DIR is where `--prepare`
937
- # runs `npm pack`; PANTHEON_REPO is the documented override the GENERATED
938
- # run-test.sh honours, and it may point somewhere else entirely. Refusing
939
- # only the default left an override able to name a checkout and have the
940
- # generated script pack a tarball into it.
941
- local protected canonical_root canonical_home canonical_protected
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 reset sandbox root that cannot be canonicalized: $SANDBOX_ROOT"
1561
+ || die "refusing to $action sandbox root that cannot be canonicalized: $SANDBOX_ROOT"
944
1562
  canonical_home="$(canonicalize_path "$HOME")" \
945
- || die "refusing to reset because HOME cannot be canonicalized: $HOME"
1563
+ || die "refusing to $action sandbox because HOME cannot be canonicalized: $HOME"
946
1564
  [ "$canonical_root" != "/" ] \
947
- || die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (filesystem root)"
1565
+ || die "refusing to $action unsafe sandbox root: $SANDBOX_ROOT (filesystem root)"
948
1566
  [ "$canonical_root" != "$canonical_home" ] \
949
- || die "refusing to reset unsafe sandbox root: $SANDBOX_ROOT (HOME)"
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 reset unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of HOME $HOME)"
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 "$REPO_DIR" "${PANTHEON_REPO:-}"; do
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 reset because protected repo path cannot be canonicalized: $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
+ 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 reset unsafe sandbox root: $SANDBOX_ROOT (it is equal to or inside the repo $protected)"
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 reset unsafe sandbox root: $SANDBOX_ROOT (it is an ancestor of the repo $protected)"
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
- cd "$REPO_DIR"
973
- npm pack --ignore-scripts 2>/dev/null || die "npm pack failed"
974
- local tgz
975
- tgz=$(ls -t pantheon-opencode-*.tgz | head -1)
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 "$REPO_DIR/$tgz" || die "install of $tgz failed"
978
- if ! git -C "$REPO_DIR" ls-files --error-unmatch -- "$tgz" >/dev/null 2>&1; then rm -f -- "$tgz"; fi
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
- mkdir -p "$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"
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 = config_path.with_name(config_path.name + ".sandbox-tmp")
1750
+ tmp_path = None
1049
1751
  try:
1050
- tmp_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
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
- try:
1054
- tmp_path.unlink()
1055
- except OSError:
1056
- pass
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
- mkdir -p "$SANDBOX_HOME" "$NPM_PREFIX"
1082
- write_run_test_sh
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
- cat > "$EXTRACT_PY" <<'PYEOF'
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
- "${TMPDIR:-/tmp}/pantheon-sandbox-probe-$v.txt::TMP-OK" \
1162
- "Using the bash tool, run a python3 -c command that writes the text TMP-OK to the file ${TMPDIR:-/tmp}/pantheon-sandbox-probe-$v.txt (overwrite it), then run another python3 -c command to read that file back. Do not use any other tool or command to write or read the file — only python3 -c. If what you read back is exactly TMP-OK, reply with exactly: TMP-OK"
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