Repository navigation
Closed
Labels
effort/mMedium: expected under 1 day of workMedium: expected under 1 day of workkind/securitySecurity issue or security hardeningSecurity issue or security hardeningmeta/enrichedIntake review done: acceptance criteria, links, and normalized labels addedIntake review done: acceptance criteria, links, and normalized labels addedpriority/highImportant and time-sensitiveImportant and time-sensitivetask/small-fixQueue packet kind: bounded fix with reproduction and targeted testsQueue packet kind: bounded fix with reproduction and targeted tests
Description
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:
- Package-conflict settings folding. The plugin
@openchamber/opencode-claude@1.3.8declares provider settings forclaude-code(including abaseURLpointing at its own loopback proxy). Those settings were folded into the config-seated nativeclaude-codeprovider even though the plugin's declaredpackageis not the package the config seats for that provider. A plugin whose declaredpackageconflicts with the config-seated package for the same provider should not have its settings folded into that provider. - Unscoped
model.requesthook ownership. The same plugin registers asession.model.requesthook that rewrites thebaseURLof 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 configpluginsarray. - Mechanism: its
session.model.requesthook rewrites every claude-code request baseURL to an ephemeral loopback proxy it starts; the native Anthropic provider then composesPOST http://127.0.0.1:<proxy-port>/v1/v1/messages, and the proxy answers with its own plain-text404 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 main5e9b135fidentically — 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 -pOK; credential subscriptionType "max", tokens healthy), config baseURL (no baseURL set in either config layer), the double-/v1endpoint 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_attemptoutcome 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 (merge9c06d0c8a4e20629dbcd883f9f8338f54daed1d5).
Suggested acceptance
- A plugin whose declared
packagediffers 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.requesthook 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).
Metadata
Metadata
Assignees
Labels
effort/mMedium: expected under 1 day of workMedium: expected under 1 day of workkind/securitySecurity issue or security hardeningSecurity issue or security hardeningmeta/enrichedIntake review done: acceptance criteria, links, and normalized labels addedIntake review done: acceptance criteria, links, and normalized labels addedpriority/highImportant and time-sensitiveImportant and time-sensitivetask/small-fixQueue packet kind: bounded fix with reproduction and targeted testsQueue packet kind: bounded fix with reproduction and targeted tests
Activity
dezren39 commentedon Nov 6, 2026
🤖 Triage evidence · issue-sweep-rotating (Make feeder)
ef4a9d21d87105dd1e233b666719168deabb7fe3(verified x2 viagh 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.packages/core/src/plugin/provider/configured.ts:14-17—foldSettingsreduces every config document'sproviders[id].settingsinto the provider viaProvider.mergeOverlaywith no comparison of the contributing entry's declaredpackageagainst the package the config seats for that provider. Likewisepackages/core/src/config/plugin/provider.ts:99,102assignsitem.packageand mergesitem.settingsunconditionally. Nothing refuses or warns on a package conflict.model.requestrewrite) still present:packages/core/src/session/model-request.ts:306-315— the hook result'sbaseURLis 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:90only skips a callback when it was registered with aproviderIDthat mismatches; an unscoped registration runs for every provider, and hook entries carry no plugin/package ownership to check against.packagediffers from the config-seated package for that provider are ignored or refused with a loud startup warning, never folded; (b) amodel.requesthook 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.dezren39 commentedon Nov 6, 2026
🤖 Agent note · queued by triage feeder
repo-health-opencode-pilot(deduperepo-health:v1:triage:developing-today/opencode:issue:1858, priority 50). Evidence: comment 6689956452 above — both gaps confirmed on mainef4a9d21d871.queue/queuedin the same pass. Execution belongs to the queue lane; the feeder does not fix.dezren39 commentedon Nov 6, 2026
Intake review
🤖 Fleet monitor intake (repo-fleet-monitor), 2026-11-06.
11 remaining items
dezren39 commentedon Nov 6, 2026
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:
providers[id].packageseats 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).packages/core/src/config/plugin/provider.ts(configuredProvidersmerge — registry path) andpackages/core/src/plugin/provider/configured.tsfoldSettings(pure function — unit-testable in isolation).ModelHookOptions(packages/plugin/src/effect/registration.ts:7-10, mirrored inpromise/registration.ts) carries onlyproviderID;PluginHooksentries are{callback, options}andtriggermutates the event entry-by-entry, so plugin attribution needs either a per-entry baseURL snapshot insidetriggerfor 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.dezren39 commentedon Nov 6, 2026
🤖 Agent note · picked up by repo-worker-opencode (task 11232, lane opencode-a, attempt 2)
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.Merge pull request #1874 from developing-today-forks/queue/11232-1858…
dezren39 commentedon Nov 6, 2026
🤖 Agent note · completed — PR #1874 merged
providers[id].packageseats the provider; a later document declaring a different package has its settings for that provider skipped in bothfoldSettingsand theconfiguredProvidersprovider transform (its package is not applied either), with a loud warning naming provider + seated + conflicting packages at config-plugin startup and inconfiguredSettings. 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.triggerwarn-logs any sessionmodel.requestbaseURL 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).provider-ownership.test.ts7/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.fix(core): ignore a conflicted document's provider identity and overl…