Skip to content

serve: /api/pty leaks orphan bash processes when connect-token is denied #50699

Open

Description

@Mlte0907

Summary

opencode serve accumulates orphaned /usr/bin/bash -l processes when PTY
sessions are created but POST /api/pty/{id}/connect-token is denied (403).
In one observed window, 1037 sessions were created in 49 minutes, connect
failed every time, and 846 processes (~1.6 GB PSS, 846 pty devices) were still
running 8+ hours later until the service was restarted. Nothing reaps
never-connected sessions.

Environment

  • opencode version: v2.0.10
  • OS: Linux 6.19.14-edge-sm8250 aarch64 (Xiaomi elish)
  • Terminal: headless opencode serve --service (no TUI attached); TERM=xterm-256color, TERM_PROGRAM unset
  • Shell: /usr/bin/bash
  • Install/channel: latest release (official installer, ~/.opencode/bin/opencode)
  • Active plugins: none found (opencode.json has no plugins entry; no local plugin dirs)

Reproduction

  1. Start opencode serve --service (listens on 0.0.0.0:4096).
  2. From another host, POST /api/pty → succeeds; server logs
    message="creating session" id=pty_... cmd=/usr/bin/bash args=["-l"]
    and spawns the process.
  3. POST /api/pty/{id}/connect-token → 403 (also observed 404 on
    subsequent PUT /api/pty/{id}).
  4. The client never attaches and repeats steps 2–3
    (observed 1037 creates between 2026-09-22T09:17:25Z and 10:06:16Z,
    peak 177 connect-token 403s in one minute).
  5. ps shows the count of /usr/bin/bash -l children of the serve process
    growing monotonically; after the client stops, they persist indefinitely.

Expected Behavior

A session whose connect-token is denied, or that is never attached, should be
torn down promptly and its process reaped. Concurrent/unattached PTY sessions
should be bounded (max sessions / TTL), so a misbehaving client cannot grow
resource usage without limit.

Actual Behavior

  • message="creating session": 1037 exact matches, all cmd=/usr/bin/bash args=["-l"] (1035 within the 49-minute window; 2 earlier on 2026-09-19).
  • Every logged connect-token response was an error:
    403 × 1033, 404 × 23, no 2xx observed.
  • 846 orphan bash -l processes still alive 8+ hours after creation, each
    holding its own pty (/dev/pts 846 of 847 slots), combined PSS ≈ 1648 MB
    (naive RSS sum 4.2 GB including shared pages).
  • The processes only disappeared when the service restarted (pty master close
    → SIGHUP). Note interactive bash ignores SIGTERM, so cleanup needs
    SIGHUP/SIGKILL.

Additional Context

  • Log evidence for one session (create succeeded, connect denied ~1s later):
timestamp=2026-09-22T09:17:25.563Z message="creating session" id=pty_0c867... cmd=/usr/bin/bash args=["-l"] cwd=<redacted> http.span=17 role=server
timestamp=2026-09-22T09:17:26.596Z message="Sent HTTP response" http.method=POST http.url=/api/pty/pty_0c867.../connect-token http.status=403 role=server
  • Client was another host on the same LAN (app unidentified); it re-created
    sessions instead of reusing them (PUT /api/pty/{id} also returned 404,
    82×404 total in window).
  • Auth question for maintainers: create produced its full side effect
    (session + spawned process) while every connect-token attempt was denied 403.
    Since serve binds 0.0.0.0:4096, please verify POST /api/pty is auth-gated
    the same way as connect-token — if create is reachable without credentials,
    any host can spawn processes; at the observed growth rate a sustained client
    would exhaust /dev/pts within a day.
  • 191 of the 1037 sessions disappeared before restart (mechanism unknown);
    the remaining 846 persisted until restart.
  • Related (same class, different subsystem): mcp: orphaned local MCP server processes accumulate after sessions end #50363, serve (1.18.25): MCP child processes accumulate on web-client reconnects until the server OOMs #46035.
  • Workaround: restart the service. No plugins or recent config changes.
  • Frequency: reproduced twice (2 sessions on Sep 19, 1035 on Sep 22), whenever
    the client entered its retry loop.

Activity

  1. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I could reproduce this on v2.0.10, and it still happens on the latest dev (fa1260d).

    Steps:

    1. Start opencode serve --hostname 0.0.0.0 with no TUI attached.
    2. Make 50 authenticated POST /api/pty calls. After each one, call POST /api/pty/{id}/connect-token with a cross-origin Origin header, as a web app on another LAN host would send.

    Result:

    • connect-token returned 403 every time.
    • 50 bash -l processes were running, and GET /api/pty listed all 50 sessions.
    • After 60 seconds with no client attached, all 50 processes were still alive. Nothing removes sessions that are never attached; they stay until the server exits.

    On your auth question: POST /api/pty does require the server password (unauthenticated requests get 401), so other hosts can't spawn shells without credentials. The 403 comes from the origin check on connect-token, which create doesn't have. Your client must have had the password but been rejected for its origin.

    The script below reproduces it. Related, but about MCP processes rather than PTYs:

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces anomalyco/opencode#50699: PTY sessions created via POST /api/pty
    # are never reaped when the client can't obtain a connect-token (403).
    # Usage: BASE=http://localhost:4096 PASS=<server password> N=50 ./repro.sh
    BASE=${BASE:-http://localhost:4096}; N=${N:-50}; AUTH="opencode:${PASS}"
    SERVE_PID=${SERVE_PID:-$(pgrep -f "serve --hostname" | head -1)}
    count() { ps -eo pid,args | grep -E 'bash -l$|/bin/bash -l' | grep -v grep | wc -l; }
    echo "bash -l processes before: $(count)"
    declare -A codes
    for i in $(seq 1 $N); do
      id=$(curl -s -u "$AUTH" -H 'content-type: application/json' -X POST "$BASE/api/pty" -d '{}' | sed -n 's/.*"id":"\(pty_[^"]*\)".*/\1/p')
      # Cross-origin client (another LAN host's web app): connect-token is refused
      c=$(curl -s -o /dev/null -w '%{http_code}' -u "$AUTH" -X POST -H 'x-opencode-ticket: 1' \
           -H 'origin: http://192.168.1.50:3000' "$BASE/api/pty/$id/connect-token")
      codes[$c]=$((${codes[$c]:-0}+1))
    done
    for k in "${!codes[@]}"; do echo "connect-token HTTP $k x ${codes[$k]}"; done
    sleep 5
    echo "bash -l processes right after: $(count)"
    echo "sessions listed by server: $(curl -s -u "$AUTH" "$BASE/api/pty" | grep -o '"id":"pty_' | wc -l)"
    echo "waiting 60s (no client attached)..."; sleep 60
    echo "bash -l processes after 60s: $(count)"

    Run it: bash repro.sh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions