← opencode report

T20 — Plugin tool dynamic discovery

Glob scan {tool,tools}/*.{js,ts} trong config directories, dynamic import, wrap với safety wrapper — user/org thêm tool riêng (Slack, Jira, DB) mà không fork core.
Nhóm: C — Tool DesignFile: tool/registry.ts · Lines 152–173ID: C.8 / T20Status: Stable

Tổng quan Tool Design

Tại sao quan trọng. Hard-code tool list trong core = mỗi lần muốn thêm tool tích hợp Slack, Jira, internal database, hoặc custom workflow → phải PR core repo hoặc fork. Discovery pattern giải quyết: scan folder convention quen thuộc (tool/, tools/), dynamic import module bất kỳ, wrap với safety wrapper (truncate, permission). User hoặc org có thể inject tool riêng vào bất kỳ opencode installation nào.
Convention over configuration: Pattern này quen thuộc từ Next.js pages/, VSCode extensions, Vim plugins. Developer biết ngay "đặt file vào đây, tool sẽ được load" — không cần đọc config documentation.

Phân tích code chi tiết Anatomy

Discovery + dynamic import

tool/registry.ts — discoverPluginTools

TS
export async function discoverPluginTools(configDirs: string[]) {
  const tools: Tool[] = []

  for (const dir of configDirs) {
    // Glob scan: cả "tool/" và "tools/" folder
    const files = await glob("{tool,tools}/*.{js,ts,mjs}", {
      cwd:      dir,
      absolute: true,
    })

    for (const file of files) {
      try {
        const mod     = await import(file)
        const toolDef = mod.default ?? mod.tool

        if (!toolDef) {
          log.warn(`Plugin file ${file} has no default export or .tool export — skipping`)
          continue
        }

        // Wrap với fromPlugin: auto-truncate + sandboxed permission
        tools.push(Tool.fromPlugin(toolDef, { source: file }))
      } catch (err) {
        // Silent fail per plugin — 1 bad plugin không block others
        log.warn(`Plugin tool load failed: ${file}`, err)
      }
    }
  }

  return tools
}
configDirs: [~/.opencode/, ./.opencode/] │ ▼ glob("{tool,tools}/*.{js,ts,mjs}") │ ├── ~/.opencode/tools/slack.ts ├── ~/.opencode/tools/jira.ts └── ./.opencode/tool/my-db.ts │ ▼ dynamic import (each file) │ ▼ Tool.fromPlugin(toolDef, { source: file }) │ ← wrap với: │ ← truncate (T15) │ ← permission check (T24) │ ← OTel span (T14) ▼ [SlackTool, JiraTool, MyDbTool] ← available to model

Plugin tool format đơn giản

~/.opencode/tools/my-db.ts — minimal plugin tool

TS
// Plugin tool format: default export object với name + description + execute
export default {
  name: "query_db",
  description: "Query the internal PostgreSQL database. Use SQL SELECT only.",

  parameters: {
    type: "object",
    properties: {
      sql: { type: "string", description: "SQL SELECT query" },
    },
    required: ["sql"],
  },

  async execute(args: { sql: string }) {
    // Tool author implement business logic
    const rows = await db.query(args.sql)
    return JSON.stringify(rows, null, 2)
    // ↑ sẽ bị wrap với truncate tự động
  },
}

Generic plugin loader

TS
import { glob } from "fast-glob"
import { pathToFileURL } from "node:url"

async function loadUserTools(pluginDirs: string[]): Promise<Tool[]> {
  const out: Tool[] = []

  for (const dir of pluginDirs) {
    const files = await glob(["tool/*.{js,ts}", "tools/*.{js,ts}"], { cwd: dir })

    for (const f of files) {
      try {
        const mod = await import(pathToFileURL(f).href)
        const def = mod.default
        if (def?.name && def?.execute) {
          out.push(wrapTool(def))  // auto-apply truncate / perm / trace
        }
      } catch (err) {
        console.warn(`Failed to load plugin: ${f}`, err)
      }
    }
  }

  return out
}

Tương tác với kỹ thuật khác Interaction

  • T14 (Effect lazy init): Tool.fromPlugin wrap plugin tool với cùng mechanism như built-in tools — truncate + OTel được gắn tự động dù tool là plugin hay core.
  • T15 (Output truncation): Plugin tool output được truncate qua fromPlugin wrapper — plugin author không cần implement truncation.
  • T24–T25 (Permission): Plugin tool được đặt trong sandbox permission riêng — fromPlugin tag source file, permission system có thể apply rule riêng cho plugin tools.
  • T27 (System prompt): Model chỉ thấy plugin tool trong skill list nếu permission cho phép — renderSkills(ctx.skills, { permissionGate: ctx.perm }) filter ra plugin tools không được phép.

Failure modes Failure

1. Security: plugin chạy với process quyền

Plugin là arbitrary JavaScript chạy với quyền của opencode process. Malicious plugin có thể exfiltrate data, modify files ngoài permission scope, hoặc phá hoại session state.

Mitigation quan trọng: Chỉ load plugin từ trusted directories (~/.opencode/, ./.opencode/). Không load plugin từ node_modules hay arbitrary path. Cần user confirm khi install plugin mới từ external source.

2. Silent load failure

try/catch với log.warn đảm bảo 1 plugin lỗi không block plugin khác. Nhưng user có thể không biết tool của mình không được load. Cần UI notification rõ hơn.

3. Type safety yếu

Plugin format chỉ được validate runtime — nếu plugin thiếu execute hoặc parameters format sai, chỉ biết lúc load hoặc khi model gọi tool.

So sánh với các harness khác Compare

HarnessPlugin/Extension systemConventionSafety wrapper
opencodeGlob scan tool/ folder, dynamic import✅ glob tool/*.ts✅ fromPlugin wrap
Claude CodeMCP (Model Context Protocol)✅ config-based✅ MCP protocol
OpenHarnessMCP + Python plugin⚠️ process-level
AiderKhông có plugin system
ClineMCP tools✅ config-based✅ MCP protocol

Implementation recipe Recipe

TS
// Minimal plugin discovery system
import { glob } from "fast-glob"

interface PluginTool {
  name:        string
  description: string
  parameters:  Record<string, unknown>  // JSON Schema object
  execute:     (args: unknown) => Promise<string>
}

async function discoverTools(baseDirs: string[]): Promise<PluginTool[]> {
  const tools: PluginTool[] = []

  for (const dir of baseDirs) {
    // Convention: tool/ hoặc tools/ folder
    const files = await glob("{tool,tools}/*.{js,mjs,ts}", {
      cwd:      dir,
      absolute: true,
    })

    for (const file of files) {
      try {
        const mod = await import(file)
        const def = mod.default ?? mod.tool

        // Validate minimal shape
        if (!def?.name || !def?.execute || typeof def.execute !== "function") {
          console.warn(`[plugin] ${file}: invalid shape, skipping`)
          continue
        }

        // Wrap với safety: truncate output
        tools.push({
          ...def,
          execute: async (args: unknown) => {
            const raw = await def.execute(args)
            return typeof raw === "string" ? truncate(raw) : JSON.stringify(raw)
          },
        })

        console.log(`[plugin] Loaded tool: ${def.name} from ${file}`)
      } catch (err) {
        console.warn(`[plugin] Failed to load ${file}:`, err)
      }
    }
  }

  return tools
}

Tham khảo Refs