PromptX ToolSandbox源码剖析:自动依赖安装与CJS/ESM统一加载是如何实现的
【免费下载链接】PromptXPromptX · 领先的AI 智能体上下文平台 | PromptX · Leading AI Agent Context Platform项目地址: https://gitcode.com/Deepractice/PromptX
本文带你拆解 PromptX(AI 智能体上下文平台)的核心工具执行环境 ToolSandbox,看它如何用 npm 官方 Arborist 实现工具依赖的自动安装,又如何用 importx + 责任链模式把 CommonJS 与 ES Module 统一加载成同一种形态,让工具开发者再也不用纠结模块格式。
🧩 什么是 ToolSandbox:AI 工具的安全执行环境
在 PromptX 中,AI Agent 角色(比如内置的"女娲")可以调用各种自定义工具完成任务:
但工具代码来自用户或社区,直接运行存在两大问题:
- 依赖问题:工具依赖的 npm 包谁来装?装到哪里?版本冲突怎么办?
- 模块格式问题:CommonJS(
require)和 ES Module(import)在 Node.js 中加载方式完全不同,写错就会报错。
ToolSandbox 就是为了解决这两个问题而生的,它采用四层架构:
┌─────────────────────────────────────┐ │ Tool Interface │ 工具实现层 ├─────────────────────────────────────┤ │ ToolSandbox │ 沙箱管理层 ├─────────────────────────────────────┤ │ SandboxIsolationManager │ 隔离执行层 ├─────────────────────────────────────┤ │ VM Context + Node.js │ 运行时环境 └─────────────────────────────────────┘核心入口类位于 ToolSandbox.js,配合官方使用文档 toolsandbox.md 可以完整理解它的能力边界。
🔄 生命周期四步走:从工具调用到执行完成
工具每次运行都经历固定流程,理解这四步是理解后续两个主题的前提:
- 分析(analyze)— analyze() 解析工具内容,调用工具的
getDependencies()提取依赖清单,并初始化目录管理器。 - 准备依赖(prepareDependencies)— prepareDependencies() 检查哪些包需要安装,只安装真正缺失的部分。
- 创建沙箱(createExecutionSandbox)— createExecutionSandbox() 构建隔离的 VM 执行上下文,并注入统一的
importx/loadModule加载函数。 - 执行(execute)— execute() 在沙箱中重新加载工具、校验参数、注入
api实例,最后调用工具的execute(params)。
其中第 1、2 步对应"自动依赖安装",第 3 步的importx注入对应"CJS/ESM 统一加载"。下面分别深入。
📦 自动依赖安装:预装检测 + Arborist 双保险
依赖隔离:每个工具一套独立 node_modules
ToolDirectoryManager 为每个工具分配独立的"工具箱"目录:
~/.promptx/toolbox/[tool-id]/ ├── package.json # 该工具专属的依赖清单 └── node_modules/ # 该工具专属的依赖目录这意味着工具 A 用lodash@4、工具 B 用lodash@3也互不干扰,版本冲突天然被隔离。
第一步:预装检测,能省则省
prepareDependencies() 并不会无脑全量安装,而是先调用analyzeToolDependencies()把依赖分成两类:
- preinstalled(预装):PromptX 发行版已内置的包,直接复用,跳过安装;
- required(需安装):真正缺失的包,只安装这部分。
如果所有依赖都已预装,日志会直接提示 "All dependencies are preinstalled, skipping installation!",实现秒级就绪。
第二步:Arborist 安装,行为等同官方 npm install
真正需要安装时,PackageInstaller 接管。它没有自己手写安装逻辑,而是直接使用 npm 官方的@npmcli/arborist,调用arb.reify()一次性解决:
- 传递依赖:自动补全
A → B → C整条依赖链(这也是修复 issue #332 的关键); - 版本冲突与 peer deps:通过
legacyPeerDeps: true兼容旧包; - 结果回读:安装后通过
arb.loadActual()读取真实安装树,记录每个包的版本和路径。
依赖声明在写入 package.json 前,还会经过 normalizeDependencies() 规范化,同时支持{"lodash": "^4.17.21"}对象格式和["lodash@^4"]数组格式。
加分项:自动选择最快的 registry 源
getOptimalRegistry() 按三级策略选源:
- 用户显式配置(
NPM_REGISTRY环境变量)优先; - 检测系统时区,识别为国内环境时自动切换到淘宝镜像
registry.npmmirror.com; - 其他情况使用 npm 官方源。
国内用户无需手动配置镜像即可享受高速下载,这是"自动依赖安装"体验顺滑的重要一环。
🔀 CJS/ESM 统一加载:ToolModuleImport 三步策略
工具代码里统一用await loadModule('chalk')或await importx('axios')加载任何包,不需要关心它是 CJS 还是 ESM。背后的实现是 ToolModuleImport,它按以下优先级加载:
第一步:缓存优先
moduleCache 是一个Map,同一工具生命周期内每个模块只真正加载一次,后续命中缓存直接返回。
第二步:预装包 → 沙箱目录,两级查找
tryPreinstalled() 先查预装依赖管理器;找不到才走 loadFromSandbox():
await importxFn(moduleName, { parentURL: pathToFileURL(sandboxPath + '/package.json').href, cache: true, loader: 'auto' // 关键:自动识别 CJS / ESM });两个细节决定了一切:
parentURL指向沙箱自己的 package.json:让 importx 从该工具的node_modules中解析依赖,保证加载的是本工具安装的版本;loader: 'auto':importx 自动探测模块类型——CJS 走require语义,ESM 走动态import(),写错了也不会崩溃。
第三步:责任链模式做"出口统一"
即使加载成功,CJS 和 ESM 返回的模块形态依然不同(一个直接是函数,一个包在{ default: fn }里)。PromptX 用责任链模式解决这个问题:ModuleNormalizer 把处理器按优先级排序后串成链,每个处理器只处理自己擅长的模块形态,处理不了就交给下一个。
默认的 8 个处理器链在 createDefaultNormalizer() 中定义:
| 优先级 | 处理器 | 职责 |
|---|---|---|
| 10 | NullHandler | 空值直接放行 |
| 20 | FunctionHandler | 模块本身就是函数 |
| 30 | ESModuleHandler | 识别__esModule标记,纯 default 时解包 |
| 35 | SmartDefaultHandler | 智能判断 default 是否应被提取 |
| 40 | MultiExportHandler | 多导出对象(如 lodash、nodemailer)原样保留 |
| 50 | SingleExportHandler | 单一导出解包 |
| 60 | DefaultExportHandler | 兜底的 default 提取 |
| 100 | PrimitiveHandler | 原始类型及最终兜底 |
其中 SmartDefaultHandler 最有意思,它内置 4 套启发式策略:
- 纯包装型:只有
default没有别的内容 → 提取 default; - default 是主函数:识别 express、debug、chalk 等知名包的"主函数 + 辅助属性"特征;
- CJS 包装 ESM 模式:default 对象与同级方法高度重合 → 视为包装,提取 default;
- 重复导出:所有命名导出都指向 default → 提取 default。
判断不了的情况不强行处理,交给后面的 MultiExportHandler 兜底。整条链还有"失败即返回原模块"的保护(normalize()),确保规范化环节绝不成为故障点。
🔐 安全沙箱:代码被关在笼子里
统一加载的前提是安全隔离。SandboxIsolationManager 用 Node.js 内置vm模块构建隔离上下文,核心手法:
- 隔离的 require:createIsolatedRequire() 用
Module.createRequire()把 require 绑定到沙箱的package.json,工具只能解析自己工具箱里的依赖; - 内置模块回退:沙箱 require 失败时,handleRequireFallback() 允许回退到 Node.js 内置模块(
path、crypto等),但child_process会被拦截,引导工具改用api.execute()跨平台执行命令; - 危险操作全部封死:createIsolatedProcess() 中的
process.exit()、process.abort()、process.binding()、dlopen以及eval全部被替换为抛错的假实现,恶意代码无法杀掉宿主进程或加载原生模块; - polyfill 注入:为 Electron 环境补齐
File/Blob/FormData等全局对象(ElectronPolyfills.js),桌面端与 CLI 端行为一致。
🎯 总结:5 个值得抄的设计
- 独立工具箱目录—— 用
@user://.promptx/toolbox/[tool-id]协议为每个工具隔离依赖,版本冲突零烦恼; - Arborist 而非手搓安装—— 直接复用 npm 官方依赖求解器,传递依赖、冲突处理全部"白嫖"成熟实现;
- 预装检测前置—— 能复用发行版内置包就绝不重复安装;
- 加载走"缓存 → 预装 → 沙箱"三级降级—— 失败自动降级而不是直接报错;
- 责任链统一 CJS/ESM 出口—— 每种模块形态交给最懂它的处理器,判断不了就保守放行。
想动手实践的话,可以从官方文档 toolsandbox.md 的"快速开始"入手,写一个声明getDependencies()的最小工具,观察~/.promptx/toolbox/下自动生成的package.json和node_modules,再通读 ToolSandbox.js 的execute()方法,就能完整跑通"依赖自动安装 + CJS/ESM 统一加载"的全链路。
【免费下载链接】PromptXPromptX · 领先的AI 智能体上下文平台 | PromptX · Leading AI Agent Context Platform项目地址: https://gitcode.com/Deepractice/PromptX
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考