{"record":{"id":"71d162602d792fa2","repo":"agalwood/Motrix","slug":"plugin-commands-access-denied","errorCode":"plugin.commands.access_denied","errorMessage":"cross-plugin command \"${commandId}\" requires a bound invoker (Plan D)","messagePattern":"cross-plugin command \"(.+?)\" requires a bound invoker \\(Plan D\\)","errorType":"exception","errorClass":"CommandsError","httpStatus":null,"severity":"error","filePath":"src/core/plugin/capabilities/commands.ts","lineNumber":191,"sourceCode":"          durMs: Date.now() - startTs,\n          ok: true,\n        })\n        return result\n      } catch (err) {\n        this.onSelfInvoke?.({\n          callerId,\n          commandId,\n          durMs: Date.now() - startTs,\n          ok: false,\n          errorCode: err instanceof Error ? err.message : String(err),\n        })\n        throw err\n      }\n    }\n\n    // Cross-plugin path\n    if (!this.invoker) {\n      throw new CommandsError(\n        'plugin.commands.access_denied',\n        `cross-plugin command \"${commandId}\" requires a bound invoker (Plan D)`\n      )\n    }\n    return this.invoker.execute(callerId, commandId, args)\n  }\n\n  /**\n   * Remove all handlers whose command ID starts with `${callerId}.`.\n   * Used during plugin teardown.\n   */\n  unregisterAll(callerId: string): void {\n    const prefix = `${callerId}.`\n    for (const key of this.handlers.keys()) {\n      if (key.startsWith(prefix)) {\n        this.handlers.delete(key)\n      }\n    }","sourceCodeStart":173,"sourceCodeEnd":209,"githubUrl":"https://github.com/agalwood/Motrix/blob/1a708ee57746c434e2c67a44bbf0906a976afea4/src/core/plugin/capabilities/commands.ts#L173-L209","documentation":"Thrown by CommandsCapabilityHost.execute() when the requested commandId does NOT start with the caller's own namespace (i.e. a foreign-namespace / cross-plugin call) and no CrossPluginInvoker was bound to the capability. Cross-plugin dispatch is intentionally delegated to a host-supplied invoker (the design note calls this 'Plan D'); without one the capability refuses rather than silently failing. The error code is `plugin.commands.access_denied`.","triggerScenarios":"A plugin calls `execute('pluginA', 'pluginB.someCommand', args)` while `callerId='pluginA'` so the id is foreign, and the host constructed CommandsCapabilityHost without passing an invoker (or passed `undefined`). Common in unit tests of a plugin in isolation, or when the host hasn't wired the plugin registry's cross-invoker.","commonSituations":"Host/bootstrap code forgot to bind the CrossPluginInvoker during capability construction; plugin is being tested outside the full plugin host that normally supplies the invoker; a refactor removed the invoker wiring thinking own-namespace calls were the only path.","solutions":["If cross-plugin calls are intended, wire the CrossPluginInvoker into CommandsCapabilityHost at construction (the host's plugin registry normally provides it).","If the call was meant to stay in-namespace, prefix the commandId with the caller's own id so it dispatches locally.","In tests, inject a stub invoker that records/forwards calls so the code path under test does not hit this guard.","Confirm the host's capability-host.ts actually binds the invoker against the PluginRegistry before any plugin activate() runs."],"exampleFix":"// before — no invoker bound, cross-plugin call fails\nconst cmds = new CommandsCapabilityHost({ /* invoker omitted */ })\nawait cmds.execute('pluginA', 'pluginB.cmd', {})\n\n// after — bind an invoker (or restrict to own namespace)\nconst cmds = new CommandsCapabilityHost({\n  invoker: registry.crossInvoker, // implements CrossPluginInvoker\n})\nawait cmds.execute('pluginA', 'pluginB.cmd', {})","handlingStrategy":"validation","validationCode":"function canCallForeign(cmds: CommandsCapabilityHost, callerId: string, commandId: string): boolean {\n  return commandId.startsWith(`${callerId}.`) || !!getInvoker(cmds)\n}","typeGuard":"function isAccessDenied(e: unknown): e is CommandsError {\n  return e instanceof Error && (e as CommandsError).code === 'plugin.commands.access_denied'\n}","tryCatchPattern":"try {\n  await cmds.execute(callerId, foreignId, args)\n} catch (e) {\n  if (isAccessDenied(e)) {\n    // degrade gracefully: own-namespace fallback or user-facing 'unsupported'\n  } else throw e\n}","preventionTips":["Wire the CrossPluginInvoker during capability construction in the host bootstrap.","In tests, inject a stub invoker rather than relying on cross-plugin routing.","Document which commands are own-namespace vs foreign in the plugin manifest."],"tags":["plugin","commands","cross-plugin","access-control","capabilities"],"backgroundTag":null,"analyzedSha":"1a708ee57746c434e2c67a44bbf0906a976afea4","analyzedAt":"2026-08-12T16:18:09.346Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}