Skip to content

Plugin permission replies via SDK client are silently dropped on v1.14.51+ (Server.Default and TCP listener use different memoMaps) #28037

Closed

Description

@va3093

Description

Plugin replies to permission requests are silently dropped on v1.14.51+. The plugin sees its client.postSessionIdPermissionsPermissionId(...) call return 200 true, but the Permission.Service.reply it lands on is a different in-process instance than the one holding the pending permission. The pending Deferred never resolves, the bus event permission.replied is never published, and the user sees the permission prompt forever in the web UI / TUI.

Effectively: any plugin that subscribes to permission.asked and tries to reply via the SDK client is broken on v1.14.51+. Pre-1.14.51 this worked because the Hono backend was a single shared in-process app instance.

Root cause: Server.Default() and the TCP listener use independent memoMaps

The TCP listener built by Server.listen(...) and the in-process Server.Default() consumed by createOpencodeClient({ fetch: (...args) => Server.Default().app.fetch(...args) }) use two separate Effect Layer memoMaps, so they hold independent copies of every singleton service — including Permission.Service and its InstanceState-backed pending map.

In packages/opencode/src/server/server.ts:

// startListener — fresh memoMap per listener
return Layer.buildWithMemoMap(listenerLayer(opts, port), Layer.makeMemoMapUnsafe(), scope).pipe(...)

In packages/opencode/src/server/routes/instance/httpapi/server.ts:

import { memoMap } from "@opencode-ai/core/effect/memo-map"
// ...
export const webHandler = lazy(() =>
  HttpRouter.toWebHandler(routes, {
    disableLogger: true,
    memoMap,                 // module-singleton from @opencode-ai/core
    middleware: disposeMiddleware,
  }),
)

And @opencode-ai/core/effect/memo-map.ts:

import { Layer } from "effect"
export const memoMap = Layer.makeMemoMapUnsafe()

Server.Default() is built from HttpApiApp.webHandler(), which uses that module-singleton memoMap. The TCP listener uses its own fresh Layer.makeMemoMapUnsafe(). They never share state.

In packages/opencode/src/plugin/index.ts:128, the plugin SDK client is wired into the Default()'s handler:

const client = createOpencodeClient({
  baseUrl: "http://localhost:4096",
  directory: ctx.directory,
  headers: ServerAuth.headers(),
  fetch: async (...args) => Server.Default().app.fetch(...args),
})

So when the user's tool call enters via the TCP listener and Permission.ask adds the entry to its pending map (memoMap A), the plugin's subsequent client.post...({response:"once"}) routes through Server.Default() into memoMap B's Permission.Service.reply, which looks at memoMap B's empty pending map and silently returns. The user-visible Permission.reply handler is:

const existing = pending.get(input.requestID)
if (!existing) return  // ← no error, no log, HTTP 200 true

Reproduction

A minimal repro plugin (server-plugin.ts):

export const Plugin = async (ctx) => {
  return {
    event: async ({ event }) => {
      const e = event as { type: string; properties?: any }
      if (e.type !== "permission.asked") return
      const { id, sessionID } = e.properties
      // This call returns 200 true but does NOT resolve the deferred.
      await ctx.client.postSessionIdPermissionsPermissionId({
        body: { response: "once" },
        path: { id: sessionID, permissionID: id },
      })
    },
  }
}

Steps:

  1. Register the plugin in opencode.json.
  2. Start opencode serve.
  3. From the web UI / TUI, run any tool call that triggers a permission ask (e.g. bash: kubectl get pods).
  4. Observe: server log shows permission.asked publishing but never permission.replied publishing. GET /permission?directory=<dir> keeps showing the permission as pending. Prompt is never auto-resolved.

Confirming it's the memoMap split (not the SDK / directory routing): replacing the SDK call with a raw fetch(ctx.serverUrl + "/session/<id>/permissions/<permID>", ...) to the running TCP listener resolves the permission correctly. Same JSON payload, same path, same headers — only the routing target differs.

Affected versions

  • First broken release: v1.14.51 (commit 195f592 "refactor(server): simplify listener lifecycle" refactor(server): simplify listener lifecycle #27413, 2026-05-14, which removed the Hono backend and exposed the existing memoMap split)
  • Still broken on current dev (verified at e4cc4e168)
  • Last working release: v1.14.50

Before v1.14.51 the default backend was Hono, a single shared in-process app instance, so plugin replies and the TCP listener naturally hit the same Permission.Service. The Effect HttpApi backend has always had this memoMap split, but it wasn't the default for plugins until #27413.

Expected behaviour

Server.Default().app.fetch(...) should resolve plugin-issued mutations against the same in-process services as the running TCP listener, so that Permission.reply from a plugin actually resolves the pending Deferred set by the listener's Permission.ask.

Suggested fix

A few possible directions, in increasing invasiveness:

  1. Share the listener's memoMap with Server.Default(). When Server.listen(...) runs, capture the MemoMap and rebuild webHandler against it (replace the module-singleton). All in-process plugin clients then hit the live listener's services.
  2. Drop the in-process fast path entirely. Have the plugin SDK fetch target the real TCP listener URL (Server.url) over loopback. Simpler, no memoMap juggling, but adds a network hop per plugin call.
  3. Plumb MemoMap as an explicit service so Server.Default() resolves to "whatever listener is currently active" rather than a separate static handler.

Happy to send a PR for (1) or (2) if there's a preferred direction.

Workaround (for plugin authors hitting this today)

Bypass ctx.client for state-mutating calls and fetch the real TCP listener directly via ctx.serverUrl. The x-opencode-directory header (or ?directory= query) still selects the right workspace. This avoids the in-process fast path and goes through WorkspaceRoutingMiddleware → InstanceContextMiddleware so the reply lands on the same Permission.Service that holds the pending entry.

// In a plugin: don't use ctx.client.postSessionIdPermissionsPermissionId(...).
// Instead:
const replyUrl = new URL(
  `/session/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(permId)}`,
  ctx.serverUrl,
)
await fetch(replyUrl, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    ...(ctx.directory ? { "x-opencode-directory": encodeURIComponent(ctx.directory) } : {}),
  },
  body: JSON.stringify({ response: "once" }),
})

Environment

  • OpenCode v1.15.3 (also reproduced on dev tip e4cc4e168)
  • macOS 26 (Apple Silicon), Bun 1.3.x
  • Web UI and TUI both affected

Activity

  1. github-actions commented on May 17, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of (or closely related to) existing issues. Please check:

  2. CaffeinatedFeline commented on Jun 4, 2026

    @CaffeinatedFeline

    I am also affected — CodeNomad user, OpenCode v1.15.13, Debian 13 / Proxmox VE

    My setup

    • OpenCode v1.15.13 (npm global install, managed by CodeNomad)
    • CodeNomad v0.16.0 (@neuralnomads/codenomad), running as codenomad.service via systemd
    • CodeNomad serves on https://127.0.0.1:9898
    • CodeNomad spawns opencode serve --port 0 --print-logs --log-level DEBUG as child processes
    • Node.js v24.15.0, Debian GNU/Linux 13 (trixie), kernel 7.0.6-2-pve (Proxmox VE), x86_64
    • Permission config:
      "permission": {
        "question": "allow",
        "websearch": "allow",
        "bash": { "*": "allow", "rm -r*": "deny", ... },
        "external_directory": "ask"
      }

    Symptom

    When a tool triggers a permission prompt with permission: "external_directory" and patterns: ["/proc/self/*"], the CodeNomad web UI displays the prompt correctly. Clicking "Approve" (or "Always") returns the error "permission.reply failed" in the UI. The pending Deferred is never resolved, the tool call hangs, and the permission prompt reappears on next attempt.

    The general permission mechanism works for other paths — I can see successful permission.asked → permission.replied event pairs in the OpenCode log files for bash permissions. The failure is specific to the HTTP-proxied permission reply flow that CodeNomad uses.

    This used to work. It broke after updating OpenCode past v1.14.50 — I did not change my CodeNomad configuration or permission settings.

    What I traced

    The error text "permission.reply failed" is not a literal string in any log file or config. It is constructed dynamically by the OpenCode SDK HTTP client when sdk.permission.reply({requestID, reply}) throws — most likely an HTTP 404 (requestID not found in the pending Map) wrapped as <methodName> failed: <statusText>.

    The relevant code path in CodeNomad's architecture is a transparent HTTP proxy:

    User clicks "Approve" in CodeNomad Web UI (browser SPA)
      → SDK client calls sdk.permission.reply({requestID: "per_xxx", reply: "once"})
      → POST /permission/{requestID}/reply via CodeNomad's Fastify HTTP proxy
      → routed to opencode serve TCP listener (real HTTP, not in-process Server.Default())
      → Permission.reply() → look up requestID in pending Map → 404 → Permission.NotFoundError
      → SDK wraps as "permission.reply failed"
    

    No permission logic lives in CodeNomad itself — it delegates entirely to the managed opencode serve instances.

    Why this is consistent with the memoMap split

    The original issue's root cause analysis is the most plausible explanation for what I am seeing. The TCP listener (buildWithMemoMap) and the request handler (Server.Default() / toWebHandler) use separate Effect memoMaps. If Permission.Service is instantiated in both memoMaps, the tool execution context (which stores the pending Deferred in the listener's memoMap) and the HTTP reply handler (which looks it up via the web handler's memoMap) resolve different service instances — hence the requestID is never found.

    The reason my case differs from the plugin SDK case described in the original issue is that CodeNomad sends genuine HTTP requests to the TCP listener, not in-process calls through Server.Default().app.fetch(). This suggests the memoMap split is broader than originally scoped: it affects the HTTP service resolution path within the listener's own request pipeline, not just the separate in-process fast path.

    Why existing workarounds do not help me

    The original issue's workaround (replacing the SDK client call with fetch(ctx.serverUrl, ...)) works for plugin authors who control their own HTTP client. In CodeNomad's architecture, the permission reply originates from the browser-side SDK bundled in the CodeNomad web SPA — I cannot modify that SDK's routing.

    A fix that ensures the HTTP request handler resolves Permission.Service from the same memoMap as the tool execution context (your Option 1 or Option 3) would resolve this for all external HTTP clients, not just plugins.

    Additional context

    • I manage 23 CodeNomad workspace instances. Cross-instance routing (permission SSE event from instance A, reply to instance B) is another potential contributor.
    • For any bash command touching external paths, ShellTool.ask() issues two sequential Permission.ask() calls — first external_directory, then bash. If the first fails, the second is never reached. This means a single failed external_directory reply blocks the entire tool call silently.

    Versions verified

    Component Version Source
    opencode-ai 1.15.13 npm list -g opencode-ai
    @neuralnomads/codenomad 0.16.0 codenomad --version
    Last working (per issue) ≤ 1.14.50 Hono backend with shared in-process instance
    First broken (per issue) 1.14.51 Commit 195f592
  3. rekram1-node commented on Jun 15, 2026

    @rekram1-node
    Contributor

    Should be fixed:
    87c33b3

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions