Skip to content

plugin: an unreadable package.json in one local plugin directory removes every plugin and provider (2.2.0) #59750

Open

Description

@iceteaSA

Description

On 2.2.0, one local plugin directory can have a package.json that the service cannot read (for example, mode 000). Then the service loads no plugins and no providers. /api/plugin and /api/provider give an empty list. This occurs at first boot and after a Location reload. On 2.1.4 the same setup is correct: the two plugins are active.

Cause

packages/plugin/src/host.ts:88-91 at tag v2.2.0:

function packageManifest(directory: string) {
  const file = path.join(directory, "package.json")
  if (!existsSync(file)) return undefined
  return Option.getOrUndefined(Schema.decodeUnknownOption(Manifest)(readFileSync(file, "utf8")))
}

existsSync gives true, then readFileSync throws EACCES, and no code catches the error. The comment at :86 says that one broken plugin directory must not stop the other plugins. The service log has:

level=ERROR message="failed to reload plugins" cause="Cause([Die(Error: EACCES: permission denied, open '<project>/bad/package.json')])"

Steps to reproduce

  1. Make a project with two local plugin directories, good and bad. Each has an index.ts that exports { id, setup }, and bad also has a package.json.
  2. Project opencode.json:
    { "plugins": [{ "package": "./good" }, { "package": "./bad" }] }
  3. chmod 000 bad/package.json.
  4. Start opencode serve --service and send GET /api/plugin and GET /api/provider with the x-opencode-directory header. For the reload test, start with a readable file, change the mode, then send POST /api/location/reload.

I used isolated HOME and XDG roots, one OpenAI-compatible test provider, and no model request.

Version Case /api/plugin /api/provider
2.1.4 first boot, mode 000 92 records, good and bad active 1
2.1.4 after reload, mode 000 92 records, good and bad active 1
2.2.0 readable package.json 92 records, good and bad active 1
2.2.0 first boot, mode 000 0 0
2.2.0 after reload, mode 000 0 0

Possible repair

In packageManifest, catch the read error and use the same result as a missing file, as the comment says. A warning for that one plugin can also help.

Environment

OpenCode 2.2.0 and 2.1.4 release binaries, Linux x86-64.

Activity

  1. opencodejr commented on Oct 27, 2026

    @opencodejr

    Thanks for the detailed report. I can confirm this on 2.2.0, and it still happens on the latest dev (5aeac4f).

    What I did, following your steps (two local plugins good and bad, plus one OpenAI-compatible provider, opencode serve --service):

    • Readable bad/package.json: /api/plugin returns 92 records with both local plugins active, and /api/provider lists the providers.
    • chmod 000 bad/package.json, run as a non-root user: both lists are empty on first boot. The log shows failed to reload plugins with Die(Error: EACCES: permission denied, open '.../bad/package.json').
    • package.json replaced by a directory: same result, with EISDIR. As in your case, the existence check passes and the read throws.
    • Reload: with a readable file it boots fine. After the file is made unreadable, POST /api/location/reload empties both lists.

    On dev the reload case fails the same way. The function you pointed at, packageManifest in packages/plugin/src/host.ts, hasn't changed since v2.2.0. The script below reproduces it (readable, eacces, eisdir and reload modes).

    Related, but with a different cause: #52618 and #49982, where a failed plugin reload also wipes the provider or agent registries.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #59750: one local plugin whose package.json cannot be read empties /api/plugin and /api/provider.
    # Usage: repro.sh [readable|eacces|eisdir|reload]   (OC="<command>" to override the opencode binary)
    # eacces needs a non-root user (root ignores mode 000); eisdir makes package.json a directory, which also
    # passes existsSync and makes readFileSync throw.
    set -u
    MODE=${1:-eacces}
    OC=${OC:-opencode}
    mkdir -p /tmp/opencode
    ROOT=$(mktemp -d /tmp/opencode/repro59750.XXXX)
    P=$ROOT/project; mkdir -p $P/good $P/bad $ROOT/home
    for n in good bad; do
      cat > $P/$n/index.ts <<TS
    export default { id: "$n-plugin", setup: async () => {} }
    TS
    done
    echo '{"name":"bad-plugin","type":"module"}' > $P/bad/package.json
    cat > $P/opencode.json <<'J'
    { "plugins": [{ "package": "./good" }, { "package": "./bad" }],
      "provider": { "mock": { "npm": "@ai-sdk/openai-compatible", "name": "Mock",
        "options": { "baseURL": "http://127.0.0.1:9/v1", "apiKey": "x" },
        "models": { "m": { "name": "M" } } } } }
    J
    case $MODE in
      eacces) chmod 000 $P/bad/package.json ;;
      eisdir) rm $P/bad/package.json; mkdir $P/bad/package.json ;;
    esac
    export HOME=$ROOT/home XDG_CONFIG_HOME=$ROOT/home/.config XDG_DATA_HOME=$ROOT/home/.local/share \
           XDG_STATE_HOME=$ROOT/home/.local/state XDG_CACHE_HOME=$ROOT/home/.cache
    mkdir -p $XDG_CONFIG_HOME/opencode; for f in opencode/service.json opencode/service-local.json; do echo "{\"password\":\"repro\"}" > $XDG_CONFIG_HOME/$f; done
    PORT=$((20000 + RANDOM % 20000))
    cd $P
    $OC serve --service --port $PORT --hostname 127.0.0.1 > $ROOT/serve.log 2>&1 &
    PID=$!
    for i in $(seq 60); do curl -s -o /dev/null http://127.0.0.1:$PORT/ && break; sleep 0.5; done
    H="x-opencode-directory: $P"
    api() { curl -s -u opencode:repro -H "$H" "$@"; }
    report() {
      # plugins load in the background; wait up to 30s for the list to settle
      for i in $(seq 30); do n=$(api http://127.0.0.1:$PORT/api/plugin | jq '.data|length'); [ "$n" != "0" ] && break; sleep 1; done
      echo "  /api/plugin: $(api http://127.0.0.1:$PORT/api/plugin | jq -c '"\(.data|length) records, local: \([.data[]|select(.source.type=="local")|"\(.id)=\(.state.status)"])"')"
      echo "  /api/provider: $(api http://127.0.0.1:$PORT/api/provider | jq -c '[.data[].id]')"
    }
    echo "version=$($OC --version) mode=$MODE"
    echo "first boot:"; report
    if [ "$MODE" = "reload" ]; then
      chmod 000 $P/bad/package.json 2>/dev/null
      [ "$(id -u)" = 0 ] && { rm $P/bad/package.json; mkdir $P/bad/package.json; }   # root ignores mode 000
      api -X POST http://127.0.0.1:$PORT/api/location/reload -o /dev/null -w "reload HTTP %{http_code}\n"
      sleep 3; echo "after reload with unreadable package.json:"; report
    fi
    [ -n "${KEEP:-}" ] && { echo "ROOT=$ROOT PORT=$PORT"; exit; }
    kill $PID; wait $PID 2>/dev/null
    grep -rh "failed to reload plugins\|EACCES\|EISDIR" $ROOT/serve.log $ROOT/home 2>/dev/null | head -3 | cut -c1-300

    Run it: bash repro.sh

  2. Aafimalek commented on Oct 28, 2026

    @Aafimalek

    I would like to investigate this and add regression coverage for an unreadable local plugin manifest. Could you assign it to me?

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions