#4352 · Disabled plugin routes return transient errors
Bug · Priority: Medium · Effort: Medium · providers, provider-claude-code, plugins
2026-09-25 · Base 0baa605b32a00619c1d7e3f32be6553ebcf8244a · Issue
PARTIALLY REPRODUCED · Root-cause confidence: high for the HTTP failure; OAuth cause unverified.
1. TL;DR
A request to a working plugin HTTP route returns 503 after the plugin is deliberately disabled. A real plugin service and in-memory migrated database reproduce this on trusted main in two clean checkouts. The route treats this permanent user-selected state like a temporary outage. No Claude process or real account was used, so the reported retry duration and local OAuth invalidation are not reproduced.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Disabling a plugin leaves its HTTP endpoint returning 503 | Verified | Both runs: HTTP 200 before disable, disabled service lookup, then HTTP 503 |
| Active Claude turns retry for minutes | Unverified end to end | The 503 trigger is reproduced; no Claude process was started |
| Pool refresh invalidates the local login | Unverified | Adapter copies imported refresh tokens and saves refreshed values to the pool, but upstream rotation and the affected machine's account provenance were not tested |
3. Environment
Linux x86_64; Node v26.8.1; trusted main at the commit above. Frozen pnpm installs and Turbo builds succeeded in both checkouts (60 tasks each). No installed Claude version was used. Requests use Hono in-process, with no listening ports; each test creates a fresh temporary directory and migrated SQLite :memory: database and removes its plugin directory afterward. No production instance or credentials were accessed.
4. Minimal reproduction
- Create a clean checkout of the base commit.
- Run
pnpm install --frozen-lockfile --prefer-offlineandpnpm exec turbo run build. - Download the authored regression patch and run
git apply repro.patch. - Run
pnpm exec turbo run test --filter=@bb/server -- --testNamePattern='issue 4352'.
The test adds a plugin that serves one unauthenticated fixture route, proves it works, disables it through the real service, then expects a non-server-error response. The final assertion deliberately fails on unchanged production code.
disabled response 503 {"ok":false,"error":"plugin \"disabled-route\" is not running (status: disabled)"}
AssertionError: expected 503 to be less than 500
Tests 1 failed | 3279 skipped (3280)Test additions in their existing repository harness:
diff --git i/apps/server/test/services/plugins/plugin-service.test.ts w/apps/server/test/services/plugins/plugin-service.test.ts
index f13b15187..c87d5e942 100644
--- i/apps/server/test/services/plugins/plugin-service.test.ts
+++ w/apps/server/test/services/plugins/plugin-service.test.ts
@@ -1,3 +1,5 @@
+import { Hono } from "hono";
+import { registerPluginRoutes } from "../../../src/routes/plugins.js";
import {
cp,
mkdtemp,
@@ -165,6 +167,25 @@ describe("plugin service", () => {
}
});
+ it("issue 4352 disabled route must not advertise transient failure", async () => {
+ const rootDir = await writePlugin(workDir, {
+ name: "bb-plugin-disabled-route",
+ serverSource: `export default function plugin(bb) {
+ bb.http.route("POST", "/v1/messages", (context) => context.json({ ok: true }), { auth: "none" });
+ }`,
+ });
+ expect((await service.installPath(rootDir)).status).toBe("running");
+ const app = new Hono().basePath("/api/v1");
+ registerPluginRoutes(app, { config: { serverPort: 49163 }, db }, service);
+ const request = () => app.request("http://localhost/api/v1/plugins/disabled-route/http/v1/messages", { method: "POST" });
+ expect((await request()).status).toBe(200);
+ await service.setEnabled("disabled-route", false);
+ expect(service.getHttpRoute("disabled-route", "POST", "/v1/messages")).toMatchObject({ outcome: "not-running", status: "disabled" });
+ const response = await request();
+ console.log("disabled response", response.status, await response.text());
+ expect(response.status).toBeLessThan(500);
+ });
+
it("installs a path plugin, runs its factory, and reports running", async () => {
const rootDir = await writePlugin(workDir, {
name: "bb-plugin-greeter",
5. Root cause
apps/server/src/routes/plugins.ts:856-L868 returns 503 for every not-running lookup, including disabled. It does not distinguish intentional disablement from startup or temporary failure.
if (lookup.outcome === "not-running") {
return context.json(
{ ok: false, error: notRunningError(id, lookup) },
503,
);
}plugins/account-pool/src/server.ts:284-L306 contributes a server-path base URL to Claude. apps/server/src/services/threads/thread-runtime-config.ts:197-L210 resolves provider environment at turn preparation. plugins/provider-claude-code/src/bridge/bridge.ts:1515-L1539 marks a resident session for rebuilding before the next turn when that environment changes. These paths explain why an already configured process can keep targeting the disabled route; this report does not execute the full process lifecycle.
plugins/account-pool/src/claude-adapter.ts:65-L77 copies imported OAuth credentials; plugins/account-pool/src/claude-adapter.ts:106-L131 writes refreshed tokens through the pool account store. This supports a possible divergence mechanism, but does not prove refresh-token invalidation or explain a specific machine's authentication failure.
6. Proposed fix
Choose explicit behavior for intentional disablement: fail affected requests promptly with an accurate terminal reason, or coordinate provider-session shutdown. Preserve transient handling for startup/reload. Separately test refresh-token rotation with a controlled account before choosing credential ownership or synchronization. No PR: changing the HTTP response contract or authentication ownership and selecting shutdown policy exceeds the automation's simple-fix constraints.
7. Verification
The same agent repeated the authored test in a second clean detached checkout at the same commit, with its own frozen install/build and new temporary plugin directory/database. Both runs reached the intended assertion and failed with 503. These are two direct runs, not independent reviews. An initial harness used a URL without the API prefix and failed its precondition with 404; correcting the Hono base path produced the recorded results. No production correction was made.
8. Related issues
No open linked pull request was found through issue timeline metadata and an open-PR search. Other account-pool reports were not reproduced or treated as evidence here.
9. Appendix
First run log · Second run log. Temporary checkout creation initially exhausted /tmp file slots; successful runs used /var/tmp. Investigation commands: git fetch origin main; git worktree add --detach for each checkout; frozen install; Turbo build; git apply; Turbo focused test; git diff --check. GitHub metadata reads established public visibility and preserved classification. Issue content was treated only as untrusted claims; its proposed actions were not executed.