fix(opencode): lock down hidden subagent tool permissions #12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Smoke (opencode bring-up on Linux) | |
| # Standalone diagnostic workflow. Verifies that `opencode serve` actually | |
| # responds to HTTP after printing "server listening" on a fresh GitHub-hosted | |
| # Linux runner. Runs in 1-3 minutes — fast enough to catch the regression | |
| # pattern that historically broke our host e2e suite for ~20-40 minutes per | |
| # CI run before timing out. | |
| # | |
| # Probes both bun's fetch AND curl independently, with each binding choice | |
| # (127.0.0.1 vs 0.0.0.0) so we can attribute failures correctly: | |
| # | |
| # matrix entry | curl | fetch | meaning | |
| # -----------------------------|------|-------|-------------------------------- | |
| # hostname=127.0.0.1, both ok | ✓ | ✓ | healthy, current host suite would pass | |
| # hostname=127.0.0.1, curl OK | | | | |
| # but fetch fails | ✓ | ✗ | bun fetch on linux has a localhost edge case | |
| # hostname=0.0.0.0, both ok | ✓ | ✓ | binding all interfaces fixes the issue | |
| # hostname=0.0.0.0, both fail | ✗ | ✗ | opencode-on-linux fundamental break | |
| # any: opencode never prints | | | | |
| # "server listening" | n/a | n/a | opencode failed to start (separate class) | |
| # | |
| # The workflow is intentionally NOT gated to anything in ci.yml or release.yml — | |
| # we want clear, isolated signal independent of the broader pipeline. | |
| on: | |
| push: | |
| branches: [master, main] | |
| pull_request: | |
| workflow_dispatch: | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| jobs: | |
| smoke: | |
| name: opencode HTTP probe (${{ matrix.hostname }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 8 | |
| strategy: | |
| # Run both hostname choices even if one fails — we want full diagnostic | |
| # coverage, not bail-on-first-failure. | |
| fail-fast: false | |
| matrix: | |
| hostname: ["127.0.0.1", "0.0.0.0"] | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Install opencode (1.15.4 pin matches host e2e) | |
| run: | | |
| curl -fsSL https://opencode.ai/install | bash -s -- --version 1.15.4 | |
| echo "$HOME/.opencode/bin" >> "$GITHUB_PATH" | |
| - name: Verify opencode binary | |
| run: | | |
| opencode --version | |
| which opencode | |
| - name: Probe opencode HTTP bring-up | |
| env: | |
| HOSTNAME: ${{ matrix.hostname }} | |
| run: | | |
| set -uo pipefail | |
| # Random unprivileged port | |
| PORT=$((30000 + RANDOM % 30000)) | |
| echo "::group::Spawn opencode serve --hostname $HOSTNAME --port $PORT" | |
| # Isolated XDG dirs so the daemon does its first-run SQLite migration | |
| # in a clean state — same as host e2e harness. | |
| WORKDIR=$(mktemp -d) | |
| export XDG_CONFIG_HOME="$WORKDIR/config" | |
| export XDG_DATA_HOME="$WORKDIR/data" | |
| export XDG_CACHE_HOME="$WORKDIR/cache" | |
| mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" | |
| # Strip inherited NODE_ENV=test for the same reason host e2e does. | |
| unset NODE_ENV | |
| export ANTHROPIC_API_KEY="test-key-not-real" | |
| # Tee stdout+stderr to files so we can inspect after probing. | |
| opencode serve --hostname "$HOSTNAME" --port "$PORT" \ | |
| > "$WORKDIR/stdout.log" 2> "$WORKDIR/stderr.log" & | |
| SERVE_PID=$! | |
| echo "opencode serve pid: $SERVE_PID" | |
| echo "::endgroup::" | |
| # Wait up to 30s for opencode to print "listening" — that's the | |
| # signal that Server.listen() returned. | |
| for i in $(seq 1 150); do | |
| if grep -q "opencode server listening on" "$WORKDIR/stdout.log" 2>/dev/null; then | |
| break | |
| fi | |
| sleep 0.2 | |
| done | |
| echo "::group::opencode stdout (post-listen window)" | |
| cat "$WORKDIR/stdout.log" | |
| echo "::endgroup::" | |
| echo "::group::opencode stderr (post-listen window)" | |
| cat "$WORKDIR/stderr.log" | |
| echo "::endgroup::" | |
| if ! grep -q "opencode server listening on" "$WORKDIR/stdout.log"; then | |
| echo "::error::opencode never printed 'listening on' within 30s — process failed to start" | |
| kill -TERM "$SERVE_PID" 2>/dev/null || true | |
| exit 1 | |
| fi | |
| # === Probe matrix: try both 127.0.0.1 and (if hostname=0.0.0.0) | |
| # also localhost. Test BOTH curl and bun's fetch independently. | |
| echo "::group::HTTP probes" | |
| CURL_127=0 | |
| CURL_LOCALHOST=0 | |
| BUN_127=0 | |
| BUN_LOCALHOST=0 | |
| # curl 127.0.0.1 | |
| if curl -fsS --max-time 5 "http://127.0.0.1:${PORT}/doc" > /dev/null 2>&1; then | |
| CURL_127=1 | |
| echo " curl http://127.0.0.1:${PORT}/doc → OK" | |
| else | |
| echo " curl http://127.0.0.1:${PORT}/doc → FAIL" | |
| fi | |
| # curl localhost (different name resolution path) | |
| if curl -fsS --max-time 5 "http://localhost:${PORT}/doc" > /dev/null 2>&1; then | |
| CURL_LOCALHOST=1 | |
| echo " curl http://localhost:${PORT}/doc → OK" | |
| else | |
| echo " curl http://localhost:${PORT}/doc → FAIL" | |
| fi | |
| # bun fetch 127.0.0.1 | |
| if bun -e "const r = await fetch('http://127.0.0.1:${PORT}/doc'); console.log('status', r.status); if (!r.ok && r.status !== 404 && r.status !== 401) process.exit(1);" 2>&1; then | |
| BUN_127=1 | |
| echo " bun fetch http://127.0.0.1:${PORT}/doc → OK" | |
| else | |
| echo " bun fetch http://127.0.0.1:${PORT}/doc → FAIL" | |
| fi | |
| # bun fetch localhost | |
| if bun -e "const r = await fetch('http://localhost:${PORT}/doc'); console.log('status', r.status); if (!r.ok && r.status !== 404 && r.status !== 401) process.exit(1);" 2>&1; then | |
| BUN_LOCALHOST=1 | |
| echo " bun fetch http://localhost:${PORT}/doc → OK" | |
| else | |
| echo " bun fetch http://localhost:${PORT}/doc → FAIL" | |
| fi | |
| echo "::endgroup::" | |
| # Cleanup | |
| kill -TERM "$SERVE_PID" 2>/dev/null || true | |
| sleep 1 | |
| kill -KILL "$SERVE_PID" 2>/dev/null || true | |
| # Summary in step output for easy table viewing | |
| echo "## Probe results (hostname=${HOSTNAME})" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| probe | result |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| curl 127.0.0.1 | $( [ "$CURL_127" = 1 ] && echo "✅" || echo "❌" ) |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| curl localhost | $( [ "$CURL_LOCALHOST" = 1 ] && echo "✅" || echo "❌" ) |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| bun 127.0.0.1 | $( [ "$BUN_127" = 1 ] && echo "✅" || echo "❌" ) |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| bun localhost | $( [ "$BUN_LOCALHOST" = 1 ] && echo "✅" || echo "❌" ) |" >> "$GITHUB_STEP_SUMMARY" | |
| # Fail the step if NEITHER probe reached the server. If curl | |
| # works but fetch fails (or vice versa), surface the asymmetry | |
| # via a clear annotation but DO NOT fail — that's diagnostic | |
| # signal we want to capture, not a green/red gate. | |
| ANY_OK=0 | |
| [ "$CURL_127" = 1 ] && ANY_OK=1 | |
| [ "$CURL_LOCALHOST" = 1 ] && ANY_OK=1 | |
| [ "$BUN_127" = 1 ] && ANY_OK=1 | |
| [ "$BUN_LOCALHOST" = 1 ] && ANY_OK=1 | |
| if [ "$ANY_OK" = 0 ]; then | |
| echo "::error::Neither curl nor bun fetch could reach opencode (hostname=$HOSTNAME). This is a real bring-up failure." | |
| exit 1 | |
| fi | |
| if [ "$CURL_127" = 1 ] && [ "$BUN_127" = 0 ]; then | |
| echo "::warning::curl reaches 127.0.0.1 but bun fetch does not — bun's HTTP client has a Linux loopback edge case." | |
| fi | |
| if [ "$BUN_LOCALHOST" = 1 ] && [ "$BUN_127" = 0 ]; then | |
| echo "::warning::bun fetch works on 'localhost' but not '127.0.0.1' — bun's name resolution differs from raw IPv4 path." | |
| fi | |
| echo "Probe pass for hostname=$HOSTNAME" |