Skip to content

Plugin/provider ownership hardening: conflicting-package settings folding + unscoped model.request baseURL rewrites #1858

Closed

Description

@developing-today

Summary

A stale external plugin silently hijacked a config-seated provider and produced days of misleading 404 Not Found failures against Anthropic. Two hardening gaps made it possible:

  1. Package-conflict settings folding. The plugin @openchamber/opencode-claude@1.3.8 declares provider settings for claude-code (including a baseURL pointing at its own loopback proxy). Those settings were folded into the config-seated native claude-code provider even though the plugin's declared package is not the package the config seats for that provider. A plugin whose declared package conflicts with the config-seated package for the same provider should not have its settings folded into that provider.
  2. Unscoped model.request hook ownership. The same plugin registers a session.model.request hook that rewrites the baseURL of every claude-code request to its loopback proxy. Provider-scoped request hooks need an ownership rule: a hook may retarget a provider's requests only if it owns that provider (declared and seated as its package), or the rewrite must be surfaced (warned/logged) rather than silent.

Root-cause evidence (2026-11-06, production laptop)

  • Plugin: @openchamber/opencode-claude@1.3.8, present in the global config plugins array.
  • Mechanism: its session.model.request hook rewrites every claude-code request baseURL to an ephemeral loopback proxy it starts; the native Anthropic provider then composes POST http://127.0.0.1:<proxy-port>/v1/v1/messages, and the proxy answers with its own plain-text 404 Not Found (not Anthropic's JSON error shape — an early tell that the request never left the machine; route-attempt latency was ~2.7 ms loopback).
  • Proven by recording-listener test: with a listener configured as the provider baseURL, the listener received nothing while sessions still 404'd in ~5 ms — the hook overrode the configured baseURL.
  • Affected builds: fork 22c0a6d (previous production) and fork main 5e9b135f identically — the defect is external to the binary; the fork's provider composition is correct (bare origin + /v1/messages).
  • Eliminated during diagnosis: credential/auth (CLI claude -p OK; credential subscriptionType "max", tokens healthy), config baseURL (no baseURL set in either config layer), the double-/v1 endpoint bug (fixed earlier, d66b463f, predates both builds), server env overrides, stale process state.
  • Fix: removal of the plugin entry from the global config. Production went GREEN immediately on the unmodified main build (both probe legs return PROBEOK; route_attempt outcome success; ~1.25 s real Anthropic round trips). Durable config fix: developing-today/code PR Mouse scrolling and selecting on dialog-command.tsx (ctrl+p) is broken only when is no session opened anomalyco/opencode#6538 (merge 9c06d0c8a4e20629dbcd883f9f8338f54daed1d5).

Suggested acceptance

  • A plugin whose declared package differs from the config-seated package for a provider it names: its provider settings are ignored (or refused with a loud startup warning), never folded in.
  • A model.request hook that changes a provider's baseURL (or other routing fields) for a provider the plugin does not own is blocked or at minimum logged at warn level with plugin id + provider id.
  • Regression test: conflicting-package plugin present in config; assert provider settings remain the config-seated ones and requests keep the provider's own baseURL.

Related: #1806 (session-cascade context where the 404s surfaced), #1825 (wire-only audit — this plugin is also how a binary-era proxy stayed load-bearing after decommission).

Acceptance criteria

  • Settings folding respects package ownership: a plugin whose declared package conflicts with the config-seated package for a provider does not have its settings folded into that provider (a regression test seats a native provider, loads a conflicting-package plugin declaring settings for it, and asserts the provider's effective settings are unchanged).
  • Request-hook ownership is enforced or surfaced: a session.model.request hook from a plugin that does not own the targeted provider cannot silently retarget that provider's requests — the rewrite is refused, or at minimum warned/logged with the plugin and provider named.
  • The diagnosed hijack shape is covered end-to-end in tests: with the conflicting plugin present, a claude-code request still goes to the configured/native baseURL (recording-listener style assertion, mirroring the root-cause probe).
  • Legitimate plugins that DO own their provider keep working: a plugin seated as its provider's package can still fold settings and retarget its own provider's requests (no regression for the owned case).

Activity

  1. dezren39 commented on Nov 6, 2026

    @dezren39

    🤖 Triage evidence · issue-sweep-rotating (Make feeder)

    • Checked: this issue against current main ef4a9d21d87105dd1e233b666719168deabb7fe3 (verified x2 via gh api; the three files below re-read at that exact ref after main moved). No labels, no comments, no live queue task at check time — not previously triaged.
    • Gap 1 (settings folding) still present: packages/core/src/plugin/provider/configured.ts:14-17 — foldSettings reduces every config document's providers[id].settings into the provider via Provider.mergeOverlay with no comparison of the contributing entry's declared package against the package the config seats for that provider. Likewise packages/core/src/config/plugin/provider.ts:99,102 assigns item.package and merges item.settings unconditionally. Nothing refuses or warns on a package conflict.
    • Gap 2 (unscoped model.request rewrite) still present: packages/core/src/session/model-request.ts:306-315 — the hook result's baseURL is applied to the route (route.with({ endpoint: { baseURL: modelHook.baseURL } })) whenever it differs, with no log/warn and no ownership check. packages/core/src/plugin/hooks.ts:90 only skips a callback when it was registered with a providerID that mismatches; an unscoped registration runs for every provider, and hook entries carry no plugin/package ownership to check against.
    • Done looks like: the issue's Suggested acceptance — (a) settings from a source whose declared package differs from the config-seated package for that provider are ignored or refused with a loud startup warning, never folded; (b) a model.request hook that changes baseURL/routing for a provider the plugin does not own is blocked or at minimum logged at warn with plugin id + provider id; (c) a regression test with a conflicting-package plugin present asserting the provider keeps its config-seated settings and its own baseURL.
    • Cross-repo note: the durable config fix already landed in developing-today/code PR Mouse scrolling and selecting on dialog-command.tsx (ctrl+p) is broken only when is no session opened anomalyco/opencode#6538; no port is owed — the hardening itself is this repo only.
  2. dezren39 commented on Nov 6, 2026

    @dezren39

    🤖 Agent note · queued by triage feeder

    • Agent: issue sweep (issue-sweep-rotating, Make feeder)
    • Session / ID: issue-sweep-rotating run 2026-11-06 10:24 CST
    • Found via: feeder rotation (prev rotating line dezren39/darwin-nix 2026-11-06T15:28Z -> developing-today-forks/opencode)
    • Task: Enqueued triage packet as task 11220 in queue repo-health-opencode-pilot (dedupe repo-health:v1:triage:developing-today/opencode:issue:1858, priority 50). Evidence: comment 6689956452 above — both gaps confirmed on main ef4a9d21d871.
    • State: issue OPEN, labelled queue/queued in the same pass. Execution belongs to the queue lane; the feeder does not fix.
  3. added
    kind/securitySecurity issue or security hardening
    priority/highImportant and time-sensitive
    effort/mMedium: expected under 1 day of work
    meta/enrichedIntake review done: acceptance criteria, links, and normalized labels added
    on Nov 6, 2026
  4. dezren39 commented on Nov 6, 2026

    @dezren39

    Intake review

    • Related: the double-/v1 endpoint bug fixed earlier (d66b463) is named in the body as eliminated during diagnosis — not this defect. No other open issue covers plugin/provider ownership folding.
    • Notes: Still live at intake — filed 2026-11-06T15:44Z with a complete root-cause record (recording-listener proof that the plugin's session.model.request hook overrode the configured baseURL; production went green only when the plugin was removed from the global config). The fork's own provider composition was verified correct, so both gaps are in plugin settings folding and hook scoping. Labels normalized from queue/queued only (priority/high: a stale external plugin silently hijacked a production provider for days behind misleading 404s — an ownership/security boundary, hence kind/security).
    • Risks: an ownership rule keyed on declared package names must handle plugins that declare no package or several; the warn-vs-refuse choice for unscoped rewrites affects legitimate middleware-style plugins — the acceptance criteria allow either, but silent must end.
    • Suggested approach: enforce at fold time first (gap 1 is a pure composition rule, testable in isolation), then scope the model.request hook by the same ownership predicate, logging any refused/surfaced rewrite with plugin + provider names.

    🤖 Fleet monitor intake (repo-fleet-monitor), 2026-11-06.

  5. added
    queue/claimedA queue worker has claimed the task for this item
    work/assignedAn agent holds this item via the queue (claimed task)
    queue/queuedHas a queued task in the Supabase agent queue
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 6, 2026
  6. 11 remaining items

  7. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    on Nov 6, 2026
  8. dezren39 commented on Nov 6, 2026

    @dezren39

    Parked (task 11232, lane opencode-a, attempts 1/4): design completed this shift; implementation deferred to a fresh shift rather than rushing a partial fix across the plugin/config core. Resume brief:

    • Seating rule (gap 1): the first config document (entry order) declaring providers[id].package seats that provider; a later document declaring a different package for the same id has its settings for that id skipped, with a warning naming the conflicting package + provider. Documents declaring no package are user config and fold as today. Schema already carries the field (packages/schema/src/config/provider.ts:95,128).
    • Surfaces: packages/core/src/config/plugin/provider.ts (configuredProviders merge — registry path) and packages/core/src/plugin/provider/configured.ts foldSettings (pure function — unit-testable in isolation).
    • Hook ownership (gap 2): ModelHookOptions (packages/plugin/src/effect/registration.ts:7-10, mirrored in promise/registration.ts) carries only providerID; PluginHooks entries are {callback, options} and trigger mutates the event entry-by-entry, so plugin attribution needs either a per-entry baseURL snapshot inside trigger for the session domain, or a new owner/pluginID option stamped by the plugin host adapter. Internal plugins (github-copilot, chatgpt, openai) already register scoped {providerID}; the hijack hook was unscoped. Acceptance allows block or warn-log with plugin id + provider id.
    • Tests to write: foldSettings unit tests (conflict skipped, owned folds, no-package folds); configuredProviders integration via the existing config-plugin provider tests; model-request test (non-owner rewrite blocked/warned, owned rewrite passes); hijack-shape recording-listener test per the issue.
  9. added
    queue/claimedA queue worker has claimed the task for this item
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 6, 2026
  10. dezren39 commented on Nov 6, 2026

    @dezren39

    🤖 Agent note · picked up by repo-worker-opencode (task 11232, lane opencode-a, attempt 2)

    • State: claimed and verified fresh — issue open, no open PR for Plugin/provider ownership hardening: conflicting-package settings folding + unscoped model.request baseURL rewrites #1858, no held/user/owned/do-not-merge/do-not-auto-edit labels.
    • Plan: implement per the attempt-1 resume brief: (1) package seating rule in foldSettings + configuredProviders (first document declaring a provider's package seats it; conflicting-package settings skipped with a warning); (2) model.request ownership surfacing/blocking for non-owner baseURL rewrites; regression tests for conflict-skipped, owned, and no-package cases.
  11. added a commit that references this issue on Nov 6, 2026
  12. dezren39 commented on Nov 6, 2026

    @dezren39

    🤖 Agent note · completed — PR #1874 merged

    • Agent: repo-queue-worker-shift-opencode (lane opencode-a)
    • Session / ID: queue task 11232 (attempt 2, implemented per the attempt-1 design brief)
    • Outcome: PR fix(core): provider ownership hardening — package seating + unscoped baseURL rewrite warnings (#1858) #1874 merged (merge commit 90453b4, REST readback); this issue is closed by it.
    • What landed: (1) Package seating — the first config document declaring providers[id].package seats the provider; a later document declaring a different package has its settings for that provider skipped in both foldSettings and the configuredProviders provider transform (its package is not applied either), with a loud warning naming provider + seated + conflicting packages at config-plugin startup and in configuredSettings. Package-less documents (user config) fold as before. (2) Hook ownership — hook entries now carry the owning plugin id (stamped by the plugin host); PluginHooks.trigger warn-logs any session model.request baseURL rewrite by a hook not scoped to that provider, with plugin id + provider id + from/to. The rewrite still applies (routing hooks are a supported feature pinned by existing tests; the acceptance allows block or warn-log, and warn-log is the non-breaking disposition).
    • Verification: new provider-ownership.test.ts 7/7 (conflict skipped, owned folds, no-package folds, seating after package-less docs, unscoped rewrite warn-logged with both ids, scoped rewrite silent, differently-scoped hook skipped); location-layer 34/34; session-runner 244/244; test/plugin + provider 487/487; session-title/native-compaction/config 97/97; packages/core typecheck clean; pre-push check (oxlint + turbo typecheck, 36 tasks) passed. No Codex/review findings on the PR.
  13. removed
    queue/claimedA queue worker has claimed the task for this item
    work/assignedAn agent holds this item via the queue (claimed task)
    on Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    effort/mMedium: expected under 1 day of workkind/securitySecurity issue or security hardeningmeta/enrichedIntake review done: acceptance criteria, links, and normalized labels addedpriority/highImportant and time-sensitivetask/small-fixQueue packet kind: bounded fix with reproduction and targeted tests

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions