agalwood/Motrix · error · CommandsError

plugin.commands.access_denied

plugin.commands.access_denied

Error message

cross-plugin command "${commandId}" requires a bound invoker (Plan D)

What it means

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`.

Source

Thrown at src/core/plugin/capabilities/commands.ts:191

          durMs: Date.now() - startTs,
          ok: true,
        })
        return result
      } catch (err) {
        this.onSelfInvoke?.({
          callerId,
          commandId,
          durMs: Date.now() - startTs,
          ok: false,
          errorCode: err instanceof Error ? err.message : String(err),
        })
        throw err
      }
    }

    // Cross-plugin path
    if (!this.invoker) {
      throw new CommandsError(
        'plugin.commands.access_denied',
        `cross-plugin command "${commandId}" requires a bound invoker (Plan D)`
      )
    }
    return this.invoker.execute(callerId, commandId, args)
  }

  /**
   * Remove all handlers whose command ID starts with `${callerId}.`.
   * Used during plugin teardown.
   */
  unregisterAll(callerId: string): void {
    const prefix = `${callerId}.`
    for (const key of this.handlers.keys()) {
      if (key.startsWith(prefix)) {
        this.handlers.delete(key)
      }
    }

View on GitHub (pinned to 1a708ee577)

Solutions

  1. If cross-plugin calls are intended, wire the CrossPluginInvoker into CommandsCapabilityHost at construction (the host's plugin registry normally provides it).
  2. If the call was meant to stay in-namespace, prefix the commandId with the caller's own id so it dispatches locally.
  3. In tests, inject a stub invoker that records/forwards calls so the code path under test does not hit this guard.
  4. Confirm the host's capability-host.ts actually binds the invoker against the PluginRegistry before any plugin activate() runs.

Example fix

// before — no invoker bound, cross-plugin call fails
const cmds = new CommandsCapabilityHost({ /* invoker omitted */ })
await cmds.execute('pluginA', 'pluginB.cmd', {})

// after — bind an invoker (or restrict to own namespace)
const cmds = new CommandsCapabilityHost({
  invoker: registry.crossInvoker, // implements CrossPluginInvoker
})
await cmds.execute('pluginA', 'pluginB.cmd', {})
Defensive patterns

Strategy: validation

Validate before calling

function canCallForeign(cmds: CommandsCapabilityHost, callerId: string, commandId: string): boolean {
  return commandId.startsWith(`${callerId}.`) || !!getInvoker(cmds)
}

Type guard

function isAccessDenied(e: unknown): e is CommandsError {
  return e instanceof Error && (e as CommandsError).code === 'plugin.commands.access_denied'
}

Try / catch

try {
  await cmds.execute(callerId, foreignId, args)
} catch (e) {
  if (isAccessDenied(e)) {
    // degrade gracefully: own-namespace fallback or user-facing 'unsupported'
  } else throw e
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of agalwood/Motrix@1a708ee577 (2026-08-12). Data as JSON: /api/errors/71d162602d792fa2. Report an issue: GitHub.