卷 VI · 造CH 23深度 23/24

合起来:一台完整的插件系统

二十二章的零件,现在装到一起。这一章有一个「效率助手 Pro」—— 它的清单里只申报了 notes.readui,代码里却偷偷调 fs.read 去读你的 SSH 私钥。装上它、启用它、点它那条命令,然后看整套机制怎么配合着把它拦下来,而其它三个插件毫发无损。

完整宿主代码走查合力

先玩

四个插件,其中一个不老实。点「一键装满四个」,然后点「偷偷读文件」那条命令:

你会在日志里看到这样一串:

[宿主] ✓ 已安装 helper-pro@3.0.0
[宿主] 命令 evil.steal 被触发 → 懒激活 helper-pro
[宿主] ✓ 已激活 helper-pro
[宿主] ✗ 拒绝:helper-pro 调用需要 "fs",但清单里没声明
[宿主] ✗ 命令 evil.steal 抛出 —— 插件「helper-pro」没有声明 "fs" 权限

四道机制依次生效了:

  1. 清单校验(第 7 章)让它必须提前申报要什么;
  2. 懒激活(第 8、21 章)让它的代码在被真正需要之前根本没跑;
  3. 权限闸门(第 17 章)在 ctx.fs.read 那一行把它拦下;
  4. 错误边界(第 6 章)接住异常,让它只死自己 —— 另外三个插件的命令还能点、笔记还在正常渲染。
◆ 但请记住第 14 章

这台宿主拦住了「效率助手 Pro」,因为它老老实实地通过 ctx 去调用

如果它换成第 14 章那三行 —— ctx.log.constructor('return globalThis')() —— 它就能绕过整个闸门,直接拿到全局的一切。因为它和宿主在同一个 JS 运行时里。

所以这台宿主的准确定位是:一个设计良好的、防误用和防不知情越界的插件系统,而不是一个能抵御蓄意攻击的沙箱。要做到后者,需要把它挪到 Worker 里(第 16、18 章给了完整方案)。

这个诚实的定位很重要 —— 它决定了你该把这台宿主用在什么场合。

完整代码走查

整台宿主,从上到下。这是真代码,就是这个页面里在跑的那份。

零件一:可弃置对象(第 13 章)

function disposable(fn) {
  let done = false;
  return {
    dispose() {
      if (done) return false;    // 幂等
      done = true;
      fn();
      return true;
    }
  };
}

八行,但它是整个卸载机制的地基。后面每一个「注册」类操作都返回它。

零件二:扩展点注册表(第 4 章)

function Registry() { this.points = {}; }

Registry.prototype.define = function (id, desc) {
  if (!this.points[id]) this.points[id] = { id, desc, items: [] };
  return this.points[id];
};

Registry.prototype.contribute = function (pointId, item) {
  const p = this.points[pointId];
  if (!p) throw new Error('没有这个扩展点:"' + pointId + '"');   // 不沉默失败
  p.items.push(item);
  p.items.sort((a, b) => (b.priority || 0) - (a.priority || 0)); // 顺序是契约的一部分
  return disposable(() => {
    const i = p.items.indexOf(item);
    if (i >= 0) p.items.splice(i, 1);
  });
};

Registry.prototype.get = function (pointId) {
  const p = this.points[pointId];
  return p ? p.items.slice() : [];      // 给副本,不交出内部数组
};

零件三:清单校验(第 7 章)

const KNOWN_PERMS = ['notes.read', 'notes.write', 'ui', 'net', 'fs', 'clipboard'];

function validateManifest(obj) {
  const errors = [], warnings = [];
  ['id', 'name', 'version', 'engine'].forEach(k => {
    if (obj[k] == null || obj[k] === '') errors.push(`缺少必填字段 "${k}"`);
  });
  if (obj.id != null && !/^[a-z][a-z0-9-]{1,38}$/.test(obj.id))
    errors.push(`"id" 必须是小写字母开头、只含小写字母数字和连字符:得到 "${obj.id}"`);
  if (obj.version != null && !parseVer(obj.version))
    errors.push(`"version" 不是合法的三段式版本号:得到 "${obj.version}"`);
  (obj.permissions ?? []).forEach(p => {
    if (!KNOWN_PERMS.includes(p))
      errors.push(`未知权限 "${p}"(可用:${KNOWN_PERMS.join(' / ')})`);   // 未知即错误
  });
  if ((obj.permissions ?? []).includes('fs'))
    warnings.push('声明了 "fs" —— 这是最危险的权限,安装时会给用户一个显著警告');
  return { ok: errors.length === 0, errors, warnings };
}

零件四:安装 —— 零执行的那一步(第 8 章)

Host.prototype.install = function (manifest, source) {
  const v = validateManifest(manifest);
  const rec = new PluginRec(this, manifest || {}, source);

  if (!v.ok) {                                    // 第一关:清单
    rec.state = 'rejected'; rec.error = v.errors[0];
    this.plugins.push(rec);                       // 被拒的也要留在列表里
    return rec;
  }
  if (!satisfies(this.version, manifest.engine)) { // 第二关:版本
    rec.state = 'rejected';
    rec.error = `要求宿主 ${manifest.engine},当前宿主是 ${this.version}`;
    this.plugins.push(rec);
    return rec;
  }

  this.plugins.push(rec);
  // 清单里声明的命令,先挂一个「只有壳没有肉」的(懒激活的地基)
  (manifest.contributes?.commands ?? []).forEach(c => {
    rec.track(this.registry.contribute('commands', {
      id: c.id, title: c.title, by: manifest.id,
      declared: true, run: null, rec,
    }));
  });
  return rec;                                     // ← 到这里,插件代码一行都没跑
};

零件五:带权限闸的 ctx(第 5、17 章)

Host.prototype.makeApi = function (rec) {
  const declared = rec.manifest.permissions ?? [];

  function need(perm) {                            // ← 闸门
    if (!host.strict) return;
    if (!declared.includes(perm)) {
      host.log(`[宿主] ✗ 拒绝:${rec.manifest.id} 调用需要 "${perm}",但清单里没声明`);
      throw new PermError(rec.manifest.id, perm);  // 抛错,不静默
    }
  }

  return {
    version: host.version,
    notes: {                                       // 按能力域分组
      list:  ()      => { need('notes.read');  return host.notes.map(shallow); },
      read:  (id)    => { need('notes.read');  return host.find(id)?.body ?? null; },
      write: (id, t) => { need('notes.write'); /* … */ },
    },
    ui: {                                          // 全部返回可弃置对象
      addCommand:   (id, title, run) => { need('ui'); return rec.track(/* … */); },
      addTransform: (name, fn, prio) => { need('ui'); return rec.track(/* … */); },
      status:       (t) => { need('ui'); host.status = t; },
    },
    net:    { fetch: (url)  => { need('net'); /* … */ } },
    fs:     { read:  (path) => { need('fs');  /* … */ } },   // ← 拦下来的就是这里
    events: { on: (ev, fn) => rec.track(host.on(ev, fn)) },  // 自动登记,防泄漏
    log:    (m) => host.log(`[插件:${rec.manifest.id}] ${m}`),
  };
};

零件六:激活 —— 唯一执行插件代码的地方(第 6 章)

Host.prototype.activate = function (id) {
  const rec = this.plugin(id);
  if (!rec || rec.state === 'active' || rec.state === 'rejected') return rec;

  let factory;
  try {
    factory = new Function('module', 'exports', rec.source);   // ① 编译
  } catch (e) {
    rec.state = 'error'; rec.error = '语法错误:' + e.message;
    return rec;                                                // 宿主活着
  }

  const mod = { exports: {} };
  try {
    factory(mod, mod.exports);                                 // ② 执行
    rec.exports = mod.exports;
    rec.exports.activate?.(this.makeApi(rec));                 // ③ 交出能力
    rec.state = 'active';
  } catch (e) {
    rec.state = 'error';
    rec.error = e.name === 'PermissionDenied' ? e.message : e.message;
  }
  return rec;                                                  // 宿主还是活着
};

零件七:懒激活(第 8、21 章)

Host.prototype.runCommand = function (cmdId) {
  let hit = this.registry.get('commands').find(c => c.id === cmdId);
  if (!hit) return { ok: false, msg: '没有这条命令:' + cmdId };

  if (!hit.run && hit.rec && hit.rec.state !== 'active') {     // ← 壳,没有肉
    this.activate(hit.rec.manifest.id);                        // ← 现在才加载
    hit = this.registry.get('commands').find(c => c.id === cmdId && c.run);
  }
  if (typeof hit?.run !== 'function') return { ok: false, msg: '命令没有实现' };

  try {
    return { ok: true, value: hit.run() };
  } catch (e) {
    return { ok: false, msg: e.message, denied: e.name === 'PermissionDenied' };
  }
};

零件八:停用 —— 把凭证挨个兑现(第 13 章)

Host.prototype.deactivate = function (id) {
  const rec = this.plugin(id);
  if (!rec || rec.state !== 'active') return 0;

  try {
    rec.exports?.deactivate?.();
  } catch (e) {
    this.log(`${id} 的 deactivate 抛了:${e.message}`);    // 插件的清理可以失败
  }
  const n = rec.disposeAll();                             // 宿主的清理不能失败
  rec.state = 'stopped';
  rec.exports = null;
  return n;
};

这就是全部了

八个零件,大约 200 行。它包含了一个插件系统所需要的每一类机制

五个问题这台宿主的答案零件
发现清单 + 校验器三、四
加载new Function + module.exports 约定
契约扩展点注册表 + 声明式 contributes + activate(ctx)二、五、六
隔离错误边界 + 权限闸门(非安全边界五、六
演化engine 版本范围 + 懒激活 + 干净卸载四、七、八

如果要把它变成生产级的,还差什么

诚实地列一下。按重要性排序:

缺什么怎么补参考章节
真正的运行时隔离把插件挪进 Worker,把 ctx 换成 RPC16、18
异步 API所有能力函数改成返回 Promise(先做这个,隔离才好加12、18
持久化插件的启用状态、配置、数据要存下来
插件的配置界面清单里声明配置项 schema,宿主自动生成 UI5、7
依赖解析拓扑排序(如果决定要支持)20
签名 / 来源校验安装时记录并校验22
耗时统计与归因每次调用插件时计时,超阈值警告15、21
远程封禁通道一个黑名单 JSON,启动时拉22
◆ 如果只能补三样

① 把所有 API 改成异步。成本几乎为零(现在就是加个 async),但它保住了你未来加隔离的可能性。不做这个,将来那一天你要让所有插件作者重写代码,而他们中的大部分不会重写。

② 加耗时统计。三十行,但它会告诉你三件你现在不知道的事:哪个插件在拖慢应用、慢在哪个钩子上、以及它是偶尔慢还是一直慢。这份数据也是你将来说服团队投资隔离时最有力的证据。

③ 加远程封禁通道。可能永远用不上,但需要它的那一天,你没有时间再去做。这是那种「保险」性质的功能 —— 成本是一个 JSON 文件加二十行代码,收益是出事那天你有得选。

装到自己身上

◆ Kotlin 版的同一台宿主(骨架)
class Host(
    val version: String = "1.0.0",
    private val strict: Boolean = true,
) {
    private val registry = Registry()
    private val plugins = mutableMapOf<String, PluginRecord>()

    // ── 安装:零执行
    fun install(manifest: PluginManifest, jar: File): InstallResult {
        val v = validate(manifest)
        if (v.isNotEmpty()) return InstallResult.BadManifest(v)
        if (!satisfies(version, manifest.engine))
            return InstallResult.Incompatible(manifest.engine, version)

        val rec = PluginRecord(manifest, jar)
        plugins[manifest.id] = rec
        // 清单里声明的命令,先挂壳
        manifest.contributes.commands.forEach { c ->
            rec.track(registry.contribute("commands",
                CommandItem(c.id, c.title, by = manifest.id, run = null, rec = rec)))
        }
        return InstallResult.Ok(rec)
    }

    // ── 激活:唯一执行插件代码的地方
    fun activate(id: String): Boolean {
        val rec = plugins[id] ?: return false
        if (rec.state == State.ACTIVE) return true

        return try {
            val loader = ChildFirstClassLoader(arrayOf(rec.jar.toURI().toURL()), javaClass.classLoader)
            rec.loader = loader
            val plugin = Class.forName(rec.manifest.main, false, loader)
                .getDeclaredConstructor().newInstance() as NotePlugin
            plugin.activate(makeContext(rec))
            rec.instance = plugin
            rec.state = State.ACTIVE
            true
        } catch (e: Throwable) {                     // Throwable,不是 Exception
            rec.state = State.ERROR
            rec.error = e.message
            log.w("激活 $id 失败", e)
            false
        }
    }

    // ── 停用:兑现所有凭证
    fun deactivate(id: String) {
        val rec = plugins[id] ?: return
        if (rec.state != State.ACTIVE) return
        runCatching { rec.instance?.deactivate() }
            .onFailure { log.w("$id 的 deactivate 抛了", it) }
        rec.disposeAll()          // 撤销所有注册
        rec.scope.cancel()        // 取消所有协程 —— Kotlin 白送的
        rec.instance = null
        rec.loader?.close()       // 释放 jar 文件句柄(Windows 上不关就删不掉)
        rec.loader = null
        rec.state = State.STOPPED
    }
}

结构和 JS 版一模一样,只是加载原语换成了类加载器,多了两件 JVM 特有的收尾(scope.cancel()loader.close())。

这一章的一句话

八个零件、两百行,一台完整的插件系统 —— 它能可靠地防住误用和不知情的越界,但防不住蓄意攻击;把这个定位说准,比把它做大更重要。

最后一章不写代码。我们回到第一章那个问题,给你一张能直接用的决策表:你的程序,到底该开多大的门。