From f92143a1bc61783673333fcdf4f01af3f852b642 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 29 Oct 2026 09:23:28 +0800 Subject: [PATCH 01/10] feat: add experimental local tool plugins --- .changeset/plugin-system-proposal.md | 5 + .gitignore | 3 + README.md | 4 + docs/plugin-system-proposal.md | 194 ++++++++++++++++++ docs/plugins.md | 111 ++++++++++ examples/plugins/context-probe/cli.cjs | 18 ++ examples/plugins/context-probe/package.json | 11 + examples/plugins/explorer/cli.mjs | 134 +++++++++++++ examples/plugins/explorer/package.json | 15 ++ scripts/plugin-smoke.mjs | 211 ++++++++++++++++++++ scripts/prepare-explorer-prototype.mjs | 40 ++++ src/cli.ts | 32 ++- src/plugins/cli.ts | 83 ++++++++ src/plugins/context.ts | 92 +++++++++ src/plugins/manifest.ts | 66 ++++++ src/plugins/run.ts | 91 +++++++++ src/plugins/store.ts | 164 +++++++++++++++ src/scripts/private.ts | 4 + tests/cli-errors.test.ts | 29 +++ tests/plugin-run.test.ts | 139 +++++++++++++ tests/plugins.test.ts | 204 +++++++++++++++++++ 21 files changed, 1639 insertions(+), 11 deletions(-) create mode 100644 .changeset/plugin-system-proposal.md create mode 100644 docs/plugin-system-proposal.md create mode 100644 docs/plugins.md create mode 100644 examples/plugins/context-probe/cli.cjs create mode 100644 examples/plugins/context-probe/package.json create mode 100644 examples/plugins/explorer/cli.mjs create mode 100644 examples/plugins/explorer/package.json create mode 100644 scripts/plugin-smoke.mjs create mode 100644 scripts/prepare-explorer-prototype.mjs create mode 100644 src/plugins/cli.ts create mode 100644 src/plugins/context.ts create mode 100644 src/plugins/manifest.ts create mode 100644 src/plugins/run.ts create mode 100644 src/plugins/store.ts create mode 100644 tests/plugin-run.test.ts create mode 100644 tests/plugins.test.ts diff --git a/.changeset/plugin-system-proposal.md b/.changeset/plugin-system-proposal.md new file mode 100644 index 0000000..5771c28 --- /dev/null +++ b/.changeset/plugin-system-proposal.md @@ -0,0 +1,5 @@ +--- +'@offckb/cli': minor +--- + +Add experimental local tool plugins with explicit link/list/unlink commands, static manifest validation, versioned devnet context, opaque argument forwarding and foreground process cleanup. Include a context probe and a pinned, repository-only Devnet Explorer adapter experiment; registry installation and background services remain deferred. diff --git a/.gitignore b/.gitignore index 4a2881d..233d17b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ coverage/ # Sisyphus plans and sessions .sisyphus/ + +# Local upstream sources for the experimental Explorer adapter +.prototype/ diff --git a/README.md b/README.md index bb16bc3..171c82c 100644 --- a/README.md +++ b/README.md @@ -606,6 +606,10 @@ sudo chown -R $(whoami) /usr/local/lib/node_modules npm install -g @offckb/cli ``` +## Experimental local plugins + +Local tool packages can be explicitly linked with `offckb plugin link `, inspected with `offckb plugin list`, and run as `offckb `. The host supplies devnet context and owns foreground cleanup. See the [plugin guide](docs/plugins.md) for the context probe, experimental Devnet Explorer adapter, and current limits. Registry installation and background plugins are not implemented yet. + ## Contributing check [development doc](/docs/develop.md) diff --git a/docs/plugin-system-proposal.md b/docs/plugin-system-proposal.md new file mode 100644 index 0000000..ed4dd9a --- /dev/null +++ b/docs/plugin-system-proposal.md @@ -0,0 +1,194 @@ +# offckb 工具插件系统:以 Devnet Explorer 为首个案例 + +状态:**设计讨论稿;Phase 1 的本地接入实验已开始实现,后续设计尚未全部落地。** 初始调研日期:2026-10-28;实现基于 2026-10-29 rebase 后的 `96094c8`。实际可用范围和操作方式见[插件指南](plugins.md)。 + +## 建议 + +值得做一个轻量的工具插件系统。offckb 负责提供当前开发链的环境信息、管理安装和启动进程;Explorer 等工具独立发布,负责自己的功能和界面。第一个目标是让一个第三方工具接入后,不再需要修改 `src/cli.ts` 或增加核心依赖。 + +首版采用 **npm 包 + 静态声明 + 独立子进程**。先用 Explorer 验证协议,再用另一个真实工具检验是否需要更多扩展点。暂不引入 Cordis、通用事件总线、插件互相依赖、热重载或多套 profile,也不迁移现有 Fiber。 + +如果目标仅仅是提供一个启动 Explorer 的快捷命令,普通子命令就足够;插件机制的收益在于第三方工具可以独立发布,并获得一致的环境信息和生命周期。判断首版是否成功,应看接入第二个工具是否还需要改核心。 + +## 当前源码告诉我们什么 + +以下以 offckb `60655db35135ce73106f8ac7c5cfd94e2cc03504` 为基线,没有依赖未接受的 Context。 + +| 当前证据 | 对设计的影响 | +| --- | --- | +| [`src/cli.ts`](../src/cli.ts) 集中注册 Commander 命令,直接接入 Fiber、debugger 和 TUI | 命令发现可以增加一个明确入口;无需改写整套 CLI | +| [`src/cmd/node.ts`](../src/cmd/node.ts) 显式管理 CKB、miner、proxy、可选 FNN;组合启动时 FNN 退出会触发整体清理 | 新的可选工具不能直接复用“任一组件失败就停止整组”的语义 | +| [`src/fiber/`](../src/fiber/) 已有专门的安装、就绪检查、锁、daemon、状态及清理代码 | 可参考进程身份和清理经验;不要一开始把这些领域逻辑抽成通用框架 | +| [`src/tools/ckb-tui.ts`](../src/tools/ckb-tui.ts) 已处理平台资产、校验和及原子安装 | 二进制分发已有经验,但它不等于第三方命令和服务的扩展协议 | +| [`src/cfg/setting.ts`](../src/cfg/setting.ts) 已统一 RPC、proxy、数据和配置路径 | 插件应接收已解析的环境,避免自己猜端口或读取 settings.json 内部结构 | +| [`src/cmd/devnet-info.ts`](../src/cmd/devnet-info.ts)、[`src/devnet/readiness.ts`](../src/devnet/readiness.ts) 已区分链类型、RPC 就绪与 indexer 就绪 | 可以复用其数据构造逻辑,形成版本化的插件上下文 | +| [`src/scripts/private.ts`](../src/scripts/private.ts) 已根据 genesis 识别 fork 并解析系统脚本 | 插件上下文应保留实际地址前缀和链身份,不能把所有 devnet 都当成普通 `ckt` 链 | +| [`scripts/build.js`](../scripts/build.js) 将 CLI 编译并用 ncc 打包 | 外部插件应从独立安装目录启动,不依赖被打包的动态 import 或核心内部模块路径 | + +现有工具说明扩展需求已经出现,但 Fiber 的复杂度不应成为所有插件的入场门槛。 + +## 从 dsh 借鉴什么 + +这里的 dsh 指 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。调研固定在 `f3803c96974ad4a94195fb97e5c637e9c2a3afe3`,避免把快速变化的上游设计当作永久约定。 + +dsh 的体系包含三个层次:Cordis 提供服务依赖及可撤销的注册;bundle 分发代码和配置;profile 组合 bundle 及用户覆盖层。它用这套机制组合整个产品,适用范围明显大于 offckb 当前的外部工具接入需求。依据:[Cordis Primer](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/docs/cordis-primer.md)、[Architecture](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/docs/architecture.md)。 + +| dsh 的设计 | offckb 的取舍 | +| --- | --- | +| 依赖通过服务声明,避免靠偶然的加载顺序 | 首版只提供核心环境协议;Explorer 自己检查 RPC 和 indexer,暂不支持插件依赖插件 | +| 注册和副作用随插件卸载而回收 | 子进程、临时上下文文件由启动它们的命令负责清理 | +| 安装、选择启用和实际激活有不同状态 | 借鉴状态分离原则:包是否存在、命令是否可发现、兼容性诊断分别报告;首版没有 dsh 的配置激活过程,“可启动”也不表示服务已就绪 | +| 配置可预览,失败能定位到插件和原始原因 | 首版在 `plugin list` 提供诊断,独立 doctor 命令按需要增加;错误包含插件版本、执行阶段及日志位置 | +| 包管理委托现成工具 | 使用 npm 安装已发布包,不自建下载器、依赖解析器或注册中心 | +| 版本兼容在导入前检查 | 读取静态声明,检查协议版本和 Node engines 后才启动插件 | +| profile、bundle、配置覆盖、服务容器及 HMR | 暂缓;实际出现多开发环境和插件协作需求后再评估 | + +dsh 当前的插件管理已包含锁、兼容性检查、安装后选择 bundle 及失败恢复,不能简单概括成一层 `pnpm add`。offckb 应学它明确报告剩余状态的做法,避免一次复制整个管理体系。依据:[CLI 实现](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/apps/cli/src/plugin.ts)、[Plugin Manager](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/packages/boot/plugin-manager/README.md)。 + +## 实现路径与安装归属 + +| 路径 | 收益与成本 | 判断 | +| --- | --- | --- | +| 按 PATH 查找 `offckb-` 并转发 | 实现最少,但安装来源、版本、上下文和进程归属难统一 | 可做快捷入口;不足以完成这次期待的集成 | +| 用户自行安装,显式登记包路径,再使用相同的上下文及子进程协议 | 能获得一致集成体验,无需核心先实现安装器;用户自行处理升级和依赖 | Phase 1 采用本地路径验证;它是正式可考虑的简化方案,内置安装器并非插件协议的前提 | +| 静态声明 + 环境协议 + 子进程 | 保留独立 CLI,增加可控的发现、兼容性及生命周期约定 | **推荐** | +| 动态加载 JS 插件并提供 `register(ctx)` | 便于共享 Commander、事件和服务,但会把核心内部 API、模块解析和异常边界变成长期协议 | 等出现必须在进程内协作的需求再考虑 | + +子进程主要解决依赖、ESM/CJS 和故障边界;它仍以用户权限执行,**不是安全沙箱**。 + +用户自装与内置安装器是安装职责的选择,可以共用同一套插件协议。Phase 1 结束后应根据实际安装困难决定是否启动 Phase 2,不能仅因协议可行就认定必须开发安装器。 + +## 首版用户体验 + +以下都是拟议接口;插件包名及版本为示意,不代表已发布。 + +```sh +# 固定版本安装;不改当前项目的 package.json +offckb plugin add @offckb/plugin-explorer@ +offckb plugin list + +# 在已部署的项目目录中,连接 offckb 当前配置的开发链 +offckb node --daemon +offckb explorer --project . + +# 也能显式传入多个项目 +offckb explorer --project ./project-a --project ./project-b --port 7070 + +offckb plugin remove explorer +``` + +`explorer` 在前台运行,Ctrl+C 停止本次启动的 Explorer,CKB daemon 继续运行。首版没有后台 Explorer,也没有自动伴随 `offckb node` 启动;因此移除正在运行的插件应拒绝并提示先退出它。 + +安装到用户级 offckb 数据目录,调用时以当前工作目录为默认项目。只有显式安装登记的插件参与命令发现,不自动执行当前目录或 PATH 里碰巧出现的插件。第三方包无需使用 `@offckb` scope。 + +## 最小插件协议 + +一个包对应一个命令命名空间,子命令和参数由插件自己解析。示意 `package.json`: + +```json +{ + "name": "@offckb/plugin-explorer", + "version": "0.1.0", + "engines": { "node": ">=22" }, + "offckb": { + "apiVersion": 1, + "command": "explorer", + "entry": "dist/cli.js", + "description": "Browse the current offckb devnet" + } +} +``` + +核心只读取声明,不执行插件代码来取得名称或帮助摘要。`entry` 是包内已构建的 JS 文件,用当前 `process.execPath` 启动,绕过 `.bin`、shell quoting 和额外的 Node 查找;启动前确认其 realpath 在包目录内且为普通文件。首版要求交付可直接运行的包,不在用户安装时编译插件。 + +`command` 限制为简单名称,禁止覆盖核心命令;两个插件声明相同命令时,安装明确失败,不采用“后装覆盖”。协议版本或 Node engines 不兼容时阻止执行;某个插件损坏仍须允许使用核心命令及 `plugin remove`。 + +如果 offckb 升级后新增的核心命令与已装插件撞名,发现阶段始终优先注册核心命令,将该插件标记为 `shadowed` 并说明冲突。保留其安装及移除能力,不让冲突导致整个 CLI 启动失败,也不自动改写第三方插件名称。 + +核心通过 `OFFCKB_PLUGIN_CONTEXT` 指向一个临时 JSON 文件,提供本次命令的只读快照,内容限于: + +- `apiVersion`、offckb 版本、当前工作目录、插件专属数据及缓存目录。 +- 当前 devnet 的 RPC URL、proxy URL、链类型、genesis hash、实际地址前缀。 +- 本次生成的系统脚本 JSON 文件路径;沿用现有导出格式,明确记录格式版本。 +- 当前 CLI 的 Node 和入口文件绝对路径,作为必要时调用核心命令的备用方式。 +- 输出格式;结构化输出模式必须由适配器遵守,不能直接混入上游的人类日志。 + +不复制完整 settings、账户私钥或不相关配置。上面的“只读”是协议约定,不构成对同用户进程的文件权限隔离;文件在本次子进程退出后清理。配置中的 RPC 凭据也不能被原样打印到诊断日志。 + +核心在实际调用时解析上下文;顶层帮助、`plugin list` 只读声明,插件 `--help` 不要求链在线。插件拿到上下文后进行业务所需的就绪检查。所有参数作为 argv 数组转发,并保留调用者 cwd,禁止拼接 shell 命令。 + +### JSON 输出与参数边界 + +插件调用时,核心的 `--json` 必须放在插件命令名之前,例如 `offckb --json explorer --project .`。插件名之后的 argv 属于插件,核心不得提升或删除其中的 `--json`;命令边界后的首个 `--` 用作分隔符,消费后剩余 token 原样传递。现有内建命令的标志兼容行为不在本次改动范围内。 + +这要求先识别插件分派边界,再处理核心前缀,不能直接将整条插件命令交给现有 `normalizeGlobalJsonFlag()`:该函数会扫描并删除所有位置的 `--json`。Phase 1 必须验证重复标志、分隔符、带空格路径和未知插件参数的透传。 + +在核心选择 JSON 格式且成功启动插件后,**适配器拥有 stdout 的结果记录**:诊断写 stderr,成功时按约定输出一条结果;父进程禁止再执行 `runCli()` 的兜底成功输出,也不因插件退出而补一条成功记录。启动前失败由核心报告,启动后由适配器报告业务错误,父进程保留退出码和必要的进程诊断。帮助请求单独委托,返回后也不补结果记录。首版不通过解析上游人类日志判断 ready。 + +依据:[`src/cli.ts`](../src/cli.ts) 的 `normalizeGlobalJsonFlag()`、`runCli()`,以及 [`src/util/logger.ts`](../src/util/logger.ts) 的 `result()`/`hasResult()`。父子进程的 logger 状态并不共享,继承同一个 stdout 也无法抑制父进程的兜底输出。 + +这比让插件 import `src/cfg/setting` 稳定,也不要求插件依赖另一个版本的 `@offckb/cli`。后续可提供少量 TypeScript 类型帮助作者开发,无需先建立运行时 SDK。 + +### 安装与进程管理的边界 + +- 每个插件使用独立的安装目录及 npm lockfile;核心只登记命令、包名、精确版本、来源和安装位置,包及依赖的完整性以 npm lockfile 为准,不再维护一份重复的校验清单。Phase 1 直接使用本地路径,正式安装器首版接受固定版本的 registry 包;Git URL、tarball 和任意安装脚本暂缓。 +- 包管理调用发生在 offckb 专属目录,使用 `--save-exact --omit=dev --ignore-scripts`。安装脚本被禁用也不代表运行时代码可信;需要安装期构建的包应在发布前完成构建。依据:[npm install 文档](https://docs.npmjs.com/cli/v11/commands/npm-install/)。 +- Windows 上不能以为解析到 `npm.cmd` 的绝对路径后就能用 `shell:false` 直接执行。安装器优先验证通过 Node 启动当前 npm 安装的 JS 入口;无法可靠解析时明确失败,不拼接用户输入回退到 shell。该入口的发现规则及带空格路径行为必须通过 Windows 原型验证后才能作为实施依据。依据:[Node 子进程文档](https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows)。 +- 安装先进入同一数据卷的 staging 目录,再校验静态声明、入口和兼容性。登记更新加锁并原子替换;失败保留原登记及旧版本。不在每次执行时联网更新,也不在 `offckb node` 时自动安装。 +- 首版保留防止运行中移除或覆盖的最小互斥记录,因为它支撑上述 remove 语义;不抽象通用 runtime 状态机,也不提供基于持久 PID 的 stop/status。互斥与异常退出后的恢复必须在 Phase 1 原型验证,不能用“Windows 删除文件可能报错”替代跨平台运行保护。完整身份验证及跨进程停止流程留待服务托管阶段。 +- 前台启动器负责转发 SIGINT/SIGTERM、等待退出及清理自己创建的进程和临时文件。退出码应反映插件失败;超时终止只针对本次拥有的进程。Windows 进程树清理需要专项验证,不能只靠 POSIX 信号假设。 +- 管理命令的 `--json` 由核心输出;插件执行按上节由适配器拥有结果记录,两条路径不得混用。 + +## Explorer:适合接入,但需要明确适配 + +固定调研版本:[`477af4400a716353d140fc1a5b8fee8175f74fed`](https://github.com/wildanrhmn/ckb-devnet-explorer/tree/477af4400a716353d140fc1a5b8fee8175f74fed),包内版本为 `0.2.0`。以下是源码检查结果,尚未运行端到端验证。 + +| 上游现状 | 接入处理 | +| --- | --- | +| 单个 Node HTTP 服务,提供网页及 JSON API,不需要数据库或独立索引服务 | 保持独立进程及浏览器入口;不需要嵌入 offckb 的终端界面 | +| 需要 Node 22+,使用 tsx 运行 TS;offckb 当前支持 Node 20+ | 只限制 Explorer 插件的运行环境,不为此直接抬高核心要求;Node 20 下给出明确诊断 | +| 已支持 `--rpc`、重复 `--project`、`--port` | 适配器从上下文补上当前 RPC,项目目录保留调用者语义;首版不开放指向任意链的覆盖,避免系统脚本与 RPC 不匹配 | +| `loadSystemScripts()` 从 PATH 调用 `offckb system-scripts -o ...` | 改为优先读取核心传入的系统脚本文件,避免调用到另一版本的 offckb,也避免为单次调用安装 PATH shim | +| 项目脚本从 `/deployment/scripts.json` 加载,启动时读入 | 可直接复用;部署或清链后提示重启 Explorer,首版不增加部署事件总线 | +| 地址编码前缀写死为 `ckt` | 首版仅支持普通本地 devnet,检测到 fork 或无法确认支持的链身份就明确拒绝启动;后续 fork 支持需改用上下文前缀并验证脚本、地址及链切换 | +| 地址、脚本及部分交易查询调用 CKB 内置 indexer | RPC 可达不等于功能全部可用;必须检查 indexer 缺失或落后并说明结果可能不完整 | +| `server.listen(port)` 没有 host 参数,打印 localhost 并不限制监听范围 | 增加 host 参数,正式集成默认显式绑定 `127.0.0.1`;不能声称外层包装已经解决 | + +依据:[README](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/README.md)、[package.json](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/package.json)、[CLI](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/cli.ts)、[scripts.ts](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/scripts.ts)、[server.ts](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/server.ts)、[explorer.ts](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/explorer.ts)。Node 未指定 host 时的行为见 [Node net 文档](https://nodejs.org/api/net.html#serverlistenport-host-backlog-callback)。 + +推荐优先向上游贡献两个小入口:系统脚本文件输入、显式 host;上游仍可保留原 CLI 的默认行为。offckb 的独立适配包负责协议转换和就绪检查。若上游不接受,则需要维护明确版本的适配分支,不能承诺现有仓库不改代码就完整符合协议,也不宜长期依赖其私有 TS 源文件路径。 + +建议职责分配为:offckb 侧维护协议及适配包,Explorer 上游维护业务及 UI。具体维护者尚未确定,也没有获得上游承诺。Phase 1 的命令协议原型先使用最小假插件,不等待上游;本地 Explorer 实验可使用明确标注的临时适配分支。Phase 1 结束时检查上游入口是否可用:若不可用,只有在明确适配分支维护者及维护成本后才推进正式分发,否则保留实验结果、暂缓 Explorer 插件发布。 + +此外,上游缓存只在 tip 回退时清除,脚本名也在启动时加载。清链或换链后统一重启 Explorer,避免把“tip 没回退”误认为链未变化。支持普通 devnet 的首版也应验证这一场景。 + +## 分阶段落地 + +1. **先验证协议,不做安装器。** 先用最小假插件验证 Commander 动态注册、JSON 结果所有权、argv 透传及前台退出,覆盖生产 ncc 包、macOS 和 Windows。再用本地 Explorer 适配验证上下文文件往返、显式 loopback 监听、普通 devnet、fork 拒绝、清链重启和临时文件清理。这两组实验通过才算本阶段完成。 +2. **按需要交付最小插件管理。** 实现固定 npm 版本安装、list、remove、命令发现和前台运行;诊断先放进 list,不要求独立 doctor 命令才能交付。开始前先以具名第二工具 `ckb-tui` 做纸面走查:通过 Node 适配器启动原生二进制,核对 TTY、argv、上下文和信号是否足够;这不是承诺迁移现有 status 命令。核心目录预计新增 `src/plugins/` 与 `src/cmd/plugin.ts`,`src/cli.ts` 增加管理命令及统一发现入口。 +3. **按实际使用增加服务托管。** 再讨论 `offckb node --with explorer`、插件 start/stop/status/logs 和 daemon。届时增加独立于人类日志的 ready 通道,HTTP 监听成功不等于 CKB 业务就绪;端口冲突及启动超时必须返回可定位的错误。 +4. **有第二个真实案例再抽象。** 优先选择另一个外部工具验证协议;Fiber 作为较晚迁移对象,保留现有命令兼容性与 fork 限制。只有出现插件间协作或多环境复用需求,再引入服务依赖、profiles 或 bundle。 + +组合服务阶段必须明确所有权:连接已有 CKB 的插件命令不能关闭 CKB;同一个 node 管理器启动的插件随它清理;独立启动的插件由自己的管理器停止。可选 Explorer 失败只降级并报告,核心 CKB/miner 失败触发所拥有组件的反向清理;如以后提供 required 模式,再显式约定整体失败。现有 `--fiber` 行为保持到专门迁移时再讨论。 + +## Kimi 独立评审回应 + +Sentinel 使用 `kimi-coding/kimi-for-coding` 做了只读评审,认为选型及分期合理,提出 M1–M4 等问题。评审核对了本地源码,未独立复核 dsh 和 Explorer 外部仓库;这些意见是协作材料,不构成人类验收或接受的 Context。 + +- **M1,输出所有权:** 已补适配器拥有结果、父进程抑制兜底及标志边界;仍需命令原型证明。 +- **M2,升级撞名:** 已补核心优先、插件 `shadowed` 及保留移除能力。 +- **M3,Windows npm:** 采纳必须验证的风险,纠正“npm.cmd 绝对路径 + shell:false 即可”的修法;当前 npm JS 入口解析及跨平台实测仍未完成。 +- **M4,维护归属:** 已提出职责及 Phase 1 结束时的分发决策点;具体维护者未定,不能视为已关闭。 +- **S1/S2,复杂度:** 保留支撑“运行中不可移除”的最小互斥,通用运行状态机推迟;完整性复用 npm lockfile,不另建清单。 +- **第二工具与验证次序:** Phase 2 前以 `ckb-tui` 走查;按命令协议、Explorer 上下文往返、安装器跨平台的顺序验证。 + +## 实施时的验收点与未决项 + +- 不安装插件时,核心启动不加载插件代码,也不额外访问 registry 或 RPC;新增一个合规插件不用修改核心命令源码。 +- 在打包后的 CLI、带空格的路径、macOS/Linux/Windows 验证命令发现、argv、cwd、Node 版本检查、信号退出及清理。 +- 损坏声明、重复命令、升级后核心撞名、路径越界、并发安装/移除、安装中断和运行中移除都能明确处理;旧版本仍可使用,核心帮助和移除命令仍可运行。 +- 自定义 RPC 端口、多个项目、空 deployment、错误 JSON、端口占用、RPC 离线、indexer 缺失/落后、清链重启和 `--json` 输出均有覆盖。 +- Node 20 仍能使用 offckb 核心;Explorer 明确拒绝不满足 engines 的运行环境。普通 devnet 通过后,再单独验证 mainnet/testnet fork,不能依据 HTTP 能打开就宣布支持。 +- 未决:确认适配包维护者、上游入口及分发方式;验证当前 npm JS 入口的可靠发现、禁用安装脚本后的交付形态、最小运行互斥和 Windows 进程清理;以 `ckb-tui` 检验协议适用性。以上事项尚未因文档修订而获得验证。 + +以上保留整体设计与后续验收标准,不表示它们已经全部通过。第一阶段实现采用显式本地 link/list/unlink;registry 安装、后台服务与正式 Explorer 分发仍未交付。实现及验证方式见[插件指南](plugins.md)。 diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..544f948 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,111 @@ +# Experimental local tool plugins + +This is the first implementation stage of the [plugin proposal](plugin-system-proposal.md): explicit local registration, a versioned environment snapshot, and foreground process ownership. Registry downloads, `plugin add`, background plugins, `node --with`, and Fiber migration are deferred. + +## Try the protocol + +Build the CLI in this checkout, then explicitly link a local package you trust: + +```sh +pnpm build +node build/index.js plugin link ./examples/plugins/context-probe +node build/index.js --json plugin list +node build/index.js --json context-probe -- --json "path with spaces" -- literal +node build/index.js context-probe --help +node build/index.js plugin unlink context-probe +``` + +The probe demonstrates exact argument forwarding and one JSON result. An uninitialized devnet is represented as `unknown`; the probe still works. Plugin code executes with your user permissions. Linking reads static metadata and never runs an install script or plugin module. + +`link` stores a canonical package path. It does not copy or install dependencies. The package author/user prepares its runtime first. `unlink` removes the registration and keeps the package, data and cache. A foreground lease prevents concurrent runs of the same plugin and unlink/relink while it is running. + +Registrations default to `/plugins`. `OFFCKB_PLUGIN_HOME` selects an isolated registry/data/cache directory, useful for development. It does not change core settings or select another devnet. A broken registry does not block core commands or core help; `plugin list` reports the registry error without overwriting it. Invalid and incompatible packages remain removable. Future core command collisions are reported as `shadowed`, with the core command taking priority. + +## Package declaration + +```json +{ + "name": "my-offckb-tool", + "version": "0.1.0", + "engines": { "node": ">=20" }, + "offckb": { + "apiVersion": 1, + "command": "my-tool", + "entry": "dist/cli.mjs", + "description": "A local development tool" + } +} +``` + +The entry must be a regular `.js`, `.cjs` or `.mjs` file whose real path stays inside the package. TypeScript compilation belongs to the package. Dependencies resolve from its own directory. The host launches the entry with its current Node executable, using argv arrays and inherited stdio/cwd; it does not import the tool into the ncc bundle. + +Commands contain lowercase letters, digits and hyphens, starting with a letter (maximum 48 characters). Core names, `help`, and already-linked command names are reserved. Node engines and protocol version are checked before execution. + +## Context and output + +On execution, `OFFCKB_PLUGIN_CONTEXT` points to a temporary JSON file: + +- `apiVersion: 1`, `offckbVersion`, `cwd`, and `output: "json" | "text"`. +- `cli.node` and `cli.entry`: the current Node/CLI paths, not a PATH lookup. +- `plugin.command`, `plugin.dataPath`, `plugin.cachePath`: persistent, plugin-specific storage. +- `devnet.rpcUrl`, `proxyUrl`, `kind`, `genesisHash`, `addressPrefix`, `systemScriptsFile`, `systemScriptsVersion: 1`. + +The scripts file contains a `devnet` record using the existing `system-scripts` entry shape. It and the context are removed after the child closes, including failures and handled signals. Data/cache remain. No account keys or complete settings object are copied. RPC URLs can contain credentials; plugins must avoid dumping the context or raw URLs to logs. + +Chain metadata comes from the configured managed CKB binary's local `list-hashes` output, with a three-second limit and no shell interpolation. A missing config/binary yields `kind: "unknown"`, null chain identity and no scripts file. A custom fork marker without a recognized public genesis also yields `unknown`. Plugins needing a known chain must reject these cases, and must verify the RPC genesis against the context before interpreting addresses/scripts. A custom `node --binary-path` with no matching managed binary is not yet supported by the context exporter. + +Standalone `offckb my-tool --help` / `-h` is delegated without exporting context or reading core settings. Tools should handle that request before reading `OFFCKB_PLUGIN_CONTEXT`. + +Core `--json` must precede the plugin command: + +```sh +offckb --json my-tool --project . +offckb my-tool --json +offckb --json my-tool -- --json -- literal +``` + +The first invocation selects core JSON mode. In the second, `--json` is a plugin argument and core JSON mode stays off. The host consumes only an immediately following `--` separator; all remaining tokens, including another `--`, are preserved. Existing core commands retain their flag normalization. + +After delegation, the child owns stdout, including its single JSON success result. The host never appends its fallback result; it propagates child exit status. Diagnostics and structured failures belong on stderr. This preserves the host's stream contract but does not validate or sandbox a misbehaving plugin's output. + +## Explorer experiment + +The example adapter uses `wildanrhmn/ckb-devnet-explorer` at commit `477af4400a716353d140fc1a5b8fee8175f74fed` (0.2.0), verified unchanged on 2026-10-29. It is **an unpublished, repository-only experiment**, using pinned private source imports. Formal upstream entry points and a release maintainer are still needed before distribution. + +Prepare a source checkout at that revision: + +```sh +git clone https://github.com/wildanrhmn/ckb-devnet-explorer.git /tmp/ckb-devnet-explorer +git -C /tmp/ckb-devnet-explorer checkout 477af4400a716353d140fc1a5b8fee8175f74fed +node scripts/prepare-explorer-prototype.mjs /tmp/ckb-devnet-explorer +npm install --prefix .prototype/explorer --omit=dev --ignore-scripts + +node build/index.js plugin link ./examples/plugins/explorer +node build/index.js node --daemon +node build/index.js explorer --project /path/to/deployed-project --port 7070 +``` + +The preparation script copies source into ignored `.prototype/explorer`; it does not edit the checkout supplied by the user. The local copy adds an explicit server host and moves the upstream startup message to stderr. The adapter reads system scripts from the host snapshot, merges `deployment/scripts.json` from selected projects, and never invokes `offckb` from PATH. It loads tsx's declared ESM entry, matching upstream's ESM loader. + +Node 22+ and an initialized ordinary devnet are required. Forks and unknown chain identities are rejected before listening. RPC genesis must match the supplied scripts context; missing/lagging built-in indexer is reported separately. The web server binds `127.0.0.1` and defaults to 7070; `--port 0` selects a free port. `--project` is repeatable and defaults to invocation cwd. There is no arbitrary `--rpc` override or automatic browser launch. + +Ctrl+C stops the Explorer command, while a separately running CKB daemon continues. Restart Explorer after redeployment, chain reset or chain switching so its script names and caches reload. Upstream acceptance of new APIs is not required for this local experiment, but the experiment is not a promise to maintain a fork. + +## Validation and remaining limits + +```sh +pnpm test --runInBand +pnpm typecheck +pnpm lint +pnpm build +# Optional: prepared Explorer runtime and an official CKB 0.208.0 binary are required. +node scripts/plugin-smoke.mjs /path/to/ckb +``` + +The opt-in smoke test uses the production ncc bundle, random local ports, temporary settings and a copied devnet. It checks exact argv, single JSON result, offline help, the real Explorer page/API, current RPC/project context, refusal to unlink a running plugin, signal cleanup, CKB remaining alive, chain reset/restart and rejection of an unknown fork marker. It redirects `os.homedir()` only inside its test workers before loading the bundle; it does not change HOME or user settings. Downloaded binaries, source and runtime dependencies are not shipped. + +The second-tool paper review uses existing `src/cmd/status.ts`: ckb-tui would consume `proxyUrl`, retain TTY checks and forward native `-r`/optional `-t` arguments from a Node adapter. The current context lacks the optional TCP log address, so the minimal adapter could use RPC-only mode; adding that address or requiring full log panels needs a later explicit protocol decision. Native TTY/job-control behavior still requires testing. Existing `status` stays in core. + +POSIX plugin processes use an invocation-owned group. SIGINT/SIGTERM/SIGHUP are forwarded; a three-second shutdown limit and final group cleanup prevent owned descendants from remaining after the tool exits. The host never signals a saved PID. Windows uses `taskkill /T /F`, so graceful cleanup differs and needs native Windows verification. Plugins must not detach their own unmanaged daemons. + +SIGKILL, host crashes or power loss can leave `registry.lock`, `.running`, or temporary context directories. This stage fails closed on those locks: verify the owner/tool has stopped before removing the named stale file, then retry. Automatic crash recovery, full runtime identity management, concurrent instances and background services are deferred. Registry locks are short-lived and registration writes use atomic rename; tool source itself remains mutable because this is explicit local linking. diff --git a/examples/plugins/context-probe/cli.cjs b/examples/plugins/context-probe/cli.cjs new file mode 100644 index 0000000..ed61525 --- /dev/null +++ b/examples/plugins/context-probe/cli.cjs @@ -0,0 +1,18 @@ +const fs = require('node:fs'); + +if (process.argv.slice(2).includes('--help')) { + console.log('Usage: offckb context-probe [arguments...]\nPrints the invocation context and unmodified arguments.'); +} else { + const context = JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT, 'utf8')); + // Do not dump the whole context: RPC URLs can contain credentials. + const result = { + ok: true, + command: 'context-probe', + argv: process.argv.slice(2), + cwd: context.cwd, + output: context.output, + kind: context.devnet.kind, + cliEntry: context.cli.entry, + }; + console.log(JSON.stringify(result)); +} diff --git a/examples/plugins/context-probe/package.json b/examples/plugins/context-probe/package.json new file mode 100644 index 0000000..5624fd3 --- /dev/null +++ b/examples/plugins/context-probe/package.json @@ -0,0 +1,11 @@ +{ + "name": "offckb-context-probe", + "version": "0.1.0", + "private": true, + "offckb": { + "apiVersion": 1, + "command": "context-probe", + "entry": "cli.cjs", + "description": "Inspect plugin context and argument forwarding (development example)" + } +} diff --git a/examples/plugins/explorer/cli.mjs b/examples/plugins/explorer/cli.mjs new file mode 100644 index 0000000..1871e1f --- /dev/null +++ b/examples/plugins/explorer/cli.mjs @@ -0,0 +1,134 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + console.log( + 'Usage: offckb explorer [--project ]... [--port <0-65535>]\nExperimental adapter: Node 22+, ordinary initialized devnet, prepared upstream source.', + ); + process.exit(0); +} + +let json = false; +try { + const context = JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT, 'utf8')); + json = context.output === 'json'; + if (context.apiVersion !== 1) throw new Error('Unsupported offckb context version'); + if ( + context.devnet.kind !== 'pure-devnet' || + context.devnet.addressPrefix !== 'ckt' || + !context.devnet.systemScriptsFile + ) { + throw new Error( + 'Explorer currently requires an initialized ordinary devnet; forks and unknown chains are unsupported.', + ); + } + const { values } = parseArgs({ + args, + options: { + project: { type: 'string', multiple: true }, + port: { type: 'string', default: '7070' }, + }, + }); + const port = Number(values.port); + if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('--port must be an integer from 0 to 65535'); + const projects = (values.project ?? [context.cwd]).map((directory) => path.resolve(context.cwd, directory)); + const source = path.resolve( + process.env.OFFCKB_EXPLORER_SOURCE || fileURLToPath(new URL('../../../.prototype/explorer', import.meta.url)), + ); + const marker = path.join(source, '.offckb-prototype.json'); + if (!fs.statSync(marker).isFile() || JSON.parse(fs.readFileSync(marker, 'utf8')).apiVersion !== 1) { + throw new Error('Prepare the pinned Explorer source first; see docs/plugins.md'); + } + const require = createRequire(path.join(source, 'package.json')); + const tsxManifest = require.resolve('tsx/package.json'); + const tsx = JSON.parse(fs.readFileSync(tsxManifest, 'utf8')); + const esmApi = tsx.exports['./esm/api'].import.default; + const { register } = await import(pathToFileURL(path.resolve(path.dirname(tsxManifest), esmApi)).href); + register(); + // Deliberately pinned private imports for this experiment, not a published compatibility contract. + const [{ startServer }, { Explorer }, { Resolver }, { loadProjectScripts }] = await Promise.all( + ['server', 'explorer', 'resolver', 'scripts'].map( + (name) => import(pathToFileURL(path.join(source, 'src', `${name}.ts`)).href), + ), + ); + const exported = JSON.parse(fs.readFileSync(context.devnet.systemScriptsFile, 'utf8')); + const known = new Map(); + for (const [name, entry] of Object.entries(exported.devnet)) { + const script = entry?.script ?? entry; + if (!script?.codeHash || !script.hashType) continue; + const dep = script.cellDeps?.[0]?.cellDep; + known.set(`${script.codeHash.toLowerCase()}:${script.hashType}`, { + name, + source: 'genesis', + codeHash: script.codeHash.toLowerCase(), + hashType: script.hashType, + location: dep ? { txHash: dep.outPoint.txHash, index: Number(dep.outPoint.index), depType: dep.depType } : null, + }); + } + for (const project of projects) for (const [key, script] of loadProjectScripts(project)) known.set(key, script); + const rpc = async (method, params = []) => { + const response = await fetch(context.devnet.rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(3000), + }); + if (!response.ok) throw new Error('Devnet RPC request failed'); + const result = await response.json(); + if (result.error) throw new Error(`Devnet RPC ${method} is unavailable`); + return result.result; + }; + if ((await rpc('get_block_hash', ['0x0'])) !== context.devnet.genesisHash) { + throw new Error('Configured RPC genesis does not match the offckb devnet context.'); + } + const tip = BigInt(await rpc('get_tip_block_number')); + let indexerReady = false; + try { + indexerReady = BigInt((await rpc('get_indexer_tip')).block_number) >= tip; + } catch { + /* Report separately. */ + } + if (!indexerReady) + console.error('CKB indexer is missing or behind; some Explorer queries may fail or be incomplete.'); + const server = startServer( + new Explorer(context.devnet.rpcUrl, new Resolver(known)), + port, + context.devnet.rpcUrl, + projects, + '127.0.0.1', + ); + server.once('listening', () => { + const url = `http://127.0.0.1:${server.address().port}`; + if (json) console.log(JSON.stringify({ ok: true, command: 'explorer', url, indexerReady })); + else console.error(`Explorer: ${url}`); + }); + server.on('error', (error) => { + console.error( + json + ? JSON.stringify({ ok: false, code: 'EXPLORER_SERVER_FAILED', message: error.code || 'Server failed' }) + : `Explorer server failed: ${error.code || 'unknown error'}`, + ); + process.exitCode = 1; + }); + for (const [signal, code] of [ + ['SIGINT', 130], + ['SIGTERM', 143], + ['SIGHUP', 129], + ]) { + process.once(signal, () => { + server.close(() => { + process.exitCode = code; + }); + server.closeAllConnections(); + }); + } +} catch (error) { + const message = + error.code === 'ENOENT' ? 'Prepared Explorer source or context is missing; see docs/plugins.md' : error.message; + console.error(json ? JSON.stringify({ ok: false, code: 'EXPLORER_START_FAILED', message }) : message); + process.exitCode = 1; +} diff --git a/examples/plugins/explorer/package.json b/examples/plugins/explorer/package.json new file mode 100644 index 0000000..e32a044 --- /dev/null +++ b/examples/plugins/explorer/package.json @@ -0,0 +1,15 @@ +{ + "name": "offckb-explorer-prototype", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "offckb": { + "apiVersion": 1, + "command": "explorer", + "entry": "cli.mjs", + "description": "Browse the current ordinary devnet (experimental upstream adapter)" + } +} diff --git a/scripts/plugin-smoke.mjs b/scripts/plugin-smoke.mjs new file mode 100644 index 0000000..f81b022 --- /dev/null +++ b/scripts/plugin-smoke.mjs @@ -0,0 +1,211 @@ +// Opt-in live integration test. Requires a build, a CKB 0.208.0 binary and the prepared Explorer runtime. +// All settings, ports, chain data and registrations belong to a fresh temporary directory. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import net from 'node:net'; +import { execFileSync, spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const project = path.resolve(fileURLToPath(new URL('../', import.meta.url))); +const cli = path.join(project, 'build/index.js'); +const ckb = process.argv[2]; +if (!ckb || !fs.statSync(ckb).isFile()) throw new Error('Usage: node scripts/plugin-smoke.mjs '); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb plugin smoke ')); +const chain = path.join(root, 'devnet'); +const home = path.join(root, 'plugins'); +const env = { + ...process.env, + OFFCKB_SMOKE_ROOT: root, + OFFCKB_PLUGIN_HOME: home, + OFFCKB_CLI_PATH: cli, + XDG_CONFIG_HOME: path.join(root, 'config'), + XDG_DATA_HOME: path.join(root, 'data'), + XDG_CACHE_HOME: path.join(root, 'cache'), + APPDATA: path.join(root, 'appdata'), + LOCALAPPDATA: path.join(root, 'localappdata'), +}; +// Redirect os.homedir in this test worker before the production bundle loads. HOME itself stays unchanged. +const worker = `require('os').homedir = () => process.env.OFFCKB_SMOKE_ROOT; process.argv = [process.execPath, ${JSON.stringify(cli)}, ...process.argv.slice(1)]; require(${JSON.stringify(cli)}).runCli(process.argv);`; +const children = new Set(); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +async function until(check, description) { + const deadline = Date.now() + 20000; + while (Date.now() < deadline) { + if (await check()) return; + await wait(50); + } + throw new Error(`Timed out: ${description}`); +} +async function freePort() { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const port = server.address().port; + await new Promise((resolve) => server.close(resolve)); + return port; +} +function invoke(args) { + return execFileSync(process.execPath, ['-e', worker, '--', ...args], { + cwd: project, + env, + encoding: 'utf8', + timeout: 15000, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} +function start(args) { + const child = spawn(process.execPath, ['-e', worker, '--', ...args], { + cwd: project, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const state = { child, stdout: '', stderr: '', code: undefined }; + children.add(state); + child.stdout.on('data', (chunk) => { + state.stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + state.stderr += chunk; + }); + state.closed = new Promise((resolve) => + child.once('close', (code) => { + state.code = code; + children.delete(state); + resolve(code); + }), + ); + return state; +} +async function stop(state) { + if (state.code !== undefined) return; + state.child.kill('SIGTERM'); + const timer = setTimeout(() => state.child.kill('SIGKILL'), 10000); + try { + await state.closed; + } finally { + clearTimeout(timer); + } +} +async function rpc(url, method) { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: [] }), + signal: AbortSignal.timeout(3000), + }); + const value = await response.json(); + if (value.error) throw new Error(JSON.stringify(value.error)); + return value.result; +} + +try { + const [rpcPort, proxyPort, tcpPort, p2pPort] = await Promise.all([freePort(), freePort(), freePort(), freePort()]); + const rpcUrl = `http://127.0.0.1:${rpcPort}`; + fs.cpSync(path.join(project, 'ckb/devnet'), chain, { + recursive: true, + filter: (file) => file !== path.join(project, 'ckb/devnet/data'), + }); + const config = path.join(chain, 'ckb.toml'); + fs.writeFileSync( + config, + fs + .readFileSync(config, 'utf8') + .replaceAll('127.0.0.1:8114', `127.0.0.1:${rpcPort}`) + .replaceAll('127.0.0.1:18114', `127.0.0.1:${tcpPort}`) + .replaceAll('/ip4/0.0.0.0/tcp/8115', `/ip4/127.0.0.1/tcp/${p2pPort}`), + ); + const miner = path.join(chain, 'ckb-miner.toml'); + fs.writeFileSync(miner, fs.readFileSync(miner, 'utf8').replaceAll('http://ckb:8114/', rpcUrl)); + const bins = path.join(root, 'bins'); + const binary = path.join(bins, '0.208.0', process.platform === 'win32' ? 'ckb.exe' : 'ckb'); + fs.mkdirSync(path.dirname(binary), { recursive: true }); + fs.copyFileSync(ckb, binary); + fs.chmodSync(binary, 0o755); + const settings = + process.platform === 'darwin' + ? path.join(root, 'Library/Preferences/offckb-nodejs/settings.json') + : process.platform === 'win32' + ? path.join(env.APPDATA, 'offckb-nodejs/Config/settings.json') + : path.join(env.XDG_CONFIG_HOME, 'offckb-nodejs/settings.json'); + fs.mkdirSync(path.dirname(settings), { recursive: true }); + fs.writeFileSync( + settings, + JSON.stringify({ + bins: { rootFolder: bins, defaultCKBVersion: '0.208.0' }, + devnet: { + rpcUrl, + rpcProxyPort: proxyPort, + configPath: chain, + dataPath: path.join(chain, 'data'), + transactionsPath: path.join(chain, 'transactions'), + debugFullTransactionsPath: path.join(chain, 'full-transactions'), + }, + }), + ); + + invoke(['--json', 'plugin', 'link', path.join(project, 'examples/plugins/context-probe')]); + const probe = JSON.parse(invoke(['--json', 'context-probe', '--', '--json', 'a b', '--', 'tail'])); + assert.deepEqual(probe.argv, ['--json', 'a b', '--', 'tail']); + assert.equal(probe.output, 'json'); + assert.equal(probe.cliEntry, cli); + assert.equal(probe.kind, 'pure-devnet'); + assert.match(invoke(['context-probe', '--help']), /Usage/); + console.log('PASS: production bundle, exact argv, single JSON result, offline help'); + + invoke(['--json', 'plugin', 'link', path.join(project, 'examples/plugins/explorer')]); + for (let cycle = 0; cycle < 2; cycle++) { + const node = start(['--json', 'node']); + try { + await until(async () => { + if (node.code !== undefined) throw new Error(`Node exited: ${node.stderr}`); + try { + return (await rpc(rpcUrl, 'get_tip_block_number')) !== undefined; + } catch { + return false; + } + }, 'devnet RPC'); + const explorer = start(['--json', 'explorer', '--project', project, '--port', '0']); + try { + await until(() => { + if (explorer.code !== undefined) throw new Error(`Explorer exited: ${explorer.stderr}`); + return explorer.stdout.trim().startsWith('{'); + }, 'Explorer ready result'); + const ready = JSON.parse(explorer.stdout); + assert.match(ready.url, /^http:\/\/127\.0\.0\.1:/); + assert.equal((await fetch(ready.url)).status, 200); + const info = await (await fetch(`${ready.url}/api/info`)).json(); + assert.equal(info.rpcUrl, rpcUrl); + assert.deepEqual(info.projects, [project]); + const overview = await fetch(`${ready.url}/api/overview`); + assert.equal(overview.status, 200, await overview.text()); + assert.throws(() => invoke(['--json', 'plugin', 'unlink', 'explorer']), /Command failed/); + } finally { + await stop(explorer); + } + assert.equal(explorer.code, 143); + assert.equal(explorer.stdout.trim().split('\n').length, 1); + assert.equal(fs.existsSync(path.join(home, 'explorer.running')), false); + assert.deepEqual(fs.readdirSync(path.join(home, 'tmp')), []); + assert.ok(await rpc(rpcUrl, 'get_tip_block_number')); + console.log( + `PASS: live Explorer cycle ${cycle + 1}, page/API, RPC context, SIGTERM, CKB remains up, clean context`, + ); + } finally { + await stop(node); + } + if (cycle === 0) invoke(['--json', 'clean', '--data']); + } + fs.writeFileSync(path.join(chain, 'fork.json'), JSON.stringify({ source: 'custom', firstRunPending: false })); + assert.throws(() => invoke(['--json', 'explorer', '--port', '0']), /Command failed/); + fs.unlinkSync(path.join(chain, 'fork.json')); + invoke(['--json', 'plugin', 'unlink', 'explorer']); + assert.deepEqual(fs.readdirSync(path.join(home, 'tmp')), []); + console.log('PASS: chain reset/restart, unknown fork rejection, unlink'); +} finally { + await Promise.all([...children].map(stop)); + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/scripts/prepare-explorer-prototype.mjs b/scripts/prepare-explorer-prototype.mjs new file mode 100644 index 0000000..df0afca --- /dev/null +++ b/scripts/prepare-explorer-prototype.mjs @@ -0,0 +1,40 @@ +// Copy a user-supplied checkout, keeping the original upstream tree untouched. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const source = process.argv[2]; +if (!source) throw new Error('Usage: node scripts/prepare-explorer-prototype.mjs '); +const target = fileURLToPath(new URL('../.prototype/explorer/', import.meta.url)); +if (fs.existsSync(target)) + throw new Error(`Destination already exists; review/remove it before preparing again: ${target}`); +const pkg = JSON.parse(fs.readFileSync(path.join(source, 'package.json'), 'utf8')); +if (pkg.name !== 'ckb-devnet-explorer' || pkg.version !== '0.2.0') + throw new Error('Expected ckb-devnet-explorer 0.2.0 at commit 477af4400a716353d140fc1a5b8fee8175f74fed'); +let server = fs.readFileSync(path.join(source, 'src/server.ts'), 'utf8'); +const signature = 'projects: string[])'; +const listen = 'server.listen(port, () => console.log('; +if (!server.includes(signature) || !server.includes(listen)) + throw new Error('Upstream server changed; review the experimental adapter instead of patching blindly'); +server = server + .replace(signature, 'projects: string[], host = "127.0.0.1")') + .replace(listen, 'server.listen(port, host, () => console.error('); +fs.mkdirSync(target, { recursive: true }); +fs.cpSync(path.join(source, 'src'), path.join(target, 'src'), { recursive: true }); +// A runtime-only manifest avoids installing upstream development tools or running its scripts. +fs.writeFileSync( + path.join(target, 'package.json'), + JSON.stringify( + { name: 'offckb-explorer-prototype-runtime', private: true, type: 'module', dependencies: pkg.dependencies }, + null, + 2, + ), +); +fs.writeFileSync(path.join(target, 'src/server.ts'), server); +fs.writeFileSync( + path.join(target, '.offckb-prototype.json'), + JSON.stringify({ apiVersion: 1, upstreamCommit: '477af4400a716353d140fc1a5b8fee8175f74fed' }), +); +console.log( + `Prepared experimental source at ${target}. Install its runtime dependencies with scripts disabled; see docs/plugins.md.`, +); diff --git a/src/cli.ts b/src/cli.ts index dc765c9..dab5249 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,6 +36,7 @@ import { logger } from './util/logger'; import { installBrokenPipeHandlers } from './util/shutdown'; import { Network } from './type/base'; import { status } from './cmd/status'; +import { registerPluginManagement } from './plugins/cli'; const version = require('../package.json').version; const description = require('../package.json').description; @@ -471,6 +472,8 @@ devnetCommand .option('--dry-run', 'Run source/spec/database preflight without replacing the current devnet') .action(devnetFork); +const plugins = registerPluginManagement(program); + function normalizeGlobalJsonFlag(argv: string[]): string[] { const jsonRequested = argv.slice(2).includes('--json'); if (!jsonRequested) return argv; @@ -489,19 +492,26 @@ function configureCommanderErrors(command: Command) { export async function runCli(argv: string[] = process.argv): Promise { installBrokenPipeHandlers(); - const normalizedArgv = normalizeGlobalJsonFlag(argv); - if (normalizedArgv.includes('--json')) logger.setJsonMode(true); - - if (!normalizedArgv.slice(2).length) { - program.outputHelp(); - return; - } - - configureCommanderErrors(program); - try { + const commandIndex = argv.findIndex((arg, index) => index >= 2 && !arg.startsWith('-')); + if (argv.slice(2, commandIndex === -1 ? argv.length : commandIndex).includes('--json')) logger.setJsonMode(true); + // Determine the plugin boundary before the legacy global flag normalization. + // Only core arguments may participate in --json promotion. + const invocation = plugins.prepare(argv); + const normalizedArgv = normalizeGlobalJsonFlag(invocation.argv); + if (normalizedArgv.includes('--json')) logger.setJsonMode(true); + if (!normalizedArgv.slice(2).length) { + program.outputHelp(); + return; + } + configureCommanderErrors(program); await program.parseAsync(normalizedArgv); - if (logger.isJsonMode() && !logger.hasResult() && (process.exitCode == null || process.exitCode === 0)) { + if ( + !invocation.delegated() && + logger.isJsonMode() && + !logger.hasResult() && + (process.exitCode == null || process.exitCode === 0) + ) { logger.result({ command: activeCommand, completed: true }); } } catch (error) { diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts new file mode 100644 index 0000000..1cca892 --- /dev/null +++ b/src/plugins/cli.ts @@ -0,0 +1,83 @@ +import { Command } from 'commander'; +import { logger } from '../util/logger'; +import { linkPlugin, listPlugins, unlinkPlugin } from './store'; +import { runPlugin } from './run'; + +export function registerPluginManagement(program: Command): { + prepare: (argv: string[]) => { argv: string[]; delegated: () => boolean }; +} { + const dynamic = new Set(); + const reserved = () => + new Set([ + 'help', + ...program.commands + .filter((command) => !dynamic.has(command)) + .flatMap((command) => [command.name(), ...command.aliases()]), + ]); + const management = program.command('plugin').description('Link and run local tool plugins (experimental)'); + management + .command('link ') + .description('Explicitly trust and register a local plugin package; runs with your user permissions') + .action((directory: string) => { + const plugin = linkPlugin(directory, reserved()); + logger.info(`Linked ${plugin.command} (${plugin.name}@${plugin.version})`); + logger.result({ command: 'plugin.link', plugin: plugin.command, directory: plugin.directory }); + }); + management + .command('list') + .description('List linked plugins and compatibility diagnostics without executing them') + .action(() => { + const plugins = listPlugins(reserved()); + for (const plugin of plugins) + logger.info(`${plugin.command}: ${plugin.state}${plugin.error ? ` — ${plugin.error}` : ''}`); + logger.result({ command: 'plugin.list', plugins }); + }); + management + .command('unlink ') + .description('Unregister a stopped local plugin; keeps its package and data') + .action((command: string) => { + unlinkPlugin(command); + logger.info(`Unlinked ${command}`); + logger.result({ command: 'plugin.unlink', plugin: command }); + }); + + return { + prepare(argv) { + for (const command of dynamic) { + const index = program.commands.indexOf(command); + // Commander exposes a readonly view of its mutable registration array. + if (index !== -1) (program.commands as Command[]).splice(index, 1); + } + dynamic.clear(); + const index = argv.findIndex((arg, i) => i >= 2 && !arg.startsWith('-')); + const selected = index === -1 ? undefined : argv[index]; + let delegated = false; + // A broken registry must not break core commands, including help and repair commands. + let plugins: ReturnType = []; + try { + plugins = listPlugins(reserved()); + } catch (error) { + if (selected && !reserved().has(selected)) throw error; + } + let pluginSelected = false; + for (const plugin of plugins) { + if (plugin.state === 'shadowed') continue; + const command = program + .command(plugin.command) + .description(plugin.description || plugin.error || 'Local plugin') + .helpOption(false) + .action(async () => { + if (plugin.state !== 'available') throw new Error(plugin.error); + const args = argv.slice(index + 1); + if (args[0] === '--') args.shift(); + delegated = true; + await runPlugin(plugin.command, args, argv.slice(2, index).includes('--json')); + }); + dynamic.add(command); + if (plugin.command === selected) pluginSelected = true; + } + // Commander parses only the core prefix. The plugin's suffix is kept byte-for-byte above. + return { argv: pluginSelected ? argv.slice(0, index + 1) : argv, delegated: () => delegated }; + }, + }; +} diff --git a/src/plugins/context.ts b/src/plugins/context.ts new file mode 100644 index 0000000..e750fc2 --- /dev/null +++ b/src/plugins/context.ts @@ -0,0 +1,92 @@ +import fs from 'fs'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import { getCKBBinaryPath, readSettings } from '../cfg/setting'; +import { resolveCliEntry } from '../util/daemon'; +import { resolveDevnetSystemScriptsFromListHashes } from '../scripts/private'; +import { PluginManifest } from './manifest'; +import { pluginHome } from './store'; + +export interface PluginContext { + apiVersion: 1; + offckbVersion: string; + cwd: string; + output: 'json' | 'text'; + cli: { node: string; entry: string | null }; + plugin: { command: string; dataPath: string; cachePath: string }; + devnet: { + rpcUrl: string; + proxyUrl: string; + kind: 'pure-devnet' | 'fork-of-mainnet' | 'fork-of-testnet' | 'unknown'; + genesisHash: string | null; + addressPrefix: 'ckb' | 'ckt' | null; + systemScriptsFile: string | null; + systemScriptsVersion: 1; + }; +} + +function isFile(file: string): boolean { + try { + return fs.statSync(file).isFile(); + } catch { + return false; + } +} + +export function createPluginContext(plugin: PluginManifest, json: boolean): { file: string; dispose: () => void } { + const temporaryRoot = path.join(pluginHome(), 'tmp'); + fs.mkdirSync(temporaryRoot, { recursive: true, mode: 0o700 }); + const directory = fs.mkdtempSync(path.join(temporaryRoot, 'run-')); + const dispose = () => fs.rmSync(directory, { recursive: true, force: true }); + try { + const settings = readSettings({ strict: true }); + // No installation, node startup or RPC request. An uninitialized chain stays unknown. + const scripts = + isFile(path.join(settings.devnet.configPath, 'ckb.toml')) && + isFile(getCKBBinaryPath(settings.bins.defaultCKBVersion)) + ? resolveDevnetSystemScriptsFromListHashes( + execFileSync( + getCKBBinaryPath(settings.bins.defaultCKBVersion), + ['list-hashes', '-C', settings.devnet.configPath], + { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'] }, + ), + ) + : null; + const systemScriptsFile = scripts ? path.join(directory, 'system-scripts.json') : null; + if (systemScriptsFile) + fs.writeFileSync(systemScriptsFile, JSON.stringify({ devnet: scripts!.scripts }), { mode: 0o600 }); + const data = path.join(pluginHome(), 'data', plugin.command); + const cache = path.join(pluginHome(), 'cache', plugin.command); + fs.mkdirSync(data, { recursive: true, mode: 0o700 }); + fs.mkdirSync(cache, { recursive: true, mode: 0o700 }); + const context: PluginContext = { + apiVersion: 1, + offckbVersion: require('../../package.json').version, + cwd: process.cwd(), + output: json ? 'json' : 'text', + cli: { node: process.execPath, entry: resolveCliEntry() }, + plugin: { command: plugin.command, dataPath: data, cachePath: cache }, + devnet: { + rpcUrl: settings.devnet.rpcUrl, + proxyUrl: `http://127.0.0.1:${settings.devnet.rpcProxyPort}`, + kind: !scripts + ? 'unknown' + : scripts.forkedFrom + ? `fork-of-${scripts.forkedFrom}` + : fs.existsSync(path.join(settings.devnet.configPath, 'fork.json')) + ? 'unknown' + : 'pure-devnet', + genesisHash: scripts?.genesisHash ?? null, + addressPrefix: !scripts ? null : scripts.forkedFrom === 'mainnet' ? 'ckb' : 'ckt', + systemScriptsFile, + systemScriptsVersion: 1, + }, + }; + const file = path.join(directory, 'context.json'); + fs.writeFileSync(file, JSON.stringify(context), { mode: 0o600, flag: 'wx' }); + return { file, dispose }; + } catch (error) { + dispose(); + throw error; + } +} diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts new file mode 100644 index 0000000..ec920f0 --- /dev/null +++ b/src/plugins/manifest.ts @@ -0,0 +1,66 @@ +import fs from 'fs'; +import path from 'path'; +import semver from 'semver'; + +export interface PluginManifest { + directory: string; + name: string; + version: string; + command: string; + description: string; + entry: string; + node?: string; +} + +export function isPluginCommand(value: unknown): value is string { + return typeof value === 'string' && /^[a-z][a-z0-9-]{0,47}$/.test(value); +} + +export function readJsonFile(file: string): unknown { + const stat = fs.statSync(file); + if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error(`Expected a JSON file under 1 MiB: ${file}`); + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +export function readPluginManifest(directory: string): PluginManifest { + const root = fs.realpathSync(directory); + const pkg = readJsonFile(path.join(root, 'package.json')) as Record; + const declaration = pkg?.offckb as Record | undefined; + if (!pkg || typeof pkg.name !== 'string' || !pkg.name.trim() || !semver.valid(String(pkg.version))) { + throw new Error(`Invalid plugin package identity: ${root}`); + } + if (declaration?.apiVersion !== 1 || !isPluginCommand(declaration.command)) { + throw new Error(`Expected offckb.apiVersion 1 and a simple offckb.command in ${root}/package.json`); + } + if (typeof declaration.entry !== 'string' || path.isAbsolute(declaration.entry)) { + throw new Error('offckb.entry must be a relative JavaScript file'); + } + const entry = fs.realpathSync(path.resolve(root, declaration.entry)); + const relative = path.relative(root, entry); + if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) { + throw new Error('offckb.entry must stay inside the plugin package'); + } + if (!fs.statSync(entry).isFile() || !/\.(?:cjs|mjs|js)$/.test(entry)) { + throw new Error('offckb.entry must be a regular, prebuilt .js, .cjs or .mjs file'); + } + const engines = pkg.engines as Record | undefined; + const node = engines?.node; + if (node !== undefined && (typeof node !== 'string' || !semver.validRange(node))) { + throw new Error('Invalid plugin engines.node range'); + } + return { + directory: root, + name: pkg.name, + version: String(pkg.version), + command: declaration.command, + entry, + description: typeof declaration.description === 'string' ? declaration.description : pkg.name, + ...(typeof node === 'string' ? { node } : {}), + }; +} + +export function assertPluginCompatible(plugin: PluginManifest): void { + if (plugin.node && !semver.satisfies(process.versions.node, plugin.node)) { + throw new Error(`${plugin.command} requires Node ${plugin.node}; running ${process.versions.node}`); + } +} diff --git a/src/plugins/run.ts b/src/plugins/run.ts new file mode 100644 index 0000000..1a09758 --- /dev/null +++ b/src/plugins/run.ts @@ -0,0 +1,91 @@ +import { ChildProcess, execFileSync, spawn } from 'child_process'; +import { + bindChildrenToProcessLifetime, + LIFETIME_SIGNALS, + LifetimeSignal, + signalExitCode, +} from '../util/child-lifetime'; +import { enterGracefulShutdown } from '../util/shutdown'; +import { createPluginContext } from './context'; +import { acquirePlugin } from './store'; + +function signalChild(child: ChildProcess, signal: NodeJS.Signals): void { + if (!child.pid) return; + try { + if (process.platform === 'win32') { + execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000 }); + } else { + // The group was created by this invocation; never target a stored PID. + process.kill(-child.pid, signal); + } + } catch (error) { + if (process.platform === 'win32' && child.exitCode == null && child.signalCode == null) child.kill('SIGKILL'); + if ((error as NodeJS.ErrnoException).code !== 'ESRCH' && process.platform !== 'win32') throw error; + } +} + +export async function runPlugin(command: string, args: string[], json: boolean): Promise { + const lease = acquirePlugin(command); + let context: ReturnType | undefined; + const guard = bindChildrenToProcessLifetime(); + const listeners: Array<[LifetimeSignal, () => void]> = []; + let child: ChildProcess | undefined; + let termination: NodeJS.Timeout | undefined; + let requestedSignal: LifetimeSignal | undefined; + const onExit = () => { + if (child) signalChild(child, 'SIGKILL'); + }; + try { + // Help is delegated without reading settings, exporting scripts or requiring a chain. + const helpOnly = args.length === 1 && (args[0] === '--help' || args[0] === '-h'); + if (!helpOnly) context = createPluginContext(lease.plugin, json); + const env = { ...process.env }; + delete env.OFFCKB_PLUGIN_CONTEXT; + if (context) env.OFFCKB_PLUGIN_CONTEXT = context.file; + child = spawn(process.execPath, [lease.plugin.entry, ...args], { + stdio: 'inherit', + cwd: process.cwd(), + env, + shell: false, + detached: process.platform !== 'win32', + }); + guard.track(child); + process.on('exit', onExit); + for (const signal of LIFETIME_SIGNALS) { + const listener = () => { + enterGracefulShutdown(); + const repeated = requestedSignal !== undefined; + requestedSignal ??= signal; + signalChild(child!, repeated ? 'SIGKILL' : signal); + if (!termination) { + termination = setTimeout(() => signalChild(child!, 'SIGKILL'), 3000); + termination.unref(); + } + }; + listeners.push([signal, listener]); + process.on(signal, listener); + } + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child!.once('error', reject); + child!.once('close', (code, signal) => resolve({ code, signal })); + }); + process.exitCode = requestedSignal + ? signalExitCode(requestedSignal) + : (result.code ?? + (result.signal && LIFETIME_SIGNALS.includes(result.signal as LifetimeSignal) + ? signalExitCode(result.signal as LifetimeSignal) + : 1)); + } finally { + if (termination) clearTimeout(termination); + for (const [signal, listener] of listeners) process.removeListener(signal, listener); + process.removeListener('exit', onExit); + if (child) signalChild(child, 'SIGKILL'); + guard.stopAll(); + guard.dispose(); + try { + context?.dispose(); + } finally { + lease.release(); + } + } +} diff --git a/src/plugins/store.ts b/src/plugins/store.ts new file mode 100644 index 0000000..4aff0ca --- /dev/null +++ b/src/plugins/store.ts @@ -0,0 +1,164 @@ +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import { dataPath } from '../cfg/setting'; +import { assertPluginCompatible, isPluginCommand, PluginManifest, readJsonFile, readPluginManifest } from './manifest'; + +interface Registry { + apiVersion: 1; + plugins: Record; +} + +export interface PluginInfo { + command: string; + directory: string; + state: 'available' | 'invalid' | 'incompatible' | 'shadowed'; + name?: string; + version?: string; + description?: string; + error?: string; +} + +export function pluginHome(): string { + return path.resolve(process.env.OFFCKB_PLUGIN_HOME || path.join(dataPath, 'plugins')); +} + +function registryPath(): string { + return path.join(pluginHome(), 'registry.json'); +} + +function readRegistry(): Registry { + let value: unknown; + try { + value = readJsonFile(registryPath()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { apiVersion: 1, plugins: {} }; + throw new Error(`Cannot read plugin registry ${registryPath()}: ${(error as Error).message}`); + } + const registry = value as Registry; + if ( + registry?.apiVersion !== 1 || + !registry.plugins || + typeof registry.plugins !== 'object' || + Array.isArray(registry.plugins) || + Object.entries(registry.plugins).some( + ([command, directory]) => + !isPluginCommand(command) || typeof directory !== 'string' || !path.isAbsolute(directory), + ) + ) + throw new Error(`Invalid plugin registry: ${registryPath()}`); + return registry; +} + +function withRegistryLock(action: () => T): T { + fs.mkdirSync(pluginHome(), { recursive: true, mode: 0o700 }); + const lock = path.join(pluginHome(), 'registry.lock'); + let fd: number; + try { + fd = fs.openSync(lock, 'wx', 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + throw new Error( + `Plugin registry is locked: ${lock}. If a writer crashed, remove this file after confirming it stopped.`, + ); + } + try { + fs.writeFileSync(fd, String(process.pid)); + return action(); + } finally { + fs.closeSync(fd); + fs.unlinkSync(lock); + } +} + +function writeRegistry(registry: Registry): void { + const temporary = `${registryPath()}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(temporary, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); + fs.renameSync(temporary, registryPath()); + } finally { + fs.rmSync(temporary, { force: true }); + } +} + +function leasePath(command: string): string { + if (!isPluginCommand(command)) throw new Error('Invalid plugin command'); + return path.join(pluginHome(), `${command}.running`); +} + +function assertNotRunning(command: string): void { + const file = leasePath(command); + if (fs.existsSync(file)) { + throw new Error( + `${command} is running or has a stale run lock at ${file}. Stop it before unlinking/relinking; after a crash, confirm it stopped before removing the lock.`, + ); + } +} + +export function listPlugins(reserved: ReadonlySet): PluginInfo[] { + return Object.entries(readRegistry().plugins).map(([command, directory]) => { + const base = { command, directory }; + if (reserved.has(command)) return { ...base, state: 'shadowed', error: 'A core command has priority' }; + let plugin: PluginManifest; + try { + plugin = readPluginManifest(directory); + if (plugin.command !== command) throw new Error('Plugin command changed; unlink and link the package again'); + } catch (error) { + return { ...base, state: 'invalid', error: (error as Error).message }; + } + const info = { ...base, name: plugin.name, version: plugin.version, description: plugin.description }; + try { + assertPluginCompatible(plugin); + return { ...info, state: 'available' }; + } catch (error) { + return { ...info, state: 'incompatible', error: (error as Error).message }; + } + }); +} + +export function linkPlugin(directory: string, reserved: ReadonlySet): PluginManifest { + return withRegistryLock(() => { + const plugin = readPluginManifest(directory); + assertPluginCompatible(plugin); + if (reserved.has(plugin.command)) throw new Error(`Plugin command ${plugin.command} conflicts with a core command`); + const registry = readRegistry(); + const existing = Object.prototype.hasOwnProperty.call(registry.plugins, plugin.command) + ? registry.plugins[plugin.command] + : undefined; + if (existing && existing !== plugin.directory) + throw new Error(`Plugin command ${plugin.command} is already linked`); + assertNotRunning(plugin.command); + registry.plugins[plugin.command] = plugin.directory; + writeRegistry(registry); + return plugin; + }); +} + +export function unlinkPlugin(command: string): void { + withRegistryLock(() => { + const registry = readRegistry(); + assertNotRunning(command); + if (!Object.prototype.hasOwnProperty.call(registry.plugins, command)) + throw new Error(`Plugin ${command} is not linked`); + delete registry.plugins[command]; + writeRegistry(registry); + }); +} + +/** A foreground lease prevents unlink/relink; it never authorizes signaling a saved PID. */ +export function acquirePlugin(command: string): { plugin: PluginManifest; release: () => void } { + return withRegistryLock(() => { + const registry = readRegistry(); + const directory = Object.prototype.hasOwnProperty.call(registry.plugins, command) + ? registry.plugins[command] + : undefined; + if (!directory) throw new Error(`Plugin ${command} is no longer linked`); + const plugin = readPluginManifest(directory); + if (plugin.command !== command) throw new Error('Plugin command changed; unlink and link the package again'); + assertPluginCompatible(plugin); + assertNotRunning(command); + const file = leasePath(command); + fs.writeFileSync(file, JSON.stringify({ pid: process.pid }), { mode: 0o600, flag: 'wx' }); + return { plugin, release: () => fs.rmSync(file, { force: true }) }; + }); +} diff --git a/src/scripts/private.ts b/src/scripts/private.ts index dcfcc6c..ea86fc0 100644 --- a/src/scripts/private.ts +++ b/src/scripts/private.ts @@ -80,6 +80,10 @@ export function resolveDevnetSystemScripts(): DevnetSystemScripts | null { return null; } + return resolveDevnetSystemScriptsFromListHashes(listHashesString); +} + +export function resolveDevnetSystemScriptsFromListHashes(listHashesString: string): DevnetSystemScripts | null { const listHashes = toml.parse(listHashesString) as unknown as ListHashes; const chainSpecHashes: SpecHashes | null = Object.values(listHashes)[0]; if (chainSpecHashes == null) { diff --git a/tests/cli-errors.test.ts b/tests/cli-errors.test.ts index d457a1a..acbae0b 100644 --- a/tests/cli-errors.test.ts +++ b/tests/cli-errors.test.ts @@ -1,7 +1,16 @@ const mockBalanceOf = jest.fn(); const mockLogsCommand = jest.fn(); +const mockListPlugins = jest.fn(); +const mockRunPlugin = jest.fn(); const jsonMode = { value: false }; +jest.mock('../src/plugins/store', () => ({ + listPlugins: (...args: unknown[]) => mockListPlugins(...args), + linkPlugin: jest.fn(), + unlinkPlugin: jest.fn(), +})); +jest.mock('../src/plugins/run', () => ({ runPlugin: (...args: unknown[]) => mockRunPlugin(...args) })); + jest.mock('../src/cmd/node', () => ({ startNode: jest.fn(), stopNode: jest.fn() })); jest.mock('../src/cmd/accounts', () => ({ accounts: jest.fn() })); jest.mock('../src/cmd/clean', () => ({ clean: jest.fn() })); @@ -75,6 +84,8 @@ function captureStderr() { describe('CLI error output', () => { beforeEach(() => { jest.clearAllMocks(); + mockListPlugins.mockReturnValue([]); + mockRunPlugin.mockResolvedValue(undefined); jsonMode.value = false; process.exitCode = undefined; }); @@ -145,4 +156,22 @@ describe('CLI error output', () => { expect(mockBalanceOf).toHaveBeenCalledWith('ckt1qaddress', expect.anything()); }); + + it('does not append a core JSON result after delegating to a plugin', async () => { + mockListPlugins.mockReturnValue([{ command: 'probe', state: 'available', directory: '/unused' }]); + const { runCli } = loadCli(); + await runCli(['node', 'offckb', '--json', 'probe', '--', '--json', '--json', '--path', 'a b']); + expect(mockRunPlugin).toHaveBeenCalledWith('probe', ['--json', '--json', '--path', 'a b'], true); + const { logger } = require('../src/util/logger'); + expect(logger.result).not.toHaveBeenCalled(); + expect(mockFailure).not.toHaveBeenCalled(); + }); + + it('does not promote --json from the plugin argument suffix', async () => { + mockListPlugins.mockReturnValue([{ command: 'probe', state: 'available', directory: '/unused' }]); + const { runCli } = loadCli(); + await runCli(['node', 'offckb', 'probe', '--json']); + expect(mockRunPlugin).toHaveBeenCalledWith('probe', ['--json'], false); + expect(jsonMode.value).toBe(false); + }); }); diff --git a/tests/plugin-run.test.ts b/tests/plugin-run.test.ts new file mode 100644 index 0000000..4adc2ca --- /dev/null +++ b/tests/plugin-run.test.ts @@ -0,0 +1,139 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { runPlugin } from '../src/plugins/run'; +import { linkPlugin, unlinkPlugin } from '../src/plugins/store'; + +const mockCreateContext = jest.fn(); +jest.mock('../src/plugins/context', () => ({ + createPluginContext: (...args: unknown[]) => mockCreateContext(...args), +})); +jest.mock('../src/util/shutdown', () => ({ enterGracefulShutdown: jest.fn() })); + +let root: string; +let originalHome: string | undefined; +let originalOutput: string | undefined; +let output: string; +let contextFile: string; +let dispose: jest.Mock; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb runner ')); + originalHome = process.env.OFFCKB_PLUGIN_HOME; + originalOutput = process.env.OFFCKB_TEST_OUTPUT; + output = path.join(root, 'result.json'); + process.env.OFFCKB_PLUGIN_HOME = path.join(root, 'home'); + process.env.OFFCKB_TEST_OUTPUT = output; + contextFile = path.join(root, 'context.json'); + dispose = jest.fn(() => fs.rmSync(contextFile, { force: true })); + mockCreateContext.mockReset().mockImplementation(() => { + fs.writeFileSync(contextFile, JSON.stringify({ apiVersion: 1 })); + return { file: contextFile, dispose }; + }); + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'runner-test', + version: '1.0.0', + offckb: { apiVersion: 1, command: 'runner', entry: 'entry.cjs' }, + }), + ); + fs.writeFileSync( + path.join(root, 'entry.cjs'), + ` + const fs = require('fs'); + const args = process.argv.slice(2); + let descendant; + if (args.includes('descendant')) descendant = require('child_process').spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => {}); require("fs").writeFileSync(process.env.OFFCKB_TEST_OUTPUT + ".desc", "ready"); setInterval(() => {}, 1000)'], {stdio: 'ignore'}); + fs.writeFileSync(process.env.OFFCKB_TEST_OUTPUT, JSON.stringify({ args, context: process.env.OFFCKB_PLUGIN_CONTEXT || null, cwd: process.cwd(), descendant: descendant?.pid })); + if (args.includes('wait')) { + setInterval(() => {}, 1000); + process.on('SIGTERM', () => process.exit(0)); + } else if (args.includes('fail')) process.exit(7); + `, + ); + linkPlugin(root, new Set()); +}); +afterEach(() => { + if (originalHome === undefined) delete process.env.OFFCKB_PLUGIN_HOME; + else process.env.OFFCKB_PLUGIN_HOME = originalHome; + if (originalOutput === undefined) delete process.env.OFFCKB_TEST_OUTPUT; + else process.env.OFFCKB_TEST_OUTPUT = originalOutput; + process.exitCode = undefined; + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); +}); + +it('runs a real Node child with cwd, exact argv and context, then removes its lease and temporary files', async () => { + await runPlugin('runner', ['a b', '--json', '--', '$literal'], true); + expect(JSON.parse(fs.readFileSync(output, 'utf8'))).toMatchObject({ + args: ['a b', '--json', '--', '$literal'], + cwd: process.cwd(), + context: contextFile, + }); + expect(process.exitCode).toBe(0); + expect(dispose).toHaveBeenCalledTimes(1); + expect(fs.existsSync(contextFile)).toBe(false); + expect(() => unlinkPlugin('runner')).not.toThrow(); +}); + +it('delegates help without exporting context and removes an inherited context variable', async () => { + const previous = process.env.OFFCKB_PLUGIN_CONTEXT; + process.env.OFFCKB_PLUGIN_CONTEXT = '/stale/context.json'; + try { + await runPlugin('runner', ['--help'], false); + } finally { + if (previous === undefined) delete process.env.OFFCKB_PLUGIN_CONTEXT; + else process.env.OFFCKB_PLUGIN_CONTEXT = previous; + } + expect(JSON.parse(fs.readFileSync(output, 'utf8')).context).toBeNull(); + expect(mockCreateContext).not.toHaveBeenCalled(); +}); + +it('preserves nonzero exit codes and cleans up after context preparation fails', async () => { + await runPlugin('runner', ['fail'], false); + expect(process.exitCode).toBe(7); + mockCreateContext.mockImplementationOnce(() => { + throw new Error('bad settings'); + }); + await expect(runPlugin('runner', [], false)).rejects.toThrow('bad settings'); + expect(() => unlinkPlugin('runner')).not.toThrow(); +}); + +it('forwards a captured shutdown signal to its owned process group and waits for cleanup', async () => { + if (process.platform === 'win32') return; // Signal and taskkill behavior needs a Windows host. + const listeners = new Map void>(); + const originalOn = process.on.bind(process); + jest.spyOn(process, 'on').mockImplementation(((event: string, listener: () => void) => { + if (['SIGINT', 'SIGTERM', 'SIGHUP'].includes(event)) { + listeners.set(event, listener); + return process; + } + return originalOn(event, listener); + }) as typeof process.on); + const running = runPlugin('runner', ['wait', 'descendant'], false); + const start = Date.now(); + while ((!fs.existsSync(output) || !fs.existsSync(output + '.desc')) && Date.now() - start < 3000) + await new Promise((resolve) => setTimeout(resolve, 10)); + try { + expect(fs.existsSync(output)).toBe(true); + expect(() => unlinkPlugin('runner')).toThrow('running'); + } finally { + listeners.get('SIGTERM')!(); + await running; + } + expect(process.exitCode).toBe(143); + expect(dispose).toHaveBeenCalledTimes(1); + const descendant = JSON.parse(fs.readFileSync(output, 'utf8')).descendant; + const deadline = Date.now() + 1000; + let alive = true; + while (alive && Date.now() < deadline) { + try { + process.kill(descendant, 0); + await new Promise((resolve) => setTimeout(resolve, 10)); + } catch { + alive = false; + } + } + expect(alive).toBe(false); + expect(() => unlinkPlugin('runner')).not.toThrow(); +}); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts new file mode 100644 index 0000000..b28b954 --- /dev/null +++ b/tests/plugins.test.ts @@ -0,0 +1,204 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { Command } from 'commander'; +import { readPluginManifest, assertPluginCompatible } from '../src/plugins/manifest'; +import { acquirePlugin, linkPlugin, listPlugins, unlinkPlugin } from '../src/plugins/store'; +import { createPluginContext } from '../src/plugins/context'; +import { registerPluginManagement } from '../src/plugins/cli'; + +const mockRunPlugin = jest.fn().mockResolvedValue(undefined); +const mockResolveScripts = jest.fn(); +const mockSettings = { + devnet: { configPath: '', rpcUrl: 'http://127.0.0.1:18114', rpcProxyPort: 38114 }, + bins: { defaultCKBVersion: 'test' }, +}; +let mockBinary = ''; +jest.mock('../src/cfg/setting', () => ({ + dataPath: '/unused', + readSettings: () => mockSettings, + getCKBBinaryPath: () => mockBinary, +})); +jest.mock('../src/scripts/private', () => ({ resolveDevnetSystemScriptsFromListHashes: () => mockResolveScripts() })); +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + execFileSync: jest.fn(() => 'test hashes'), +})); +jest.mock('../src/util/daemon', () => ({ resolveCliEntry: () => '/current/offckb.js' })); +jest.mock('../src/plugins/run', () => ({ runPlugin: (...args: unknown[]) => mockRunPlugin(...args) })); + +let directory: string; +let previousHome: string | undefined; +function makePlugin(command = 'probe', overrides: Record = {}) { + const root = path.join(directory, `${command} package ${Math.random()}`); + fs.mkdirSync(root); + fs.writeFileSync(path.join(root, 'entry.mjs'), 'throw new Error("manifest discovery must not execute this");'); + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ + name: `test-${command}`, + version: '1.0.0', + ...overrides, + offckb: { apiVersion: 1, command, entry: 'entry.mjs', ...((overrides.offckb || {}) as object) }, + }), + ); + return root; +} + +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb plugin tests ')); + previousHome = process.env.OFFCKB_PLUGIN_HOME; + process.env.OFFCKB_PLUGIN_HOME = path.join(directory, 'home'); + mockSettings.devnet.configPath = path.join(directory, 'chain'); + mockBinary = path.join(directory, 'ckb'); + mockResolveScripts.mockReset(); + mockRunPlugin.mockClear(); +}); +afterEach(() => { + if (previousHome === undefined) delete process.env.OFFCKB_PLUGIN_HOME; + else process.env.OFFCKB_PLUGIN_HOME = previousHome; + fs.rmSync(directory, { recursive: true, force: true }); +}); + +it('reads only static metadata and checks Node requirements before execution', () => { + const root = makePlugin('probe', { engines: { node: '>=999' } }); + const plugin = readPluginManifest(root); + expect(plugin.directory).toBe(fs.realpathSync(root)); + expect(() => assertPluginCompatible(plugin)).toThrow('requires Node'); +}); + +it.each([{ apiVersion: 2 }, { command: '../escape' }, { entry: '../outside.mjs' }, { entry: '.' }])( + 'rejects invalid or escaping declarations: %j', + (offckb) => { + fs.writeFileSync(path.join(directory, 'outside.mjs'), ''); + expect(() => readPluginManifest(makePlugin('probe', { offckb }))).toThrow(); + }, +); + +it('rejects an entry symlink that resolves outside the package', () => { + if (process.platform === 'win32') return; // Creating symlinks may require elevation. + const root = makePlugin(); + fs.writeFileSync(path.join(directory, 'outside.mjs'), ''); + fs.unlinkSync(path.join(root, 'entry.mjs')); + fs.symlinkSync(path.join(directory, 'outside.mjs'), path.join(root, 'entry.mjs')); + expect(() => readPluginManifest(root)).toThrow('inside the plugin'); +}); + +it('links explicit paths, prevents conflicts and reports future core shadowing', () => { + const root = makePlugin(); + expect(() => linkPlugin(root, new Set(['probe']))).toThrow('core command'); + linkPlugin(root, new Set()); + expect(listPlugins(new Set())[0].state).toBe('available'); + expect(() => linkPlugin(makePlugin(), new Set())).toThrow('already linked'); + expect(listPlugins(new Set(['probe']))[0].state).toBe('shadowed'); + unlinkPlugin('probe'); + expect(fs.existsSync(root)).toBe(true); + expect(listPlugins(new Set())).toEqual([]); +}); + +it('allows removal of a broken package and blocks unlink/relink during a foreground run', () => { + const root = makePlugin(); + linkPlugin(root, new Set()); + const lease = acquirePlugin('probe'); + expect(() => unlinkPlugin('probe')).toThrow('running'); + expect(() => linkPlugin(root, new Set())).toThrow('running'); + expect(() => acquirePlugin('probe')).toThrow('running'); + lease.release(); + fs.rmSync(root, { recursive: true }); + expect(listPlugins(new Set())[0].state).toBe('invalid'); + unlinkPlugin('probe'); +}); + +it('preserves a corrupt registry instead of overwriting it', () => { + fs.mkdirSync(process.env.OFFCKB_PLUGIN_HOME!); + const file = path.join(process.env.OFFCKB_PLUGIN_HOME!, 'registry.json'); + fs.writeFileSync(file, '{broken'); + expect(() => linkPlugin(makePlugin(), new Set())).toThrow('Cannot read plugin registry'); + expect(fs.readFileSync(file, 'utf8')).toBe('{broken'); +}); + +it('exports current chain metadata without keys and removes per-run files', () => { + fs.mkdirSync(mockSettings.devnet.configPath); + fs.writeFileSync(path.join(mockSettings.devnet.configPath, 'ckb.toml'), ''); + fs.writeFileSync(mockBinary, ''); + mockResolveScripts.mockReturnValue({ + scripts: { example: { script: { codeHash: '0x1234' } } }, + forkedFrom: 'mainnet', + genesisHash: '0xabc', + }); + const run = createPluginContext(readPluginManifest(makePlugin()), true); + const value = JSON.parse(fs.readFileSync(run.file, 'utf8')); + expect(value).toMatchObject({ + apiVersion: 1, + output: 'json', + cwd: process.cwd(), + cli: { entry: '/current/offckb.js' }, + devnet: { + kind: 'fork-of-mainnet', + addressPrefix: 'ckb', + genesisHash: '0xabc', + rpcUrl: mockSettings.devnet.rpcUrl, + }, + }); + expect(JSON.parse(fs.readFileSync(value.devnet.systemScriptsFile, 'utf8')).devnet.example).toBeDefined(); + expect(value).not.toHaveProperty('settings'); + expect(value).not.toHaveProperty('accounts'); + run.dispose(); + expect(fs.existsSync(run.file)).toBe(false); + expect(fs.existsSync(value.devnet.systemScriptsFile)).toBe(false); +}); + +it('represents an uninitialized chain as unknown and does not run CKB', () => { + const run = createPluginContext(readPluginManifest(makePlugin()), false); + expect(JSON.parse(fs.readFileSync(run.file, 'utf8')).devnet).toMatchObject({ + kind: 'unknown', + addressPrefix: null, + systemScriptsFile: null, + }); + expect(mockResolveScripts).not.toHaveBeenCalled(); + run.dispose(); +}); + +it('lets Commander parse only the core prefix and keeps plugin arguments opaque', async () => { + linkPlugin(makePlugin(), new Set()); + const program = new Command().enablePositionalOptions().option('--json'); + const plugins = registerPluginManagement(program); + const invocation = plugins.prepare([ + 'node', + 'offckb', + '--json', + 'probe', + '--', + '--json', + '--json', + '--project', + 'a b', + '--', + 'tail', + ]); + await program.parseAsync(invocation.argv); + expect(invocation.delegated()).toBe(true); + expect(mockRunPlugin).toHaveBeenCalledWith('probe', ['--json', '--json', '--project', 'a b', '--', 'tail'], true); +}); + +it('does not interpret a plugin --json as the core output format and delegates help', async () => { + linkPlugin(makePlugin(), new Set()); + const program = new Command().enablePositionalOptions().option('--json'); + const plugins = registerPluginManagement(program); + for (const args of [['--json'], ['--help']]) { + const invocation = plugins.prepare(['node', 'offckb', 'probe', ...args]); + await program.parseAsync(invocation.argv); + expect(mockRunPlugin).toHaveBeenLastCalledWith('probe', args, false); + } +}); + +it('keeps core commands usable with a corrupt registry', async () => { + fs.mkdirSync(process.env.OFFCKB_PLUGIN_HOME!); + fs.writeFileSync(path.join(process.env.OFFCKB_PLUGIN_HOME!, 'registry.json'), 'not json'); + const program = new Command(); + const action = jest.fn(); + program.command('node').action(action); + const plugins = registerPluginManagement(program); + await program.parseAsync(plugins.prepare(['node', 'offckb', 'node']).argv); + expect(action).toHaveBeenCalled(); +}); From b3cc7124feb3fd70dd7f0580e8d55b7fe8997fb8 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 29 Oct 2026 11:25:24 +0800 Subject: [PATCH 02/10] fix: tolerate unavailable plugin chain metadata --- docs/plugin-system-proposal.md | 24 ++++++++++++----- docs/plugins.md | 13 +++++---- examples/plugins/context-probe/cli.cjs | 1 + examples/plugins/explorer/cli.mjs | 1 + scripts/plugin-smoke.mjs | 13 +++++++++ src/plugins/context.ts | 31 ++++++++++++++------- tests/cli-errors.test.ts | 30 +++++++++++++++++++++ tests/plugins.test.ts | 37 +++++++++++++++++++++++++- 8 files changed, 129 insertions(+), 21 deletions(-) diff --git a/docs/plugin-system-proposal.md b/docs/plugin-system-proposal.md index ed4dd9a..5f8a863 100644 --- a/docs/plugin-system-proposal.md +++ b/docs/plugin-system-proposal.md @@ -109,6 +109,7 @@ offckb plugin remove explorer - `apiVersion`、offckb 版本、当前工作目录、插件专属数据及缓存目录。 - 当前 devnet 的 RPC URL、proxy URL、链类型、genesis hash、实际地址前缀。 +- 本地 `list-hashes` 失败时,链身份和脚本降级为未知,并附可选的 `devnet.diagnostic`(固定错误码及脱敏说明);不需要链的工具仍可运行,需要链的工具自行拒绝未知状态。 - 本次生成的系统脚本 JSON 文件路径;沿用现有导出格式,明确记录格式版本。 - 当前 CLI 的 Node 和入口文件绝对路径,作为必要时调用核心命令的备用方式。 - 输出格式;结构化输出模式必须由适配器遵守,不能直接混入上游的人类日志。 @@ -141,7 +142,7 @@ offckb plugin remove explorer ## Explorer:适合接入,但需要明确适配 -固定调研版本:[`477af4400a716353d140fc1a5b8fee8175f74fed`](https://github.com/wildanrhmn/ckb-devnet-explorer/tree/477af4400a716353d140fc1a5b8fee8175f74fed),包内版本为 `0.2.0`。以下是源码检查结果,尚未运行端到端验证。 +固定调研版本:[`477af4400a716353d140fc1a5b8fee8175f74fed`](https://github.com/wildanrhmn/ckb-devnet-explorer/tree/477af4400a716353d140fc1a5b8fee8175f74fed),包内版本为 `0.2.0`。以下保留初始源码检查结果;当前本地实验的端到端验证及局限见[插件指南](plugins.md)。 | 上游现状 | 接入处理 | | --- | --- | @@ -156,12 +157,21 @@ offckb plugin remove explorer 依据:[README](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/README.md)、[package.json](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/package.json)、[CLI](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/cli.ts)、[scripts.ts](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/scripts.ts)、[server.ts](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/server.ts)、[explorer.ts](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/explorer.ts)。Node 未指定 host 时的行为见 [Node net 文档](https://nodejs.org/api/net.html#serverlistenport-host-backlog-callback)。 -推荐优先向上游贡献两个小入口:系统脚本文件输入、显式 host;上游仍可保留原 CLI 的默认行为。offckb 的独立适配包负责协议转换和就绪检查。若上游不接受,则需要维护明确版本的适配分支,不能承诺现有仓库不改代码就完整符合协议,也不宜长期依赖其私有 TS 源文件路径。 +推荐优先向上游贡献两个小入口:系统脚本文件输入、显式 host;上游仍可保留原 CLI 的默认行为。offckb 的独立适配包负责协议转换和就绪检查。不能承诺现有仓库不改代码就完整符合协议;临时适配分支只用于实验,不能以明确了维护者或固定了 commit 为由继续依赖私有 TS 路径正式分发。 -建议职责分配为:offckb 侧维护协议及适配包,Explorer 上游维护业务及 UI。具体维护者尚未确定,也没有获得上游承诺。Phase 1 的命令协议原型先使用最小假插件,不等待上游;本地 Explorer 实验可使用明确标注的临时适配分支。Phase 1 结束时检查上游入口是否可用:若不可用,只有在明确适配分支维护者及维护成本后才推进正式分发,否则保留实验结果、暂缓 Explorer 插件发布。 +建议职责分配为:offckb 侧维护协议及适配包,Explorer 上游维护业务及 UI。具体维护者尚未确定,也没有获得上游承诺。Phase 1 的命令协议原型先使用最小假插件,不等待上游;本地 Explorer 实验可使用明确标注的临时适配分支。正式分发还必须满足下节门槛,否则保留实验结果、暂缓插件发布。 此外,上游缓存只在 tip 回退时清除,脚本名也在启动时加载。清链或换链后统一重启 Explorer,避免把“tip 没回退”误认为链未变化。支持普通 devnet 的首版也应验证这一场景。 +### Explorer 正式分发门禁 + +这是将 Explorer 实验升级为正式插件的相关 PR 的合并前置条件,不阻断仓库内 Phase 1 协议实验。正式分发必须先确定维护者,并完成以下二选一: + +1. 上游合并并发布显式 host、系统脚本文件输入及受 semver 约束的 `startServer` 等公开入口;适配包通过正常包解析使用这些入口。 +2. 适配包只调用上游公开 CLI,固定发布版本,并以契约测试覆盖参数、loopback 监听、脚本输入、输出及退出清理。若公开 CLI 尚不能满足这些行为,仍不能毕业。 + +毕业时删除源码 magic-string replace、私有 `src/*.ts` import、对 tsx export map 内部形状的解析,以及 `OFFCKB_EXPLORER_SOURCE` 实验路径覆盖。不将 `.prototype`、准备脚本或这些私有接入方式包装为正式发布方案。固定 commit 和通过本地冒烟测试不能代替公开兼容契约;保留上述依赖的正式分发 PR 不应合并。 + ## 分阶段落地 1. **先验证协议,不做安装器。** 先用最小假插件验证 Commander 动态注册、JSON 结果所有权、argv 透传及前台退出,覆盖生产 ncc 包、macOS 和 Windows。再用本地 Explorer 适配验证上下文文件往返、显式 loopback 监听、普通 devnet、fork 拒绝、清链重启和临时文件清理。这两组实验通过才算本阶段完成。 @@ -175,20 +185,22 @@ offckb plugin remove explorer Sentinel 使用 `kimi-coding/kimi-for-coding` 做了只读评审,认为选型及分期合理,提出 M1–M4 等问题。评审核对了本地源码,未独立复核 dsh 和 Explorer 外部仓库;这些意见是协作材料,不构成人类验收或接受的 Context。 -- **M1,输出所有权:** 已补适配器拥有结果、父进程抑制兜底及标志边界;仍需命令原型证明。 +- **M1,输出所有权:** 已补适配器拥有结果、父进程抑制兜底及标志边界;命令回归测试及生产 ncc 包冒烟已验证这些边界。 - **M2,升级撞名:** 已补核心优先、插件 `shadowed` 及保留移除能力。 - **M3,Windows npm:** 采纳必须验证的风险,纠正“npm.cmd 绝对路径 + shell:false 即可”的修法;当前 npm JS 入口解析及跨平台实测仍未完成。 -- **M4,维护归属:** 已提出职责及 Phase 1 结束时的分发决策点;具体维护者未定,不能视为已关闭。 +- **M4,维护归属:** 已提出职责及正式分发门禁;具体维护者与公开兼容契约未定,不能视为已关闭。 - **S1/S2,复杂度:** 保留支撑“运行中不可移除”的最小互斥,通用运行状态机推迟;完整性复用 npm lockfile,不另建清单。 - **第二工具与验证次序:** Phase 2 前以 `ckb-tui` 走查;按命令协议、Explorer 上下文往返、安装器跨平台的顺序验证。 ## 实施时的验收点与未决项 -- 不安装插件时,核心启动不加载插件代码,也不额外访问 registry 或 RPC;新增一个合规插件不用修改核心命令源码。 +- 未登记插件时,核心启动不加载插件代码,也不为插件创建 registry 或发起 RPC;命令发现允许只读探测 `registry.json` 是否存在。新增一个合规插件不用修改核心命令源码。 - 在打包后的 CLI、带空格的路径、macOS/Linux/Windows 验证命令发现、argv、cwd、Node 版本检查、信号退出及清理。 - 损坏声明、重复命令、升级后核心撞名、路径越界、并发安装/移除、安装中断和运行中移除都能明确处理;旧版本仍可使用,核心帮助和移除命令仍可运行。 - 自定义 RPC 端口、多个项目、空 deployment、错误 JSON、端口占用、RPC 离线、indexer 缺失/落后、清链重启和 `--json` 输出均有覆盖。 - Node 20 仍能使用 offckb 核心;Explorer 明确拒绝不满足 engines 的运行环境。普通 devnet 通过后,再单独验证 mainnet/testnet fork,不能依据 HTTP 能打开就宣布支持。 - 未决:确认适配包维护者、上游入口及分发方式;验证当前 npm JS 入口的可靠发现、禁用安装脚本后的交付形态、最小运行互斥和 Windows 进程清理;以 `ckb-tui` 检验协议适用性。以上事项尚未因文档修订而获得验证。 +侧聊审查补充:本地元数据失败已改为降级,重复 `runCli()` 的命令刷新有回归测试。Phase 2 再评估失效租约恢复与自定义 binary 的 RPC 链身份探测:仅有持久 PID 的存活检查不能证明进程身份或子进程组已退出,不能直接据此删除互斥记录,更不能向该 PID 发信号。当前人工确认后清理的限制保留。现有 Windows CI 的普通子进程测试通过,但信号测试跳过了 Windows;真实 `taskkill /T /F`、父命令退出、后代清理及中断状态的专项验证仍是 Phase 1 未闭环项,不能用三平台 CI 全绿代替。本文不构成阶段签收。 + 以上保留整体设计与后续验收标准,不表示它们已经全部通过。第一阶段实现采用显式本地 link/list/unlink;registry 安装、后台服务与正式 Explorer 分发仍未交付。实现及验证方式见[插件指南](plugins.md)。 diff --git a/docs/plugins.md b/docs/plugins.md index 544f948..8135d1f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -19,7 +19,7 @@ The probe demonstrates exact argument forwarding and one JSON result. An uniniti `link` stores a canonical package path. It does not copy or install dependencies. The package author/user prepares its runtime first. `unlink` removes the registration and keeps the package, data and cache. A foreground lease prevents concurrent runs of the same plugin and unlink/relink while it is running. -Registrations default to `/plugins`. `OFFCKB_PLUGIN_HOME` selects an isolated registry/data/cache directory, useful for development. It does not change core settings or select another devnet. A broken registry does not block core commands or core help; `plugin list` reports the registry error without overwriting it. Invalid and incompatible packages remain removable. Future core command collisions are reported as `shadowed`, with the core command taking priority. +Registrations default to `/plugins`. `OFFCKB_PLUGIN_HOME` selects an isolated registry/data/cache directory, useful for development. It does not change core settings or select another devnet. Discovery probes `registry.json` even for core commands; an absent registry is not created. A broken registry does not block core commands or core help; `plugin list` reports the registry error without overwriting it. Invalid and incompatible packages remain removable. Future core command collisions are reported as `shadowed`, with the core command taking priority. ## Package declaration @@ -49,12 +49,13 @@ On execution, `OFFCKB_PLUGIN_CONTEXT` points to a temporary JSON file: - `cli.node` and `cli.entry`: the current Node/CLI paths, not a PATH lookup. - `plugin.command`, `plugin.dataPath`, `plugin.cachePath`: persistent, plugin-specific storage. - `devnet.rpcUrl`, `proxyUrl`, `kind`, `genesisHash`, `addressPrefix`, `systemScriptsFile`, `systemScriptsVersion: 1`. +- Optional `devnet.diagnostic: { code: "LIST_HASHES_FAILED", message: string }` when local metadata export fails. Consumers must tolerate additive fields in API version 1. The scripts file contains a `devnet` record using the existing `system-scripts` entry shape. It and the context are removed after the child closes, including failures and handled signals. Data/cache remain. No account keys or complete settings object are copied. RPC URLs can contain credentials; plugins must avoid dumping the context or raw URLs to logs. -Chain metadata comes from the configured managed CKB binary's local `list-hashes` output, with a three-second limit and no shell interpolation. A missing config/binary yields `kind: "unknown"`, null chain identity and no scripts file. A custom fork marker without a recognized public genesis also yields `unknown`. Plugins needing a known chain must reject these cases, and must verify the RPC genesis against the context before interpreting addresses/scripts. A custom `node --binary-path` with no matching managed binary is not yet supported by the context exporter. +Chain metadata comes from the configured managed CKB binary's local `list-hashes` output, with a three-second limit and no shell interpolation. A missing config/binary yields `kind: "unknown"`, null chain identity and no scripts file. Timeout, nonzero exit or invalid output yields the same unknown identity plus the diagnostic above; raw subprocess errors/stderr are excluded. Chain-independent plugins can continue. Invalid core settings or inability to write the context still fails the invocation. A custom fork marker without a recognized public genesis also yields `unknown`. Plugins needing a known chain must reject these cases, and must verify the RPC genesis against the context before interpreting addresses/scripts. A custom `node --binary-path` with no matching managed binary is not yet supported by the context exporter. -Standalone `offckb my-tool --help` / `-h` is delegated without exporting context or reading core settings. Tools should handle that request before reading `OFFCKB_PLUGIN_CONTEXT`. +Standalone `offckb my-tool --help` / `-h` is delegated without exporting context or reading core settings. Tools should handle that request before reading `OFFCKB_PLUGIN_CONTEXT`. With additional arguments, normal context preparation still runs; the host does not guess whether a help-looking token is an option value or literal plugin argument. Core `--json` must precede the plugin command: @@ -72,6 +73,8 @@ After delegation, the child owns stdout, including its single JSON success resul The example adapter uses `wildanrhmn/ckb-devnet-explorer` at commit `477af4400a716353d140fc1a5b8fee8175f74fed` (0.2.0), verified unchanged on 2026-10-29. It is **an unpublished, repository-only experiment**, using pinned private source imports. Formal upstream entry points and a release maintainer are still needed before distribution. +The [formal-distribution gate](plugin-system-proposal.md#explorer-正式分发门禁) requires either released upstream APIs covered by semver or exclusive use of its public CLI with a pinned version and contract tests. A formal plugin PR retaining source replacements, private imports, tsx export-map inspection or the **PROTOTYPE-ONLY** `OFFCKB_EXPLORER_SOURCE` override must not merge. A successful local experiment does not clear this gate. + Prepare a source checkout at that revision: ```sh @@ -102,10 +105,10 @@ pnpm build node scripts/plugin-smoke.mjs /path/to/ckb ``` -The opt-in smoke test uses the production ncc bundle, random local ports, temporary settings and a copied devnet. It checks exact argv, single JSON result, offline help, the real Explorer page/API, current RPC/project context, refusal to unlink a running plugin, signal cleanup, CKB remaining alive, chain reset/restart and rejection of an unknown fork marker. It redirects `os.homedir()` only inside its test workers before loading the bundle; it does not change HOME or user settings. Downloaded binaries, source and runtime dependencies are not shipped. +The opt-in smoke test uses the production ncc bundle, random local ports, temporary settings and a copied devnet. It checks exact argv, single JSON result, offline help, continued probe execution after a real `list-hashes` failure, the real Explorer page/API, current RPC/project context, refusal to unlink a running plugin, signal cleanup, CKB remaining alive, chain reset/restart and rejection of an unknown fork marker. It redirects `os.homedir()` only inside its test workers before loading the bundle; it does not change HOME or user settings. Downloaded binaries, source and runtime dependencies are not shipped. The second-tool paper review uses existing `src/cmd/status.ts`: ckb-tui would consume `proxyUrl`, retain TTY checks and forward native `-r`/optional `-t` arguments from a Node adapter. The current context lacks the optional TCP log address, so the minimal adapter could use RPC-only mode; adding that address or requiring full log panels needs a later explicit protocol decision. Native TTY/job-control behavior still requires testing. Existing `status` stays in core. -POSIX plugin processes use an invocation-owned group. SIGINT/SIGTERM/SIGHUP are forwarded; a three-second shutdown limit and final group cleanup prevent owned descendants from remaining after the tool exits. The host never signals a saved PID. Windows uses `taskkill /T /F`, so graceful cleanup differs and needs native Windows verification. Plugins must not detach their own unmanaged daemons. +POSIX plugin processes use an invocation-owned group. SIGINT/SIGTERM/SIGHUP are forwarded; a three-second shutdown limit and final group cleanup prevent owned descendants from remaining after the tool exits. The host never signals a saved PID. Windows immediately uses `taskkill /T /F` without a graceful period. The signal test skips Windows; passing its ordinary spawn tests does not verify this branch. Native Windows interrupt, process-tree cleanup and exit-status validation remain open for Phase 1. Plugins must not detach their own unmanaged daemons. SIGKILL, host crashes or power loss can leave `registry.lock`, `.running`, or temporary context directories. This stage fails closed on those locks: verify the owner/tool has stopped before removing the named stale file, then retry. Automatic crash recovery, full runtime identity management, concurrent instances and background services are deferred. Registry locks are short-lived and registration writes use atomic rename; tool source itself remains mutable because this is explicit local linking. diff --git a/examples/plugins/context-probe/cli.cjs b/examples/plugins/context-probe/cli.cjs index ed61525..4007058 100644 --- a/examples/plugins/context-probe/cli.cjs +++ b/examples/plugins/context-probe/cli.cjs @@ -12,6 +12,7 @@ if (process.argv.slice(2).includes('--help')) { cwd: context.cwd, output: context.output, kind: context.devnet.kind, + diagnosticCode: context.devnet.diagnostic?.code, cliEntry: context.cli.entry, }; console.log(JSON.stringify(result)); diff --git a/examples/plugins/explorer/cli.mjs b/examples/plugins/explorer/cli.mjs index 1871e1f..c1d2283 100644 --- a/examples/plugins/explorer/cli.mjs +++ b/examples/plugins/explorer/cli.mjs @@ -36,6 +36,7 @@ try { const port = Number(values.port); if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('--port must be an integer from 0 to 65535'); const projects = (values.project ?? [context.cwd]).map((directory) => path.resolve(context.cwd, directory)); + // PROTOTYPE-ONLY: remove this override together with private source imports before formal distribution. const source = path.resolve( process.env.OFFCKB_EXPLORER_SOURCE || fileURLToPath(new URL('../../../.prototype/explorer', import.meta.url)), ); diff --git a/scripts/plugin-smoke.mjs b/scripts/plugin-smoke.mjs index f81b022..c089aaf 100644 --- a/scripts/plugin-smoke.mjs +++ b/scripts/plugin-smoke.mjs @@ -156,6 +156,19 @@ try { assert.match(invoke(['context-probe', '--help']), /Usage/); console.log('PASS: production bundle, exact argv, single JSON result, offline help'); + const validConfig = fs.readFileSync(config, 'utf8'); + try { + fs.writeFileSync(config, 'invalid = ['); + const unavailable = JSON.parse(invoke(['--json', 'context-probe'])); + assert.equal(unavailable.kind, 'unknown'); + assert.equal(unavailable.diagnosticCode, 'LIST_HASHES_FAILED'); + assert.equal(fs.existsSync(path.join(home, 'context-probe.running')), false); + assert.deepEqual(fs.readdirSync(path.join(home, 'tmp')), []); + } finally { + fs.writeFileSync(config, validConfig); + } + console.log('PASS: failed local list-hashes does not block a chain-independent plugin, clean context'); + invoke(['--json', 'plugin', 'link', path.join(project, 'examples/plugins/explorer')]); for (let cycle = 0; cycle < 2; cycle++) { const node = start(['--json', 'node']); diff --git a/src/plugins/context.ts b/src/plugins/context.ts index e750fc2..fde1f99 100644 --- a/src/plugins/context.ts +++ b/src/plugins/context.ts @@ -22,6 +22,7 @@ export interface PluginContext { addressPrefix: 'ckb' | 'ckt' | null; systemScriptsFile: string | null; systemScriptsVersion: 1; + diagnostic?: { code: 'LIST_HASHES_FAILED'; message: string }; }; } @@ -41,17 +42,28 @@ export function createPluginContext(plugin: PluginManifest, json: boolean): { fi try { const settings = readSettings({ strict: true }); // No installation, node startup or RPC request. An uninitialized chain stays unknown. - const scripts = + let scripts: ReturnType = null; + let diagnostic: PluginContext['devnet']['diagnostic']; + if ( isFile(path.join(settings.devnet.configPath, 'ckb.toml')) && isFile(getCKBBinaryPath(settings.bins.defaultCKBVersion)) - ? resolveDevnetSystemScriptsFromListHashes( - execFileSync( - getCKBBinaryPath(settings.bins.defaultCKBVersion), - ['list-hashes', '-C', settings.devnet.configPath], - { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'] }, - ), - ) - : null; + ) { + try { + scripts = resolveDevnetSystemScriptsFromListHashes( + execFileSync( + getCKBBinaryPath(settings.bins.defaultCKBVersion), + ['list-hashes', '-C', settings.devnet.configPath], + { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'] }, + ), + ); + } catch { + // Chain-independent tools can still run. Do not expose raw stderr or settings in the snapshot. + diagnostic = { + code: 'LIST_HASHES_FAILED', + message: 'Local chain metadata is unavailable; check the devnet config and managed CKB binary.', + }; + } + } const systemScriptsFile = scripts ? path.join(directory, 'system-scripts.json') : null; if (systemScriptsFile) fs.writeFileSync(systemScriptsFile, JSON.stringify({ devnet: scripts!.scripts }), { mode: 0o600 }); @@ -80,6 +92,7 @@ export function createPluginContext(plugin: PluginManifest, json: boolean): { fi addressPrefix: !scripts ? null : scripts.forkedFrom === 'mainnet' ? 'ckb' : 'ckt', systemScriptsFile, systemScriptsVersion: 1, + ...(diagnostic ? { diagnostic } : {}), }, }; const file = path.join(directory, 'context.json'); diff --git a/tests/cli-errors.test.ts b/tests/cli-errors.test.ts index acbae0b..46a1037 100644 --- a/tests/cli-errors.test.ts +++ b/tests/cli-errors.test.ts @@ -174,4 +174,34 @@ describe('CLI error output', () => { expect(mockRunPlugin).toHaveBeenCalledWith('probe', ['--json'], false); expect(jsonMode.value).toBe(false); }); + + it('refreshes plugin commands across runCli calls without duplicate help entries or stale arguments', async () => { + mockListPlugins.mockReturnValue([ + { command: 'probe', state: 'available', directory: '/unused' }, + { command: 'other-tool', state: 'available', directory: '/unused' }, + ]); + const { runCli } = loadCli(); + await runCli(['node', 'offckb', 'probe', 'first']); + await runCli(['node', 'offckb', 'probe', 'second']); + expect(mockRunPlugin.mock.calls).toEqual([ + ['probe', ['first'], false], + ['probe', ['second'], false], + ]); + + let help = ''; + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + help += String(chunk); + return true; + }); + try { + await runCli(['node', 'offckb', '--help']); + } finally { + stdout.mockRestore(); + } + for (const command of ['probe', 'other-tool', 'plugin', 'balance']) { + expect(help.match(new RegExp(`^\\s+${command}(?:\\s|$)`, 'gm'))).toHaveLength(1); + } + expect(mockRunPlugin).toHaveBeenCalledTimes(2); + expect(mockFailure).not.toHaveBeenCalled(); + }); }); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index b28b954..bfdcd18 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -9,6 +9,7 @@ import { registerPluginManagement } from '../src/plugins/cli'; const mockRunPlugin = jest.fn().mockResolvedValue(undefined); const mockResolveScripts = jest.fn(); +const mockExecFileSync = jest.fn(); const mockSettings = { devnet: { configPath: '', rpcUrl: 'http://127.0.0.1:18114', rpcProxyPort: 38114 }, bins: { defaultCKBVersion: 'test' }, @@ -22,7 +23,7 @@ jest.mock('../src/cfg/setting', () => ({ jest.mock('../src/scripts/private', () => ({ resolveDevnetSystemScriptsFromListHashes: () => mockResolveScripts() })); jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), - execFileSync: jest.fn(() => 'test hashes'), + execFileSync: (...args: unknown[]) => mockExecFileSync(...args), })); jest.mock('../src/util/daemon', () => ({ resolveCliEntry: () => '/current/offckb.js' })); jest.mock('../src/plugins/run', () => ({ runPlugin: (...args: unknown[]) => mockRunPlugin(...args) })); @@ -52,6 +53,7 @@ beforeEach(() => { mockSettings.devnet.configPath = path.join(directory, 'chain'); mockBinary = path.join(directory, 'ckb'); mockResolveScripts.mockReset(); + mockExecFileSync.mockReset().mockReturnValue('test hashes'); mockRunPlugin.mockClear(); }); afterEach(() => { @@ -143,6 +145,7 @@ it('exports current chain metadata without keys and removes per-run files', () = expect(JSON.parse(fs.readFileSync(value.devnet.systemScriptsFile, 'utf8')).devnet.example).toBeDefined(); expect(value).not.toHaveProperty('settings'); expect(value).not.toHaveProperty('accounts'); + expect(value.devnet).not.toHaveProperty('diagnostic'); run.dispose(); expect(fs.existsSync(run.file)).toBe(false); expect(fs.existsSync(value.devnet.systemScriptsFile)).toBe(false); @@ -156,9 +159,41 @@ it('represents an uninitialized chain as unknown and does not run CKB', () => { systemScriptsFile: null, }); expect(mockResolveScripts).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); run.dispose(); }); +it.each(['timeout', 'nonzero exit', 'invalid output'])( + 'keeps the context usable with unknown chain identity after list-hashes %s', + (failure) => { + fs.mkdirSync(mockSettings.devnet.configPath); + fs.writeFileSync(path.join(mockSettings.devnet.configPath, 'ckb.toml'), ''); + fs.writeFileSync(mockBinary, ''); + const fail = () => { + throw Object.assign(new Error('private stderr: rpc://user:secret@host'), { + code: failure === 'timeout' ? 'ETIMEDOUT' : undefined, + status: failure === 'nonzero exit' ? 1 : null, + }); + }; + if (failure === 'invalid output') mockResolveScripts.mockImplementationOnce(fail); + else mockExecFileSync.mockImplementationOnce(fail); + const run = createPluginContext(readPluginManifest(makePlugin()), false); + const value = JSON.parse(fs.readFileSync(run.file, 'utf8')); + expect(value.devnet).toMatchObject({ + rpcUrl: mockSettings.devnet.rpcUrl, + kind: 'unknown', + genesisHash: null, + addressPrefix: null, + systemScriptsFile: null, + diagnostic: { code: 'LIST_HASHES_FAILED', message: expect.any(String) }, + }); + expect(JSON.stringify(value)).not.toContain('secret'); + expect(fs.readdirSync(path.dirname(run.file))).toEqual(['context.json']); + run.dispose(); + expect(fs.existsSync(run.file)).toBe(false); + }, +); + it('lets Commander parse only the core prefix and keeps plugin arguments opaque', async () => { linkPlugin(makePlugin(), new Set()); const program = new Command().enablePositionalOptions().option('--json'); From b7bbd84da38bd5dcb90ac8c7b1179d6984636db1 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 30 Oct 2026 09:43:35 +0800 Subject: [PATCH 03/10] fix: validate explorer sources and preserve plugin cleanup --- docs/plugins.md | 2 +- scripts/prepare-explorer-prototype.mjs | 21 ++++++++++++-- src/plugins/run.ts | 11 ++++++-- tests/plugin-run.test.ts | 39 ++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 8135d1f..0228d02 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -88,7 +88,7 @@ node build/index.js node --daemon node build/index.js explorer --project /path/to/deployed-project --port 7070 ``` -The preparation script copies source into ignored `.prototype/explorer`; it does not edit the checkout supplied by the user. The local copy adds an explicit server host and moves the upstream startup message to stderr. The adapter reads system scripts from the host snapshot, merges `deployment/scripts.json` from selected projects, and never invokes `offckb` from PATH. It loads tsx's declared ESM entry, matching upstream's ESM loader. +The preparation script requires a Git checkout at the pinned commit with no changes reported by `git status`, including untracked files. Source archives, different revisions and dirty checkouts are rejected before copying or writing a marker. It copies source into ignored `.prototype/explorer`; it does not edit the checkout supplied by the user. The local copy adds an explicit server host and moves the upstream startup message to stderr. The adapter reads system scripts from the host snapshot, merges `deployment/scripts.json` from selected projects, and never invokes `offckb` from PATH. It loads tsx's declared ESM entry, matching upstream's ESM loader. Node 22+ and an initialized ordinary devnet are required. Forks and unknown chain identities are rejected before listening. RPC genesis must match the supplied scripts context; missing/lagging built-in indexer is reported separately. The web server binds `127.0.0.1` and defaults to 7070; `--port 0` selects a free port. `--project` is repeatable and defaults to invocation cwd. There is no arbitrary `--rpc` override or automatic browser launch. diff --git a/scripts/prepare-explorer-prototype.mjs b/scripts/prepare-explorer-prototype.mjs index df0afca..4b0597a 100644 --- a/scripts/prepare-explorer-prototype.mjs +++ b/scripts/prepare-explorer-prototype.mjs @@ -1,16 +1,33 @@ // Copy a user-supplied checkout, keeping the original upstream tree untouched. +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +const pinnedCommit = '477af4400a716353d140fc1a5b8fee8175f74fed'; const source = process.argv[2]; if (!source) throw new Error('Usage: node scripts/prepare-explorer-prototype.mjs '); const target = fileURLToPath(new URL('../.prototype/explorer/', import.meta.url)); if (fs.existsSync(target)) throw new Error(`Destination already exists; review/remove it before preparing again: ${target}`); +const git = (args) => + execFileSync('git', ['-C', source, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10000, + }); +let head; +let status; +try { + head = git(['rev-parse', '--verify', 'HEAD^{commit}']).trim(); + status = git(['status', '--porcelain', '--untracked-files=all']); +} catch { + throw new Error(`Expected a clean Git checkout at commit ${pinnedCommit}`); +} +if (head !== pinnedCommit || status !== '') throw new Error(`Expected a clean Git checkout at commit ${pinnedCommit}`); const pkg = JSON.parse(fs.readFileSync(path.join(source, 'package.json'), 'utf8')); if (pkg.name !== 'ckb-devnet-explorer' || pkg.version !== '0.2.0') - throw new Error('Expected ckb-devnet-explorer 0.2.0 at commit 477af4400a716353d140fc1a5b8fee8175f74fed'); + throw new Error(`Expected ckb-devnet-explorer 0.2.0 at commit ${pinnedCommit}`); let server = fs.readFileSync(path.join(source, 'src/server.ts'), 'utf8'); const signature = 'projects: string[])'; const listen = 'server.listen(port, () => console.log('; @@ -33,7 +50,7 @@ fs.writeFileSync( fs.writeFileSync(path.join(target, 'src/server.ts'), server); fs.writeFileSync( path.join(target, '.offckb-prototype.json'), - JSON.stringify({ apiVersion: 1, upstreamCommit: '477af4400a716353d140fc1a5b8fee8175f74fed' }), + JSON.stringify({ apiVersion: 1, upstreamCommit: pinnedCommit }), ); console.log( `Prepared experimental source at ${target}. Install its runtime dependencies with scripts disabled; see docs/plugins.md.`, diff --git a/src/plugins/run.ts b/src/plugins/run.ts index 1a09758..5acd5c8 100644 --- a/src/plugins/run.ts +++ b/src/plugins/run.ts @@ -19,8 +19,12 @@ function signalChild(child: ChildProcess, signal: NodeJS.Signals): void { process.kill(-child.pid, signal); } } catch (error) { - if (process.platform === 'win32' && child.exitCode == null && child.signalCode == null) child.kill('SIGKILL'); - if ((error as NodeJS.ErrnoException).code !== 'ESRCH' && process.platform !== 'win32') throw error; + if ((error as NodeJS.ErrnoException).code === 'ESRCH' || child.exitCode != null || child.signalCode != null) return; + try { + child.kill(process.platform === 'win32' ? 'SIGKILL' : signal); + } catch { + // Best effort: a failed signal must not bypass context cleanup or lease release. + } } } @@ -66,7 +70,8 @@ export async function runPlugin(command: string, args: string[], json: boolean): process.on(signal, listener); } const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { - child!.once('error', reject); + // kill() can emit another error during the final cleanup attempt. + child!.on('error', reject); child!.once('close', (code, signal) => resolve({ code, signal })); }); process.exitCode = requestedSignal diff --git a/tests/plugin-run.test.ts b/tests/plugin-run.test.ts index 4adc2ca..3de58fd 100644 --- a/tests/plugin-run.test.ts +++ b/tests/plugin-run.test.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import childProcess from 'child_process'; import { runPlugin } from '../src/plugins/run'; import { linkPlugin, unlinkPlugin } from '../src/plugins/store'; @@ -99,6 +100,44 @@ it('preserves nonzero exit codes and cleans up after context preparation fails', expect(() => unlinkPlugin('runner')).not.toThrow(); }); +const posixIt = process.platform === 'win32' ? it.skip : it; +posixIt.each(['success', 'throw', 'error event'])( + 'keeps shutdown and cleanup usable after a group signal fails and the child fallback reports %s', + async (fallback) => { + const child = new childProcess.ChildProcess(); + Object.assign(child, { pid: 123456 }); // No real process is created or signaled. + jest.spyOn(childProcess, 'spawn').mockReturnValue(child); + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('group signaling denied'), { code: 'EPERM' }); + }); + const failure = Object.assign(new Error('child signaling denied'), { code: 'EPERM' }); + const kill = jest.spyOn(child, 'kill').mockImplementation(() => { + if (fallback === 'throw') throw failure; + if (fallback === 'error event') { + child.emit('error', failure); + return false; + } + return true; + }); + const before = process.listeners('SIGTERM'); + const running = runPlugin('runner', [], false); + const completion = fallback === 'error event' ? expect(running).rejects.toBe(failure) : running; + const handler = process.listeners('SIGTERM').find((listener) => !before.includes(listener))!; + expect(() => handler('SIGTERM')).not.toThrow(); + expect(dispose).not.toHaveBeenCalled(); // Cleanup still waits for termination or a child error. + if (fallback !== 'error event') { + child.exitCode = 0; + child.emit('close', 0, null); + } + await completion; + expect(kill).toHaveBeenCalledWith('SIGTERM'); + expect(dispose).toHaveBeenCalledTimes(1); + expect(fs.existsSync(contextFile)).toBe(false); + expect(process.listeners('SIGTERM')).toEqual(before); + expect(() => unlinkPlugin('runner')).not.toThrow(); + }, +); + it('forwards a captured shutdown signal to its owned process group and waits for cleanup', async () => { if (process.platform === 'win32') return; // Signal and taskkill behavior needs a Windows host. const listeners = new Map void>(); From ad6282105cfdc8e3713c9ada30f82e4fa6e0e95a Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 30 Oct 2026 14:17:03 +0800 Subject: [PATCH 04/10] feat: sandbox plugins and use public Explorer CLI integration --- .changeset/plugin-system-proposal.md | 2 +- .github/workflows/plugin-sandbox.yml | 35 ++++ README.md | 2 +- docs/plugin-system-proposal.md | 22 ++- docs/plugins.md | 109 ++++++------ examples/plugins/explorer/cli.mjs | 135 --------------- examples/plugins/explorer/package.json | 15 -- package.json | 1 + packages/plugin-explorer/README.md | 31 ++++ packages/plugin-explorer/cli.mjs | 146 ++++++++++++++++ packages/plugin-explorer/package.json | 29 ++++ pnpm-lock.yaml | 29 ++++ scripts/build.js | 6 +- scripts/plugin-sandbox-smoke.mjs | 155 +++++++++++++++++ scripts/plugin-smoke.mjs | 93 ++++++++++- scripts/prepare-explorer-prototype.mjs | 57 ------- src/cli.ts | 4 +- src/plugins/broker.ts | 222 +++++++++++++++++++++++++ src/plugins/cli.ts | 58 ++++++- src/plugins/context.ts | 6 + src/plugins/manifest.ts | 12 ++ src/plugins/run.ts | 21 ++- src/plugins/sandbox.ts | 163 ++++++++++++++++++ src/plugins/store.ts | 98 +++++++++-- tests/cli-errors.test.ts | 17 +- tests/plugin-broker.test.ts | 62 +++++++ tests/plugin-run.test.ts | 15 ++ tests/plugin-sandbox.test.ts | 22 +++ tests/plugins.test.ts | 61 ++++++- 29 files changed, 1313 insertions(+), 315 deletions(-) create mode 100644 .github/workflows/plugin-sandbox.yml delete mode 100644 examples/plugins/explorer/cli.mjs delete mode 100644 examples/plugins/explorer/package.json create mode 100644 packages/plugin-explorer/README.md create mode 100644 packages/plugin-explorer/cli.mjs create mode 100644 packages/plugin-explorer/package.json create mode 100644 scripts/plugin-sandbox-smoke.mjs delete mode 100644 scripts/prepare-explorer-prototype.mjs create mode 100644 src/plugins/broker.ts create mode 100644 src/plugins/sandbox.ts create mode 100644 tests/plugin-broker.test.ts create mode 100644 tests/plugin-sandbox.test.ts diff --git a/.changeset/plugin-system-proposal.md b/.changeset/plugin-system-proposal.md index 5771c28..f3d552d 100644 --- a/.changeset/plugin-system-proposal.md +++ b/.changeset/plugin-system-proposal.md @@ -2,4 +2,4 @@ '@offckb/cli': minor --- -Add experimental local tool plugins with explicit link/list/unlink commands, static manifest validation, versioned devnet context, opaque argument forwarding and foreground process cleanup. Include a context probe and a pinned, repository-only Devnet Explorer adapter experiment; registry installation and background services remain deferred. +Add experimental local tool plugins with explicit link/list/unlink commands, static manifest validation, versioned devnet context, opaque argument forwarding and foreground process cleanup. Enforce OS sandboxing with explicit filesystem/read-RPC/HTTP capabilities and filtered environment. Include a context probe and an unpublished Devnet Explorer adapter using only the proposed public upstream CLI contract; registry installation and background services remain deferred. diff --git a/.github/workflows/plugin-sandbox.yml b/.github/workflows/plugin-sandbox.yml new file mode 100644 index 0000000..00986d3 --- /dev/null +++ b/.github/workflows/plugin-sandbox.yml @@ -0,0 +1,35 @@ +name: Plugin sandbox + +on: + push: + branches: [master, develop] + pull_request: + branches: [master, develop] + +jobs: + native-sandbox: + name: Native sandbox on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 24.x + - name: Install Linux sandbox prerequisites + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y bubblewrap socat ripgrep + # Ephemeral runner only: Ubuntu's AppArmor restriction otherwise blocks bwrap. + # Never change a user's host settings automatically. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: node scripts/plugin-sandbox-smoke.mjs diff --git a/README.md b/README.md index 171c82c..9e284bf 100644 --- a/README.md +++ b/README.md @@ -608,7 +608,7 @@ npm install -g @offckb/cli ## Experimental local plugins -Local tool packages can be explicitly linked with `offckb plugin link `, inspected with `offckb plugin list`, and run as `offckb `. The host supplies devnet context and owns foreground cleanup. See the [plugin guide](docs/plugins.md) for the context probe, experimental Devnet Explorer adapter, and current limits. Registry installation and background plugins are not implemented yet. +Local tool packages can be explicitly linked with `offckb plugin link `, inspected with `offckb plugin list`, and run as `offckb `. The host enforces an OS sandbox with explicit capability grants, supplies restricted devnet context and owns foreground cleanup. Plugins currently require macOS/Linux and Node 22.21+ (22.x) or 24.5+; unsupported environments fail closed. See the [plugin guide](docs/plugins.md) for the context probe, public-CLI Explorer adapter (awaiting upstream release), and security limits. Registry installation and background plugins are not implemented yet. ## Contributing diff --git a/docs/plugin-system-proposal.md b/docs/plugin-system-proposal.md index 5f8a863..816b431 100644 --- a/docs/plugin-system-proposal.md +++ b/docs/plugin-system-proposal.md @@ -1,6 +1,12 @@ # offckb 工具插件系统:以 Devnet Explorer 为首个案例 -状态:**设计讨论稿;Phase 1 的本地接入实验已开始实现,后续设计尚未全部落地。** 初始调研日期:2026-10-28;实现基于 2026-10-29 rebase 后的 `96094c8`。实际可用范围和操作方式见[插件指南](plugins.md)。 +状态:**设计讨论稿;本地协议已实现,正在补齐强制沙箱与公开 CLI 接入;未获人类验收。** 初始调研日期:2026-10-28;实现基于 2026-10-29 rebase 后的 `96094c8`。实际可用范围和操作方式见[插件指南](plugins.md)。 + +## 本轮实现调整(2026-10-30) + +用户要求强制安全沙箱与 Explorer 公开稳定接入。本轮以固定 sandbox-runtime 隔离文件及网络(作为可选安装依赖保留核心 Node 20 安装兼容性,但执行插件时必须存在),显式授权 `devnet-read`、`project-deployment`、`serve` 和额外 `--read` 路径;RPC 凭据留在宿主,只暴露只读网关,HTTP 经指定 Unix 连接由宿主监听 loopback。不继承调用者凭据环境。进程 cwd 改为插件目录,原目录用 `context.cwd`。插件要求 macOS/Linux 与 Node 22.21+(22.x)或 24.5+;不支持时拒绝执行,核心 Node 20 不变。无不安全降级开关。 + +Explorer 迁到 `packages/plugin-explorer`,仅调用 npm 公开 bin 的版本化能力查询、脚本输入、JSON ready 和 Unix HTTP bridge。旧私有源码实验已删除。[上游贡献 PR #1](https://github.com/wildanrhmn/ckb-devnet-explorer/pull/1) 尚待合并发布;适配包保持 private,不能将本地 tarball 验证算作正式分发。沙箱不提供 VM 或 CPU/内存配额,仍需审查代码及授权路径。以下早期跨平台/运行边界以本段及[实际指南](plugins.md)为准。 ## 建议 @@ -54,7 +60,7 @@ dsh 当前的插件管理已包含锁、兼容性检查、安装后选择 bundle | 静态声明 + 环境协议 + 子进程 | 保留独立 CLI,增加可控的发现、兼容性及生命周期约定 | **推荐** | | 动态加载 JS 插件并提供 `register(ctx)` | 便于共享 Commander、事件和服务,但会把核心内部 API、模块解析和异常边界变成长期协议 | 等出现必须在进程内协作的需求再考虑 | -子进程主要解决依赖、ESM/CJS 和故障边界;它仍以用户权限执行,**不是安全沙箱**。 +子进程本身只解决依赖、ESM/CJS 和故障边界。当前实现必须再套 OS 沙箱,文件及网络访问按显式授权限制;不能以子进程隔离替代沙箱。 用户自装与内置安装器是安装职责的选择,可以共用同一套插件协议。Phase 1 结束后应根据实际安装困难决定是否启动 Phase 2,不能仅因协议可行就认定必须开发安装器。 @@ -114,9 +120,9 @@ offckb plugin remove explorer - 当前 CLI 的 Node 和入口文件绝对路径,作为必要时调用核心命令的备用方式。 - 输出格式;结构化输出模式必须由适配器遵守,不能直接混入上游的人类日志。 -不复制完整 settings、账户私钥或不相关配置。上面的“只读”是协议约定,不构成对同用户进程的文件权限隔离;文件在本次子进程退出后清理。配置中的 RPC 凭据也不能被原样打印到诊断日志。 +不复制完整 settings、账户私钥或不相关配置。当前运行器以 OS 文件隔离约束可见范围,RPC URL 替换为只读网关或空字符串,认证留在宿主;临时快照在子进程退出后清理。协议快照仍应视为可能被插件修改,不能回读其内容作为宿主授权依据。 -核心在实际调用时解析上下文;顶层帮助、`plugin list` 只读声明,插件 `--help` 不要求链在线。插件拿到上下文后进行业务所需的就绪检查。所有参数作为 argv 数组转发,并保留调用者 cwd,禁止拼接 shell 命令。 +核心在实际调用时解析上下文;顶层帮助、`plugin list` 只读声明,插件 `--help` 不要求链在线。插件拿到上下文后进行业务所需的就绪检查。所有参数作为 argv 数组转发;沙箱中 cwd 为插件包目录,项目相对路径按上下文 cwd 解析,禁止拼接 shell 命令。 ### JSON 输出与参数边界 @@ -142,7 +148,7 @@ offckb plugin remove explorer ## Explorer:适合接入,但需要明确适配 -固定调研版本:[`477af4400a716353d140fc1a5b8fee8175f74fed`](https://github.com/wildanrhmn/ckb-devnet-explorer/tree/477af4400a716353d140fc1a5b8fee8175f74fed),包内版本为 `0.2.0`。以下保留初始源码检查结果;当前本地实验的端到端验证及局限见[插件指南](plugins.md)。 +历史调研版本(以下是旧版事实,当前实现只接公开 CLI):[`477af4400a716353d140fc1a5b8fee8175f74fed`](https://github.com/wildanrhmn/ckb-devnet-explorer/tree/477af4400a716353d140fc1a5b8fee8175f74fed),包内版本为 `0.2.0`。以下保留初始源码检查结果;当前本地实验的端到端验证及局限见[插件指南](plugins.md)。 | 上游现状 | 接入处理 | | --- | --- | @@ -165,10 +171,10 @@ offckb plugin remove explorer ### Explorer 正式分发门禁 -这是将 Explorer 实验升级为正式插件的相关 PR 的合并前置条件,不阻断仓库内 Phase 1 协议实验。正式分发必须先确定维护者,并完成以下二选一: +这是 Explorer 正式分发的合并前置条件。当前选择第 2 条,私有源码接入已删除;未发布的适配包不等于门禁已通过。正式分发必须先确定维护者,并完成以下二选一: 1. 上游合并并发布显式 host、系统脚本文件输入及受 semver 约束的 `startServer` 等公开入口;适配包通过正常包解析使用这些入口。 -2. 适配包只调用上游公开 CLI,固定发布版本,并以契约测试覆盖参数、loopback 监听、脚本输入、输出及退出清理。若公开 CLI 尚不能满足这些行为,仍不能毕业。 +2. 适配包只调用上游公开 CLI,固定发布版本,并以契约测试覆盖能力版本、loopback 监听、脚本输入、JSON ready、Unix bridge 及退出清理。当前已准备上游实现和契约测试,暂定 provider 0.3.0;须等待上游合并、发布并确认精确版本后才能发布适配包。若公开 CLI 尚不能满足这些行为,仍不能毕业。 毕业时删除源码 magic-string replace、私有 `src/*.ts` import、对 tsx export map 内部形状的解析,以及 `OFFCKB_EXPLORER_SOURCE` 实验路径覆盖。不将 `.prototype`、准备脚本或这些私有接入方式包装为正式发布方案。固定 commit 和通过本地冒烟测试不能代替公开兼容契约;保留上述依赖的正式分发 PR 不应合并。 @@ -201,6 +207,6 @@ Sentinel 使用 `kimi-coding/kimi-for-coding` 做了只读评审,认为选型 - Node 20 仍能使用 offckb 核心;Explorer 明确拒绝不满足 engines 的运行环境。普通 devnet 通过后,再单独验证 mainnet/testnet fork,不能依据 HTTP 能打开就宣布支持。 - 未决:确认适配包维护者、上游入口及分发方式;验证当前 npm JS 入口的可靠发现、禁用安装脚本后的交付形态、最小运行互斥和 Windows 进程清理;以 `ckb-tui` 检验协议适用性。以上事项尚未因文档修订而获得验证。 -侧聊审查补充:本地元数据失败已改为降级,重复 `runCli()` 的命令刷新有回归测试。Phase 2 再评估失效租约恢复与自定义 binary 的 RPC 链身份探测:仅有持久 PID 的存活检查不能证明进程身份或子进程组已退出,不能直接据此删除互斥记录,更不能向该 PID 发信号。当前人工确认后清理的限制保留。现有 Windows CI 的普通子进程测试通过,但信号测试跳过了 Windows;真实 `taskkill /T /F`、父命令退出、后代清理及中断状态的专项验证仍是 Phase 1 未闭环项,不能用三平台 CI 全绿代替。本文不构成阶段签收。 +侧聊审查补充:本地元数据失败已改为降级,重复 `runCli()` 的命令刷新有回归测试。Phase 2 再评估失效租约恢复与自定义 binary 的 RPC 链身份探测:仅有持久 PID 的存活检查不能证明进程身份或子进程组已退出,不能直接据此删除互斥记录,更不能向该 PID 发信号。当前人工确认后清理的限制保留。现有三平台核心 CI 不代表沙箱验证完成。当前 Windows 插件执行明确关闭;恢复支持须另行验证可靠的文件、网络及进程隔离,不能仅以 `taskkill` 测试代替。macOS/Linux 增加原生沙箱拒绝测试。本文不构成阶段签收。 以上保留整体设计与后续验收标准,不表示它们已经全部通过。第一阶段实现采用显式本地 link/list/unlink;registry 安装、后台服务与正式 Explorer 分发仍未交付。实现及验证方式见[插件指南](plugins.md)。 diff --git a/docs/plugins.md b/docs/plugins.md index 0228d02..070a89b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,10 +1,10 @@ -# Experimental local tool plugins +# Sandboxed local tool plugins -This is the first implementation stage of the [plugin proposal](plugin-system-proposal.md): explicit local registration, a versioned environment snapshot, and foreground process ownership. Registry downloads, `plugin add`, background plugins, `node --with`, and Fiber migration are deferred. +Plugins are independent npm-shaped packages with a static declaration and a prebuilt JavaScript entry. The host supplies a versioned context and runs each tool in an OS sandbox. This stage implements explicit `link/list/unlink`, foreground execution and capability grants. Registry installation, background services and plugin dependencies are deferred. ## Try the protocol -Build the CLI in this checkout, then explicitly link a local package you trust: +Use macOS or Linux, Node **22.21+ (22.x) or 24.5+**, and a working sandbox backend. macOS uses `sandbox-exec`; Linux requires bubblewrap, socat, ripgrep and permitted unprivileged user namespaces. The runtime is an optional npm dependency so core Node 20 installations remain supported; plugin users must install it on a supported Node version without omitting optional dependencies. A missing runtime or backend failure stops execution. There is no unsandboxed fallback. Windows plugin execution is disabled until a suitable backend is validated. Core offckb commands still support Node 20 on all three platforms. ```sh pnpm build @@ -15,100 +15,113 @@ node build/index.js context-probe --help node build/index.js plugin unlink context-probe ``` -The probe demonstrates exact argument forwarding and one JSON result. An uninitialized devnet is represented as `unknown`; the probe still works. Plugin code executes with your user permissions. Linking reads static metadata and never runs an install script or plugin module. +Linking reads metadata; it does not execute the plugin, install dependencies or run install scripts. `link` records the canonical package directory and explicit grants. `unlink` keeps the package and its data/cache. A lease prevents concurrent execution or relinking/unlinking a running tool. -`link` stores a canonical package path. It does not copy or install dependencies. The package author/user prepares its runtime first. `unlink` removes the registration and keeps the package, data and cache. A foreground lease prevents concurrent runs of the same plugin and unlink/relink while it is running. +A third party can publish a normal npm package. Until an installer exists, prepare it in a dedicated directory with an exact dependency version, `npm install --save-exact --omit=dev --ignore-scripts @`, inspect it, then link its directory under `node_modules`. Dependencies must work without installation scripts. Installing with an external package manager is outside the runtime sandbox; never enable unreviewed lifecycle scripts on the assumption that offckb will contain them. -Registrations default to `/plugins`. `OFFCKB_PLUGIN_HOME` selects an isolated registry/data/cache directory, useful for development. It does not change core settings or select another devnet. Discovery probes `registry.json` even for core commands; an absent registry is not created. A broken registry does not block core commands or core help; `plugin list` reports the registry error without overwriting it. Invalid and incompatible packages remain removable. Future core command collisions are reported as `shadowed`, with the core command taking priority. +Registrations default to `/plugins`. `OFFCKB_PLUGIN_HOME` selects isolated registry/storage; it does not change core settings. Discovery probes `registry.json` even for core commands, without creating an absent registry. Broken declarations/registries do not block core commands. `plugin list` reports invalid/incompatible entries; future core collisions are `shadowed` and remain removable. Legacy registrations have zero capability grants; new permissions require relinking. -## Package declaration +## Declaration and grants ```json { "name": "my-offckb-tool", "version": "0.1.0", - "engines": { "node": ">=20" }, + "engines": { "node": "^22.21.0 || >=24.5.0" }, "offckb": { "apiVersion": 1, "command": "my-tool", "entry": "dist/cli.mjs", - "description": "A local development tool" + "description": "A local development tool", + "permissions": ["devnet-read", "project-deployment", "serve"] } } ``` -The entry must be a regular `.js`, `.cjs` or `.mjs` file whose real path stays inside the package. TypeScript compilation belongs to the package. Dependencies resolve from its own directory. The host launches the entry with its current Node executable, using argv arrays and inherited stdio/cwd; it does not import the tool into the ncc bundle. +The entry must be a regular `.js`, `.cjs` or `.mjs` file whose real path stays inside the package. Commands start with a lowercase letter and contain lowercase letters, digits or hyphens, at most 48 characters. Core and duplicate names are reserved. Protocol and Node engine compatibility are checked before execution. -Commands contain lowercase letters, digits and hyphens, starting with a letter (maximum 48 characters). Core names, `help`, and already-linked command names are reserved. Node engines and protocol version are checked before execution. +Every requested capability must be explicitly granted: + +```sh +offckb plugin link /path/to/package \ + --allow devnet-read --allow project-deployment --allow serve +# Additional files or directories must be granted individually: +offckb plugin link /path/to/package --allow devnet-read --read /path/to/report.json +``` + +| Grant | Access | +| --- | --- | +| None | Read package/runtime dependencies and invocation context; write own data/cache and temporary directory | +| `devnet-read` | Reach only a host-owned JSON-RPC gateway with an explicit read-method allowlist; no transaction submission or node administration | +| `project-deployment` | Read the invocation project's `deployment/` directory; an escaping directory symlink is rejected | +| `serve` | Serve HTTP over a designated Unix socket to a host-owned `127.0.0.1` listener; no arbitrary TCP bind or loopback access | +| `--read ` | Read the canonical file/directory, recursively for directories; treat this as disclosure of its contents | + +Capabilities do not grant arbitrary Internet access, project writes, access to other plugins' data, SSH agents, keys or core settings. Manifest changes that add capabilities invalidate the registration until explicitly relinked. Local package source remains mutable; review changes before running it again. + +The host uses the pinned [Anthropic sandbox runtime](https://github.com/anthropics/sandbox-runtime) as an external npm dependency. OS restrictions apply to descendants too. Caller environment variables are replaced with a small allowlist: no inherited tokens, proxy credentials, SSH agent socket, Node preload options or module search overrides. The sandbox can read required OS/runtime locations and its package/dependency tree, so keep credentials out of these locations and out of packages. This is a filesystem/network boundary, not a VM or a resource quota: CPU/memory exhaustion, terminal output and OS/runtime vulnerabilities remain risks. Trust and review plugin code, particularly before granting additional read paths. ## Context and output -On execution, `OFFCKB_PLUGIN_CONTEXT` points to a temporary JSON file: +`OFFCKB_PLUGIN_CONTEXT` points to a temporary JSON file containing: -- `apiVersion: 1`, `offckbVersion`, `cwd`, and `output: "json" | "text"`. -- `cli.node` and `cli.entry`: the current Node/CLI paths, not a PATH lookup. -- `plugin.command`, `plugin.dataPath`, `plugin.cachePath`: persistent, plugin-specific storage. -- `devnet.rpcUrl`, `proxyUrl`, `kind`, `genesisHash`, `addressPrefix`, `systemScriptsFile`, `systemScriptsVersion: 1`. -- Optional `devnet.diagnostic: { code: "LIST_HASHES_FAILED", message: string }` when local metadata export fails. Consumers must tolerate additive fields in API version 1. +- `apiVersion: 1`, `offckbVersion`, invocation `cwd`, and `output: "json" | "text"`. +- `cli.node` and `cli.entry`: informative paths; they do not grant access to core files or permission to run privileged core operations. +- `plugin.command`, `plugin.dataPath`, `plugin.cachePath`: plugin-specific persistent storage. +- `devnet.kind`, `genesisHash`, `addressPrefix`, `systemScriptsFile`, `systemScriptsVersion: 1`. +- `devnet.rpcUrl` and `proxyUrl`: empty without `devnet-read`; otherwise the restricted gateway URL. Original RPC URLs, userinfo and credentials stay in the host. +- `sandbox.backend: "os"`, granted `permissions`, and optional `http: { socket, url }` for `serve`. +- Optional `devnet.diagnostic: { code: "LIST_HASHES_FAILED", message: string }`. Consumers must tolerate additive API v1 fields. -The scripts file contains a `devnet` record using the existing `system-scripts` entry shape. It and the context are removed after the child closes, including failures and handled signals. Data/cache remain. No account keys or complete settings object are copied. RPC URLs can contain credentials; plugins must avoid dumping the context or raw URLs to logs. +The process working directory is the package directory. Resolve project-relative arguments against **context.cwd**; that path alone grants no filesystem access. Only the listed paths/capabilities are available. The system scripts file uses the existing `devnet` record shape. Context/scripts and sandbox scratch storage are removed on normal/handled exit; data/cache remain. No account keys or full settings are exported. -Chain metadata comes from the configured managed CKB binary's local `list-hashes` output, with a three-second limit and no shell interpolation. A missing config/binary yields `kind: "unknown"`, null chain identity and no scripts file. Timeout, nonzero exit or invalid output yields the same unknown identity plus the diagnostic above; raw subprocess errors/stderr are excluded. Chain-independent plugins can continue. Invalid core settings or inability to write the context still fails the invocation. A custom fork marker without a recognized public genesis also yields `unknown`. Plugins needing a known chain must reject these cases, and must verify the RPC genesis against the context before interpreting addresses/scripts. A custom `node --binary-path` with no matching managed binary is not yet supported by the context exporter. +Chain metadata uses the managed CKB binary's local `list-hashes`, with a three-second timeout. Missing or failed metadata yields `unknown`, null identity/scripts and, on failure, a safe diagnostic; chain-independent tools can continue. Invalid settings/context writes still fail. Custom binaries without matching managed metadata remain unsupported. Chain-aware tools must reject unknown/unsupported chains and verify RPC genesis before interpreting addresses/scripts. -Standalone `offckb my-tool --help` / `-h` is delegated without exporting context or reading core settings. Tools should handle that request before reading `OFFCKB_PLUGIN_CONTEXT`. With additional arguments, normal context preparation still runs; the host does not guess whether a help-looking token is an option value or literal plugin argument. +Standalone `--help`/`-h` is sandboxed but skips settings and context export. Handle it before reading the context. Help mixed with other arguments follows normal preparation. -Core `--json` must precede the plugin command: +Core flags must precede the plugin name: ```sh -offckb --json my-tool --project . +offckb --json --plugin-port 7070 my-tool --project . offckb my-tool --json offckb --json my-tool -- --json -- literal ``` -The first invocation selects core JSON mode. In the second, `--json` is a plugin argument and core JSON mode stays off. The host consumes only an immediately following `--` separator; all remaining tokens, including another `--`, are preserved. Existing core commands retain their flag normalization. +In the second invocation `--json` belongs to the plugin. The host consumes only the immediately following `--` separator and preserves the rest. `--plugin-port` selects the host HTTP listener (default 7070, `0` selects a free port); only `serve` uses it. The plugin owns stdout after delegation, including its one JSON result. Diagnostics go to stderr; the parent propagates exit status without appending a fallback result. A malicious plugin can still violate this output convention. -After delegation, the child owns stdout, including its single JSON success result. The host never appends its fallback result; it propagates child exit status. Diagnostics and structured failures belong on stderr. This preserves the host's stream contract but does not validate or sandbox a misbehaving plugin's output. +The RPC gateway only accepts bounded JSON-RPC 2.0 POST requests with array params and known read methods. Redirects, batches, write/admin methods and oversized requests/responses are rejected. Browser-facing HTTP responses receive host-controlled CSP, no-referrer and nosniff headers; the bridge provides no CONNECT or WebSocket tunnel. It cannot protect information the user explicitly enters into a plugin UI or eliminate every browser-side risk. -## Explorer experiment +## Explorer through a public upstream CLI -The example adapter uses `wildanrhmn/ckb-devnet-explorer` at commit `477af4400a716353d140fc1a5b8fee8175f74fed` (0.2.0), verified unchanged on 2026-10-29. It is **an unpublished, repository-only experiment**, using pinned private source imports. Formal upstream entry points and a release maintainer are still needed before distribution. +The adapter is now [packages/plugin-explorer](../packages/plugin-explorer). It exclusively resolves upstream's declared npm `bin` and invokes its public CLI; it does not import private `src/*.ts`, inspect tsx exports, replace source strings, or accept a source-path override. The former private-source prototype and preparation script have been removed. -The [formal-distribution gate](plugin-system-proposal.md#explorer-正式分发门禁) requires either released upstream APIs covered by semver or exclusive use of its public CLI with a pinned version and contract tests. A formal plugin PR retaining source replacements, private imports, tsx export-map inspection or the **PROTOTYPE-ONLY** `OFFCKB_EXPLORER_SOURCE` override must not merge. A successful local experiment does not clear this gate. +The adapter requires a pinned upstream release with integration API v1: offline `--integration-info`, `--system-scripts`, explicit `--host`, outbound Unix `--bridge` transport, and a single versioned JSON ready record. Its provisional dependency is `ckb-devnet-explorer@0.3.0`. **That upstream release is not yet available ([upstream PR #1](https://github.com/wildanrhmn/ckb-devnet-explorer/pull/1)); the adapter package is private and must not be published until the upstream contract is merged/released and the exact version is confirmed.** Local tests use an npm tarball of the upstream contribution, never a private import fallback. See the [distribution gate](plugin-system-proposal.md#explorer-正式分发门禁). -Prepare a source checkout at that revision: +Once the released provider is available: ```sh -git clone https://github.com/wildanrhmn/ckb-devnet-explorer.git /tmp/ckb-devnet-explorer -git -C /tmp/ckb-devnet-explorer checkout 477af4400a716353d140fc1a5b8fee8175f74fed -node scripts/prepare-explorer-prototype.mjs /tmp/ckb-devnet-explorer -npm install --prefix .prototype/explorer --omit=dev --ignore-scripts - -node build/index.js plugin link ./examples/plugins/explorer +npm install --prefix packages/plugin-explorer --omit=dev --ignore-scripts +node build/index.js plugin link ./packages/plugin-explorer \ + --allow devnet-read --allow project-deployment --allow serve node build/index.js node --daemon -node build/index.js explorer --project /path/to/deployed-project --port 7070 +node build/index.js --plugin-port 7070 explorer --project . +# Other project deployment directories require explicit --read grants at link time; use canonical absolute paths for --project (symlink aliases outside the granted tree are unavailable). ``` -The preparation script requires a Git checkout at the pinned commit with no changes reported by `git status`, including untracked files. Source archives, different revisions and dirty checkouts are rejected before copying or writing a marker. It copies source into ignored `.prototype/explorer`; it does not edit the checkout supplied by the user. The local copy adds an explicit server host and moves the upstream startup message to stderr. The adapter reads system scripts from the host snapshot, merges `deployment/scripts.json` from selected projects, and never invokes `offckb` from PATH. It loads tsx's declared ESM entry, matching upstream's ESM loader. - -Node 22+ and an initialized ordinary devnet are required. Forks and unknown chain identities are rejected before listening. RPC genesis must match the supplied scripts context; missing/lagging built-in indexer is reported separately. The web server binds `127.0.0.1` and defaults to 7070; `--port 0` selects a free port. `--project` is repeatable and defaults to invocation cwd. There is no arbitrary `--rpc` override or automatic browser launch. - -Ctrl+C stops the Explorer command, while a separately running CKB daemon continues. Restart Explorer after redeployment, chain reset or chain switching so its script names and caches reload. Upstream acceptance of new APIs is not required for this local experiment, but the experiment is not a promise to maintain a fork. +Only ordinary devnet with `ckt` is supported. Unknown chains/forks fail before ready. RPC genesis must match context; missing/lagging indexer is reported separately. The adapter receives only read RPC access; the host exposes the page on loopback. Ctrl+C stops the tool without stopping a separately running CKB node. Restart after redeploy, reset or chain switching to reload scripts/caches. There is no arbitrary RPC override or automatic browser launch. -## Validation and remaining limits +## Validation and limits ```sh pnpm test --runInBand pnpm typecheck pnpm lint pnpm build -# Optional: prepared Explorer runtime and an official CKB 0.208.0 binary are required. +node scripts/plugin-sandbox-smoke.mjs +# Optional: public provider installed and official CKB 0.208.0 binary: node scripts/plugin-smoke.mjs /path/to/ckb ``` -The opt-in smoke test uses the production ncc bundle, random local ports, temporary settings and a copied devnet. It checks exact argv, single JSON result, offline help, continued probe execution after a real `list-hashes` failure, the real Explorer page/API, current RPC/project context, refusal to unlink a running plugin, signal cleanup, CKB remaining alive, chain reset/restart and rejection of an unknown fork marker. It redirects `os.homedir()` only inside its test workers before loading the bundle; it does not change HOME or user settings. Downloaded binaries, source and runtime dependencies are not shipped. - -The second-tool paper review uses existing `src/cmd/status.ts`: ckb-tui would consume `proxyUrl`, retain TTY checks and forward native `-r`/optional `-t` arguments from a Node adapter. The current context lacks the optional TCP log address, so the minimal adapter could use RPC-only mode; adding that address or requiring full log panels needs a later explicit protocol decision. Native TTY/job-control behavior still requires testing. Existing `status` stays in core. - -POSIX plugin processes use an invocation-owned group. SIGINT/SIGTERM/SIGHUP are forwarded; a three-second shutdown limit and final group cleanup prevent owned descendants from remaining after the tool exits. The host never signals a saved PID. Windows immediately uses `taskkill /T /F` without a graceful period. The signal test skips Windows; passing its ordinary spawn tests does not verify this branch. Native Windows interrupt, process-tree cleanup and exit-status validation remain open for Phase 1. Plugins must not detach their own unmanaged daemons. +The native security test exercises the production bundle with canary secrets: forbidden reads/writes, symlink/child escape, direct network access, credential inheritance and write RPC rejection, plus positive cache/read-RPC access. CI runs it on native macOS/Linux with Node 24. The live test additionally checks Explorer page/API, ready output, SIGTERM, node survival, chain reset/restart and unknown-fork rejection. Tests isolate settings and override `os.homedir()` only in their worker, without changing HOME. -SIGKILL, host crashes or power loss can leave `registry.lock`, `.running`, or temporary context directories. This stage fails closed on those locks: verify the owner/tool has stopped before removing the named stale file, then retry. Automatic crash recovery, full runtime identity management, concurrent instances and background services are deferred. Registry locks are short-lived and registration writes use atomic rename; tool source itself remains mutable because this is explicit local linking. +POSIX foreground groups receive SIGINT/SIGTERM/SIGHUP, a three-second limit and final owned-process cleanup. No saved PID is signalled. Windows plugin execution is refused. Crash/SIGKILL can leave registry/run locks or temporary files: confirm the tool has stopped before manual cleanup. Automatic crash recovery, concurrent instances, resource quotas, background services and arbitrary network grants are deferred. A second-tool `ckb-tui` adapter still needs native TTY and sandbox network adaptation; its existing core `status` command is unchanged. diff --git a/examples/plugins/explorer/cli.mjs b/examples/plugins/explorer/cli.mjs deleted file mode 100644 index c1d2283..0000000 --- a/examples/plugins/explorer/cli.mjs +++ /dev/null @@ -1,135 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { createRequire } from 'node:module'; -import { pathToFileURL, fileURLToPath } from 'node:url'; -import { parseArgs } from 'node:util'; - -const args = process.argv.slice(2); -if (args.includes('--help') || args.includes('-h')) { - console.log( - 'Usage: offckb explorer [--project ]... [--port <0-65535>]\nExperimental adapter: Node 22+, ordinary initialized devnet, prepared upstream source.', - ); - process.exit(0); -} - -let json = false; -try { - const context = JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT, 'utf8')); - json = context.output === 'json'; - if (context.apiVersion !== 1) throw new Error('Unsupported offckb context version'); - if ( - context.devnet.kind !== 'pure-devnet' || - context.devnet.addressPrefix !== 'ckt' || - !context.devnet.systemScriptsFile - ) { - throw new Error( - 'Explorer currently requires an initialized ordinary devnet; forks and unknown chains are unsupported.', - ); - } - const { values } = parseArgs({ - args, - options: { - project: { type: 'string', multiple: true }, - port: { type: 'string', default: '7070' }, - }, - }); - const port = Number(values.port); - if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('--port must be an integer from 0 to 65535'); - const projects = (values.project ?? [context.cwd]).map((directory) => path.resolve(context.cwd, directory)); - // PROTOTYPE-ONLY: remove this override together with private source imports before formal distribution. - const source = path.resolve( - process.env.OFFCKB_EXPLORER_SOURCE || fileURLToPath(new URL('../../../.prototype/explorer', import.meta.url)), - ); - const marker = path.join(source, '.offckb-prototype.json'); - if (!fs.statSync(marker).isFile() || JSON.parse(fs.readFileSync(marker, 'utf8')).apiVersion !== 1) { - throw new Error('Prepare the pinned Explorer source first; see docs/plugins.md'); - } - const require = createRequire(path.join(source, 'package.json')); - const tsxManifest = require.resolve('tsx/package.json'); - const tsx = JSON.parse(fs.readFileSync(tsxManifest, 'utf8')); - const esmApi = tsx.exports['./esm/api'].import.default; - const { register } = await import(pathToFileURL(path.resolve(path.dirname(tsxManifest), esmApi)).href); - register(); - // Deliberately pinned private imports for this experiment, not a published compatibility contract. - const [{ startServer }, { Explorer }, { Resolver }, { loadProjectScripts }] = await Promise.all( - ['server', 'explorer', 'resolver', 'scripts'].map( - (name) => import(pathToFileURL(path.join(source, 'src', `${name}.ts`)).href), - ), - ); - const exported = JSON.parse(fs.readFileSync(context.devnet.systemScriptsFile, 'utf8')); - const known = new Map(); - for (const [name, entry] of Object.entries(exported.devnet)) { - const script = entry?.script ?? entry; - if (!script?.codeHash || !script.hashType) continue; - const dep = script.cellDeps?.[0]?.cellDep; - known.set(`${script.codeHash.toLowerCase()}:${script.hashType}`, { - name, - source: 'genesis', - codeHash: script.codeHash.toLowerCase(), - hashType: script.hashType, - location: dep ? { txHash: dep.outPoint.txHash, index: Number(dep.outPoint.index), depType: dep.depType } : null, - }); - } - for (const project of projects) for (const [key, script] of loadProjectScripts(project)) known.set(key, script); - const rpc = async (method, params = []) => { - const response = await fetch(context.devnet.rpcUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - signal: AbortSignal.timeout(3000), - }); - if (!response.ok) throw new Error('Devnet RPC request failed'); - const result = await response.json(); - if (result.error) throw new Error(`Devnet RPC ${method} is unavailable`); - return result.result; - }; - if ((await rpc('get_block_hash', ['0x0'])) !== context.devnet.genesisHash) { - throw new Error('Configured RPC genesis does not match the offckb devnet context.'); - } - const tip = BigInt(await rpc('get_tip_block_number')); - let indexerReady = false; - try { - indexerReady = BigInt((await rpc('get_indexer_tip')).block_number) >= tip; - } catch { - /* Report separately. */ - } - if (!indexerReady) - console.error('CKB indexer is missing or behind; some Explorer queries may fail or be incomplete.'); - const server = startServer( - new Explorer(context.devnet.rpcUrl, new Resolver(known)), - port, - context.devnet.rpcUrl, - projects, - '127.0.0.1', - ); - server.once('listening', () => { - const url = `http://127.0.0.1:${server.address().port}`; - if (json) console.log(JSON.stringify({ ok: true, command: 'explorer', url, indexerReady })); - else console.error(`Explorer: ${url}`); - }); - server.on('error', (error) => { - console.error( - json - ? JSON.stringify({ ok: false, code: 'EXPLORER_SERVER_FAILED', message: error.code || 'Server failed' }) - : `Explorer server failed: ${error.code || 'unknown error'}`, - ); - process.exitCode = 1; - }); - for (const [signal, code] of [ - ['SIGINT', 130], - ['SIGTERM', 143], - ['SIGHUP', 129], - ]) { - process.once(signal, () => { - server.close(() => { - process.exitCode = code; - }); - server.closeAllConnections(); - }); - } -} catch (error) { - const message = - error.code === 'ENOENT' ? 'Prepared Explorer source or context is missing; see docs/plugins.md' : error.message; - console.error(json ? JSON.stringify({ ok: false, code: 'EXPLORER_START_FAILED', message }) : message); - process.exitCode = 1; -} diff --git a/examples/plugins/explorer/package.json b/examples/plugins/explorer/package.json deleted file mode 100644 index e32a044..0000000 --- a/examples/plugins/explorer/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "offckb-explorer-prototype", - "version": "0.1.0", - "private": true, - "type": "module", - "engines": { - "node": ">=22" - }, - "offckb": { - "apiVersion": 1, - "command": "explorer", - "entry": "cli.mjs", - "description": "Browse the current ordinary devnet (experimental upstream adapter)" - } -} diff --git a/package.json b/package.json index 80f67d8..023b13f 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "winston": "^3.17.0" }, "optionalDependencies": { + "@anthropic-ai/sandbox-runtime": "0.0.81", "cpu-features": "^0.0.10" } } diff --git a/packages/plugin-explorer/README.md b/packages/plugin-explorer/README.md new file mode 100644 index 0000000..7272c6a --- /dev/null +++ b/packages/plugin-explorer/README.md @@ -0,0 +1,31 @@ +# @offckb/plugin-explorer + +Unpublished adapter for the **public** ckb-devnet-explorer CLI integration API v1. This package is private until [the upstream interface contribution](https://github.com/wildanrhmn/ckb-devnet-explorer/pull/1) is merged and released. `0.3.0` is the proposed provider version, not a claim that npm already contains it. + +Requires sandboxed offckb, macOS/Linux and Node 22.21+ (22.x) or 24.5+. Install the exact provider dependency with scripts disabled, then: + +```sh +offckb plugin link /path/to/this/package \ + --allow devnet-read --allow project-deployment --allow serve +offckb --plugin-port 7070 explorer --project . +``` + +Only an initialized ordinary devnet is supported. Forks/unknown chain identity or a mismatching genesis are rejected. Indexer lag is reported separately. Restart after deploy/reset. Additional projects require explicit `--read /absolute/project/deployment` grants when linking. + +The adapter invokes the provider's declared npm bin with `--integration-info`, then `serve --json --rpc --system-scripts --bridge `. It checks API v1 and the capabilities `system-scripts`, `host`, `bridge`, `json-ready`; one JSON ready record is required. No private imports, PATH invocation of offckb or source overrides are used. The host owns loopback serving and descendant cleanup. + +See [the plugin guide](../../docs/plugins.md) for grants, context, security limits and validation. Do not publish this package or substitute an unrelated upstream version to bypass its compatibility check. + +To reproduce pre-release integration tests, use the upstream PR commit as a normal npm provider tarball (not a source-import override): + +```sh +git clone https://github.com/RetricSu/ckb-devnet-explorer.git .prototype/explorer-public-cli +git -C .prototype/explorer-public-cli checkout fecb659 +npm pack ./.prototype/explorer-public-cli --ignore-scripts --pack-destination .prototype +npm install --prefix packages/plugin-explorer --no-save --package-lock=false \ + --omit=dev --ignore-scripts ./.prototype/ckb-devnet-explorer-0.3.0.tgz +pnpm build +node scripts/plugin-smoke.mjs /path/to/ckb-0.208.0 +``` + +This validates the proposed contract locally; it does not satisfy the upstream release gate. Keep these test artifacts out of the published package. diff --git a/packages/plugin-explorer/cli.mjs b/packages/plugin-explorer/cli.mjs new file mode 100644 index 0000000..7fe7e9b --- /dev/null +++ b/packages/plugin-explorer/cli.mjs @@ -0,0 +1,146 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { execFileSync, spawn } from 'node:child_process'; +import { parseArgs } from 'node:util'; + +const args = process.argv.slice(2); +if (args.length === 1 && ['--help', '-h'].includes(args[0])) { + console.log( + 'Usage: offckb [--plugin-port ] explorer [--project ]...\nRequires the public Devnet Explorer CLI integration API v1.', + ); + process.exit(0); +} + +let json = false; +let child; +try { + const context = JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT, 'utf8')); + json = context.output === 'json'; + if (context.apiVersion !== 1 || !context.sandbox?.http) + throw new Error('A sandboxed offckb context with HTTP serving is required'); + if ( + context.devnet.kind !== 'pure-devnet' || + context.devnet.addressPrefix !== 'ckt' || + !context.devnet.systemScriptsFile + ) + throw new Error('Explorer requires an initialized ordinary devnet; forks and unknown chains are unsupported'); + const { values } = parseArgs({ args, options: { project: { type: 'string', multiple: true } } }); + const projects = (values.project ?? [context.cwd]).map((file) => { + try { + return fs.realpathSync(path.resolve(context.cwd, file)); + } catch { + throw new Error( + 'Project path is unavailable in the sandbox; use a canonical path and grant its deployment directory', + ); + } + }); + const require = createRequire(import.meta.url); + const manifest = require.resolve('ckb-devnet-explorer/package.json'); + const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')); + if (pkg.name !== 'ckb-devnet-explorer' || pkg.version !== '0.3.0') + throw new Error('Expected ckb-devnet-explorer 0.3.0'); + const root = fs.realpathSync(path.dirname(manifest)); + const bin = fs.realpathSync(path.resolve(root, pkg.bin['ckb-devnet-explorer'])); + if (!bin.startsWith(root + path.sep) || !fs.statSync(bin).isFile()) + throw new Error('Invalid Explorer public CLI entry'); + const info = JSON.parse( + execFileSync(process.execPath, [bin, '--integration-info'], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'pipe'], + }), + ); + if ( + info.apiVersion !== 1 || + !['system-scripts', 'host', 'bridge', 'json-ready'].every((value) => info.capabilities?.includes(value)) + ) + throw new Error('Explorer does not provide the required public CLI integration contract'); + const rpc = async (method, params = []) => { + const response = await fetch(context.devnet.rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(5000), + }); + const result = await response.json(); + if (!response.ok || result.error) throw new Error(`Devnet RPC ${method} is unavailable`); + return result.result; + }; + if ((await rpc('get_block_hash', ['0x0'])) !== context.devnet.genesisHash) + throw new Error('RPC genesis does not match the supplied scripts'); + const tip = BigInt(await rpc('get_tip_block_number')); + let indexerReady = false; + try { + indexerReady = BigInt((await rpc('get_indexer_tip')).block_number) >= tip; + } catch { + /* Report separately. */ + } + if (!indexerReady) console.error('Indexer is missing or behind; some queries may be incomplete'); + child = spawn( + process.execPath, + [ + bin, + 'serve', + '--json', + '--rpc', + context.devnet.rpcUrl, + '--system-scripts', + context.devnet.systemScriptsFile, + '--bridge', + context.sandbox.http.socket, + ...projects.flatMap((directory) => ['--project', directory]), + ], + { stdio: ['ignore', 'pipe', 'inherit'] }, + ); + const closed = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + let requested; + for (const [signal, code] of [ + ['SIGINT', 130], + ['SIGTERM', 143], + ['SIGHUP', 129], + ]) + process.once(signal, () => { + requested = code; + child.kill(signal); + }); + let count = 0; + let buffer = ''; + let invalid = false; + child.stdout.on('data', (chunk) => { + buffer += chunk.toString(); + if (buffer.length > 65536) { + invalid = true; + child.kill('SIGTERM'); + return; + } + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + try { + const ready = JSON.parse(line); + if (++count !== 1 || ready.ok !== true || ready.apiVersion !== 1 || ready.transport !== 'bridge') + throw new Error('Unexpected ready record'); + if (json) + console.log(JSON.stringify({ ok: true, command: 'explorer', url: context.sandbox.http.url, indexerReady })); + else console.error(`Explorer: ${context.sandbox.http.url}`); + } catch { + invalid = true; + child.kill('SIGTERM'); + } + } + }); + const exit = await closed; + if (invalid || count !== 1 || buffer.trim()) throw new Error('Explorer violated its public ready/output contract'); + process.exitCode = requested ?? exit.code ?? 1; +} catch (error) { + child?.kill('SIGTERM'); + const message = + error.code === 'MODULE_NOT_FOUND' ? 'Install the pinned Explorer provider; see the plugin README' : error.message; + console.error(json ? JSON.stringify({ ok: false, code: 'EXPLORER_FAILED', message }) : message); + process.exitCode = 1; +} diff --git a/packages/plugin-explorer/package.json b/packages/plugin-explorer/package.json new file mode 100644 index 0000000..a6d0bdd --- /dev/null +++ b/packages/plugin-explorer/package.json @@ -0,0 +1,29 @@ +{ + "name": "@offckb/plugin-explorer", + "version": "0.1.0", + "description": "Devnet Explorer through offckb's restricted plugin interface", + "license": "MIT", + "type": "module", + "files": [ + "cli.mjs", + "README.md" + ], + "engines": { + "node": "^22.21.0 || >=24.5.0" + }, + "dependencies": { + "ckb-devnet-explorer": "0.3.0" + }, + "offckb": { + "apiVersion": 1, + "command": "explorer", + "entry": "cli.mjs", + "description": "Browse the current devnet in the sandbox", + "permissions": [ + "devnet-read", + "project-deployment", + "serve" + ] + }, + "private": true +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1e333e..770d8cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: specifier: ^5.3.3 version: 5.8.2 optionalDependencies: + '@anthropic-ai/sandbox-runtime': + specifier: 0.0.81 + version: 0.0.81 cpu-features: specifier: ^0.0.10 version: 0.0.10 @@ -149,6 +152,11 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@anthropic-ai/sandbox-runtime@0.0.81': + resolution: {integrity: sha512-bjHDu87gjIR7+Oxy9jPiK3v8CvSELCzfpXa3fjqfSKdvlZET2iF51xcXILWPayTpowB3xtZeQVf2JR916nVW/w==} + engines: {node: '>=22.12.0'} + hasBin: true + '@babel/code-frame@7.28.6': resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} @@ -804,6 +812,9 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@pondwader/socks5-server@1.0.10': + resolution: {integrity: sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==} + '@sinclair/typebox@0.34.52': resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} @@ -2279,6 +2290,10 @@ packages: encoding: optional: true + node-forge@1.4.2: + resolution: {integrity: sha512-rRtAR5XLJ8gCKd9du4qrKAsgCG1ObGP4gLeNJMa4vYSeq4bmBMOJk5IHkrviBV0q7uoAJlVd8HEQYQSVUb4FGQ==} + engines: {node: '>= 6.13.0'} + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -2930,6 +2945,14 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} + '@anthropic-ai/sandbox-runtime@0.0.81': + dependencies: + '@pondwader/socks5-server': 1.0.10 + commander: 12.1.0 + node-forge: 1.4.2 + zod: 3.25.76 + optional: true + '@babel/code-frame@7.28.6': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3843,6 +3866,9 @@ snapshots: '@pkgr/core@0.2.9': {} + '@pondwader/socks5-server@1.0.10': + optional: true + '@sinclair/typebox@0.34.52': {} '@sinonjs/commons@3.0.1': @@ -5464,6 +5490,9 @@ snapshots: dependencies: whatwg-url: 5.0.0(patch_hash=d3c34002a8c3a046735554ca0e151137b2b4d443668f131ed6e883c0128a6d3f) + node-forge@1.4.2: + optional: true + node-int64@0.4.0: {} node-releases@2.0.53: {} diff --git a/scripts/build.js b/scripts/build.js index 60641fe..80a424f 100755 --- a/scripts/build.js +++ b/scripts/build.js @@ -14,7 +14,11 @@ try { // Step 2: Bundle with NCC console.log('🔗 Bundling with NCC...'); - execSync('ncc build dist/cli.js -o build --external cpu-features', { stdio: 'inherit' }); + fs.rmSync('build', { recursive: true, force: true }); + execSync( + 'ncc build dist/cli.js -o build --external cpu-features --external @anthropic-ai/sandbox-runtime', + { stdio: 'inherit' }, + ); // Ship the official runtime once, without its unused entry point, types or snippets. const biscuitSource = 'node_modules/@biscuit-auth/biscuit-wasm/module'; const biscuitModule = new WebAssembly.Module(fs.readFileSync(path.join(biscuitSource, 'biscuit_bg.wasm'))); diff --git a/scripts/plugin-sandbox-smoke.mjs b/scripts/plugin-sandbox-smoke.mjs new file mode 100644 index 0000000..39f4131 --- /dev/null +++ b/scripts/plugin-sandbox-smoke.mjs @@ -0,0 +1,155 @@ +// Native denial tests against the production bundle. No real credentials or chain are used. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import net from 'node:net'; +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const project = fileURLToPath(new URL('../', import.meta.url)); +const cli = path.join(project, 'build/index.js'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb sandbox ')); +const home = path.join(root, 'plugins'); +const env = { + ...process.env, + OFFCKB_SANDBOX_TEST_ROOT: root, + OFFCKB_PLUGIN_HOME: home, + OFFCKB_CLI_PATH: cli, + OFFCKB_TEST_SECRET: 'environment-canary', + SSH_AUTH_SOCK: path.join(root, 'fake-agent.sock'), + XDG_CONFIG_HOME: path.join(root, 'config'), + XDG_DATA_HOME: path.join(root, 'data'), + XDG_CACHE_HOME: path.join(root, 'cache'), +}; +const makeWorker = (entry) => + `require('os').homedir=()=>process.env.OFFCKB_SANDBOX_TEST_ROOT;process.argv=[process.execPath,${JSON.stringify(entry)},...process.argv.slice(1)];require(${JSON.stringify(entry)}).runCli(process.argv);`; +async function invoke(args, expected = 0, entry = cli) { + const child = spawn(process.execPath, ['-e', makeWorker(entry), '--', ...args], { + cwd: project, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = '', + stderr = ''; + child.stdout.on('data', (value) => { + stdout += value; + }); + child.stderr.on('data', (value) => { + stderr += value; + }); + const timer = setTimeout(() => child.kill('SIGKILL'), 20000); + const code = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + clearTimeout(timer); + assert.equal(code, expected, stderr || stdout); + return { stdout, stderr }; +} +function plugin(name, code, permissions = []) { + const directory = path.join(root, name); + fs.mkdirSync(directory); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ name, version: '1.0.0', offckb: { apiVersion: 1, command: name, entry: 'cli.cjs', permissions } }), + ); + fs.writeFileSync(path.join(directory, 'cli.cjs'), code); + return directory; +} +const upstreamCalls = []; +const rpc = http.createServer(async (req, res) => { + let raw = ''; + for await (const chunk of req) raw += chunk; + upstreamCalls.push({ body: JSON.parse(raw), authorization: req.headers.authorization }); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x0' })); +}); +let forbiddenConnections = 0; +const forbidden = net.createServer((socket) => { + forbiddenConnections++; + socket.destroy(); +}); +try { + await Promise.all([ + new Promise((resolve) => rpc.listen(0, '127.0.0.1', resolve)), + new Promise((resolve) => forbidden.listen(0, '127.0.0.1', resolve)), + ]); + const settings = + process.platform === 'darwin' + ? path.join(root, 'Library/Preferences/offckb-nodejs/settings.json') + : path.join(env.XDG_CONFIG_HOME, 'offckb-nodejs/settings.json'); + fs.mkdirSync(path.dirname(settings), { recursive: true }); + fs.writeFileSync( + settings, + JSON.stringify({ devnet: { rpcUrl: `http://user:rpc-canary@127.0.0.1:${rpc.address().port}` } }), + ); + const secret = path.join(root, 'private.txt'); + fs.writeFileSync(secret, 'file-canary'); + const escapedWrite = path.join(root, 'escaped.txt'); + const probe = plugin( + 'malicious-probe', + ` + const fs=require('fs'),cp=require('child_process'),net=require('net'); + const c=JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT,'utf8')); + const denied=fn=>{try{fn();return false;}catch{return true;}}; + fs.symlinkSync(${JSON.stringify(secret)},c.plugin.dataPath+'/escape'); + fs.writeFileSync(c.plugin.cachePath+'/own','allowed'); + const result={read:denied(()=>fs.readFileSync(${JSON.stringify(secret)})),write:denied(()=>fs.writeFileSync(${JSON.stringify(escapedWrite)},'bad')),symlink:denied(()=>fs.readFileSync(c.plugin.dataPath+'/escape')),child:denied(()=>cp.execFileSync(process.execPath,['-e','require("fs").readFileSync('+JSON.stringify(${JSON.stringify(secret)})+')'],{stdio:'pipe'})),env:process.env.OFFCKB_TEST_SECRET===undefined&&process.env.SSH_AUTH_SOCK===undefined,context:!JSON.stringify(c).includes('rpc-canary')&&c.devnet.rpcUrl===''}; + const socket=net.connect(${forbidden.address().port},'127.0.0.1');let finished=false; + const timer=setTimeout(()=>{socket.destroy();done(true);},1000); + function done(blocked){if(finished)return;finished=true;clearTimeout(timer);result.network=blocked;console.log(JSON.stringify(result));if(Object.values(result).some(value=>value!==true))process.exitCode=1;} + socket.once('connect',()=>{socket.destroy();done(false);});socket.once('error',()=>done(true)); + `, + ); + await invoke(['plugin', 'link', probe]); + const denied = JSON.parse((await invoke(['--json', 'malicious-probe'])).stdout); + assert.ok(Object.values(denied).every((value) => value === true)); + assert.equal(fs.existsSync(escapedWrite), false); + assert.equal(fs.readFileSync(path.join(home, 'cache/malicious-probe/own'), 'utf8'), 'allowed'); + const bare = path.join(root, 'bare-bundle'); + fs.cpSync(path.dirname(cli), bare, { recursive: true }); + assert.match( + (await invoke(['malicious-probe'], 1, path.join(bare, 'index.js'))).stderr, + /Sandbox runtime is unavailable/, + ); + await invoke(['plugin', 'unlink', 'malicious-probe']); + console.log( + 'PASS: denied file reads/writes, symlinks, child escape, direct networking and inherited/context credentials', + ); + + const reader = plugin( + 'rpc-reader', + ` + const fs=require('fs');const c=JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT,'utf8')); + (async()=>{const call=method=>fetch(c.devnet.rpcUrl,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({jsonrpc:'2.0',id:1,method,params:[]})}); + const read=await call('get_tip_block_number'),write=await call('send_transaction');let otherBlocked;try{otherBlocked=(await fetch('http://127.0.0.1:${forbidden.address().port}',{signal:AbortSignal.timeout(3000)})).status===403;}catch{otherBlocked=true;}console.log(JSON.stringify({read:read.status,write:write.status,otherBlocked,safe:!JSON.stringify(c).includes('rpc-canary')}));})().catch(error=>{console.error(error);process.exitCode=1;}); + `, + ['devnet-read'], + ); + assert.match((await invoke(['plugin', 'link', reader], 1)).stderr, /explicit grants/); + await invoke(['plugin', 'link', reader, '--allow', 'devnet-read']); + assert.deepEqual(JSON.parse((await invoke(['--json', 'rpc-reader'])).stdout), { + read: 200, + write: 403, + otherBlocked: true, + safe: true, + }); + assert.equal(forbiddenConnections, 0); + assert.equal(upstreamCalls.length, 1); + assert.equal(upstreamCalls[0].body.method, 'get_tip_block_number'); + assert.equal(upstreamCalls[0].authorization, `Basic ${Buffer.from('user:rpc-canary').toString('base64')}`); + await invoke(['plugin', 'unlink', 'rpc-reader']); + const signalled = plugin('signal-exit', "process.kill(process.pid,'SIGTERM');"); + await invoke(['plugin', 'link', signalled]); + await invoke(['signal-exit'], 143); + await invoke(['plugin', 'unlink', 'signal-exit']); + assert.deepEqual(fs.readdirSync(path.join(home, 'tmp')), []); + console.log('PASS: explicit grants, read-only RPC gateway, no upstream credential disclosure, lease/context cleanup'); +} finally { + rpc.closeAllConnections(); + rpc.close(); + forbidden.close(); + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/scripts/plugin-smoke.mjs b/scripts/plugin-smoke.mjs index c089aaf..648c049 100644 --- a/scripts/plugin-smoke.mjs +++ b/scripts/plugin-smoke.mjs @@ -1,4 +1,4 @@ -// Opt-in live integration test. Requires a build, a CKB 0.208.0 binary and the prepared Explorer runtime. +// Opt-in live integration test. Requires a build, CKB 0.208.0 and the public Explorer provider installed in packages/plugin-explorer. // All settings, ports, chain data and registrations belong to a fresh temporary directory. import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -12,11 +12,13 @@ const project = path.resolve(fileURLToPath(new URL('../', import.meta.url))); const cli = path.join(project, 'build/index.js'); const ckb = process.argv[2]; if (!ckb || !fs.statSync(ckb).isFile()) throw new Error('Usage: node scripts/plugin-smoke.mjs '); -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb plugin smoke ')); +const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'offckb plugin smoke '))); const chain = path.join(root, 'devnet'); const home = path.join(root, 'plugins'); const env = { ...process.env, + OFFCKB_TEST_SECRET: 'sandbox-canary', + SSH_AUTH_SOCK: '/private/sandbox-canary-agent.sock', OFFCKB_SMOKE_ROOT: root, OFFCKB_PLUGIN_HOME: home, OFFCKB_CLI_PATH: cli, @@ -169,7 +171,78 @@ try { } console.log('PASS: failed local list-hashes does not block a chain-independent plugin, clean context'); - invoke(['--json', 'plugin', 'link', path.join(project, 'examples/plugins/explorer')]); + const attacker = path.join(root, 'attacker'); + fs.mkdirSync(attacker); + const secret = path.join(root, 'private.txt'); + const escapedWrite = path.join(root, 'escaped.txt'); + fs.writeFileSync(secret, 'private-canary'); + const listener = net.createServer((socket) => { + socket.destroy(); + throw new Error('Sandbox escaped network isolation'); + }); + await new Promise((resolve) => listener.listen(0, '127.0.0.1', resolve)); + fs.writeFileSync( + path.join(attacker, 'package.json'), + JSON.stringify({ + name: 'sandbox-probe', + version: '1.0.0', + offckb: { apiVersion: 1, command: 'sandbox-probe', entry: 'cli.cjs' }, + }), + ); + fs.writeFileSync( + path.join(attacker, 'cli.cjs'), + ` + const fs=require('fs'),cp=require('child_process'),net=require('net'); + const context=JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT,'utf8')); + const denied=(fn)=>{try{fn();return false;}catch{return true;}}; + const link=context.plugin.dataPath+'/escape';fs.symlinkSync(${JSON.stringify(secret)},link); + fs.writeFileSync(context.plugin.cachePath+'/own','allowed'); + const result={read:denied(()=>fs.readFileSync(${JSON.stringify(secret)})),write:denied(()=>fs.writeFileSync(${JSON.stringify(escapedWrite)},'bad')),symlink:denied(()=>fs.readFileSync(link)),child:denied(()=>cp.execFileSync('/bin/cat',[${JSON.stringify(secret)}],{stdio:'pipe'})),env:process.env.OFFCKB_TEST_SECRET===undefined&&process.env.SSH_AUTH_SOCK===undefined,rpc:context.devnet.rpcUrl===''}; + const socket=net.connect(${listener.address().port},'127.0.0.1'); + const timer=setTimeout(()=>{socket.destroy();result.network=true;finish();},1000); + function finish(){clearTimeout(timer);console.log(JSON.stringify(result));if(Object.values(result).some(value=>value!==true))process.exitCode=1;} + socket.once('connect',()=>{socket.destroy();result.network=false;finish();}); + socket.once('error',()=>{result.network=true;finish();}); + `, + ); + try { + invoke(['plugin', 'link', attacker]); + const result = JSON.parse(invoke(['--json', 'sandbox-probe'])); + assert.ok(Object.values(result).every((value) => value === true)); + assert.equal(fs.existsSync(escapedWrite), false); + invoke(['plugin', 'unlink', 'sandbox-probe']); + } finally { + await new Promise((resolve) => listener.close(resolve)); + } + console.log( + 'PASS: OS sandbox blocks secret reads, writes, symlink/child escapes, inherited credentials and direct networking', + ); + + const deployedProject = path.join(root, 'deployed project'); + const deployment = path.join(deployedProject, 'deployment'); + fs.mkdirSync(deployment, { recursive: true }); + fs.writeFileSync( + path.join(deployment, 'scripts.json'), + JSON.stringify({ + devnet: { + 'sandbox-fixture': { script: { codeHash: '0x' + 'ab'.repeat(32), hashType: 'type' } }, + }, + }), + ); + invoke([ + '--json', + 'plugin', + 'link', + path.join(project, 'packages/plugin-explorer'), + '--read', + deployment, + '--allow', + 'devnet-read', + '--allow', + 'project-deployment', + '--allow', + 'serve', + ]); for (let cycle = 0; cycle < 2; cycle++) { const node = start(['--json', 'node']); try { @@ -181,7 +254,7 @@ try { return false; } }, 'devnet RPC'); - const explorer = start(['--json', 'explorer', '--project', project, '--port', '0']); + const explorer = start(['--json', '--plugin-port', '0', 'explorer', '--project', deployedProject]); try { await until(() => { if (explorer.code !== undefined) throw new Error(`Explorer exited: ${explorer.stderr}`); @@ -191,10 +264,14 @@ try { assert.match(ready.url, /^http:\/\/127\.0\.0\.1:/); assert.equal((await fetch(ready.url)).status, 200); const info = await (await fetch(`${ready.url}/api/info`)).json(); - assert.equal(info.rpcUrl, rpcUrl); - assert.deepEqual(info.projects, [project]); + assert.match(info.rpcUrl, /^http:\/\/127\.0\.0\.1:/); + assert.notEqual(info.rpcUrl, rpcUrl); + assert.deepEqual(info.projects, [deployedProject]); const overview = await fetch(`${ready.url}/api/overview`); - assert.equal(overview.status, 200, await overview.text()); + assert.equal(overview.status, 200); + const summary = await overview.json(); + assert.equal(summary.knownScripts.project, 1); + assert.equal(summary.scripts[0].name, 'sandbox-fixture'); assert.throws(() => invoke(['--json', 'plugin', 'unlink', 'explorer']), /Command failed/); } finally { await stop(explorer); @@ -213,7 +290,7 @@ try { if (cycle === 0) invoke(['--json', 'clean', '--data']); } fs.writeFileSync(path.join(chain, 'fork.json'), JSON.stringify({ source: 'custom', firstRunPending: false })); - assert.throws(() => invoke(['--json', 'explorer', '--port', '0']), /Command failed/); + assert.throws(() => invoke(['--json', '--plugin-port', '0', 'explorer']), /Command failed/); fs.unlinkSync(path.join(chain, 'fork.json')); invoke(['--json', 'plugin', 'unlink', 'explorer']); assert.deepEqual(fs.readdirSync(path.join(home, 'tmp')), []); diff --git a/scripts/prepare-explorer-prototype.mjs b/scripts/prepare-explorer-prototype.mjs deleted file mode 100644 index 4b0597a..0000000 --- a/scripts/prepare-explorer-prototype.mjs +++ /dev/null @@ -1,57 +0,0 @@ -// Copy a user-supplied checkout, keeping the original upstream tree untouched. -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const pinnedCommit = '477af4400a716353d140fc1a5b8fee8175f74fed'; -const source = process.argv[2]; -if (!source) throw new Error('Usage: node scripts/prepare-explorer-prototype.mjs '); -const target = fileURLToPath(new URL('../.prototype/explorer/', import.meta.url)); -if (fs.existsSync(target)) - throw new Error(`Destination already exists; review/remove it before preparing again: ${target}`); -const git = (args) => - execFileSync('git', ['-C', source, ...args], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 10000, - }); -let head; -let status; -try { - head = git(['rev-parse', '--verify', 'HEAD^{commit}']).trim(); - status = git(['status', '--porcelain', '--untracked-files=all']); -} catch { - throw new Error(`Expected a clean Git checkout at commit ${pinnedCommit}`); -} -if (head !== pinnedCommit || status !== '') throw new Error(`Expected a clean Git checkout at commit ${pinnedCommit}`); -const pkg = JSON.parse(fs.readFileSync(path.join(source, 'package.json'), 'utf8')); -if (pkg.name !== 'ckb-devnet-explorer' || pkg.version !== '0.2.0') - throw new Error(`Expected ckb-devnet-explorer 0.2.0 at commit ${pinnedCommit}`); -let server = fs.readFileSync(path.join(source, 'src/server.ts'), 'utf8'); -const signature = 'projects: string[])'; -const listen = 'server.listen(port, () => console.log('; -if (!server.includes(signature) || !server.includes(listen)) - throw new Error('Upstream server changed; review the experimental adapter instead of patching blindly'); -server = server - .replace(signature, 'projects: string[], host = "127.0.0.1")') - .replace(listen, 'server.listen(port, host, () => console.error('); -fs.mkdirSync(target, { recursive: true }); -fs.cpSync(path.join(source, 'src'), path.join(target, 'src'), { recursive: true }); -// A runtime-only manifest avoids installing upstream development tools or running its scripts. -fs.writeFileSync( - path.join(target, 'package.json'), - JSON.stringify( - { name: 'offckb-explorer-prototype-runtime', private: true, type: 'module', dependencies: pkg.dependencies }, - null, - 2, - ), -); -fs.writeFileSync(path.join(target, 'src/server.ts'), server); -fs.writeFileSync( - path.join(target, '.offckb-prototype.json'), - JSON.stringify({ apiVersion: 1, upstreamCommit: pinnedCommit }), -); -console.log( - `Prepared experimental source at ${target}. Install its runtime dependencies with scripts disabled; see docs/plugins.md.`, -); diff --git a/src/cli.ts b/src/cli.ts index dab5249..5e88dc1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,7 +36,7 @@ import { logger } from './util/logger'; import { installBrokenPipeHandlers } from './util/shutdown'; import { Network } from './type/base'; import { status } from './cmd/status'; -import { registerPluginManagement } from './plugins/cli'; +import { coreCommandIndex, registerPluginManagement } from './plugins/cli'; const version = require('../package.json').version; const description = require('../package.json').description; @@ -493,7 +493,7 @@ function configureCommanderErrors(command: Command) { export async function runCli(argv: string[] = process.argv): Promise { installBrokenPipeHandlers(); try { - const commandIndex = argv.findIndex((arg, index) => index >= 2 && !arg.startsWith('-')); + const commandIndex = coreCommandIndex(program, argv); if (argv.slice(2, commandIndex === -1 ? argv.length : commandIndex).includes('--json')) logger.setJsonMode(true); // Determine the plugin boundary before the legacy global flag normalization. // Only core arguments may participate in --json promotion. diff --git a/src/plugins/broker.ts b/src/plugins/broker.ts new file mode 100644 index 0000000..8e508ee --- /dev/null +++ b/src/plugins/broker.ts @@ -0,0 +1,222 @@ +import http from 'http'; +import net from 'net'; + +const READ_METHODS = new Set([ + 'get_block', + 'get_block_by_number', + 'get_block_hash', + 'get_header', + 'get_header_by_number', + 'get_tip_header', + 'get_tip_block_number', + 'get_transaction', + 'get_live_cell', + 'get_cells', + 'get_cells_capacity', + 'get_transactions', + 'get_indexer_tip', + 'get_blockchain_info', + 'get_current_epoch', + 'get_epoch_by_number', + 'get_fee_rate_statistics', + 'estimate_fee_rate', +]); + +async function listen(server: net.Server, port: number): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + return (server.address() as net.AddressInfo).port; +} + +/** Only fixed read methods reach the configured CKB endpoint; credentials remain in the host. */ +export async function createReadRpcBroker(source: string): Promise<{ url: string; close: () => void }> { + const target = new URL(source); + if (!['http:', 'https:'].includes(target.protocol)) throw new Error('Sandbox devnet RPC requires HTTP(S)'); + const authorization = + target.username || target.password + ? `Basic ${Buffer.from(`${decodeURIComponent(target.username)}:${decodeURIComponent(target.password)}`).toString('base64')}` + : undefined; + target.username = ''; + target.password = ''; + const server = http.createServer(async (req, res) => { + try { + if (req.method !== 'POST' || req.url !== '/' || !req.headers['content-type']?.startsWith('application/json')) { + res.writeHead(405).end(); + return; + } + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + const bytes = Buffer.from(chunk); + size += bytes.length; + if (size > 256 * 1024) { + res.writeHead(413).end(); + return; + } + chunks.push(bytes); + } + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if ( + !body || + Array.isArray(body) || + body.jsonrpc !== '2.0' || + !READ_METHODS.has(body.method) || + !Array.isArray(body.params) + ) { + res + .writeHead(403, { 'Content-Type': 'application/json' }) + .end(JSON.stringify({ error: 'RPC method is not permitted' })); + return; + } + const response = await fetch(target, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(5000), + headers: { 'Content-Type': 'application/json', ...(authorization ? { Authorization: authorization } : {}) }, + body: JSON.stringify({ jsonrpc: '2.0', id: body.id ?? null, method: body.method, params: body.params }), + }); + const result: Buffer[] = []; + let responseSize = 0; + if (!response.body) throw new Error('Empty RPC response'); + const reader = response.body.getReader(); + for (;;) { + const { value: chunk, done } = await reader.read(); + if (done) break; + responseSize += chunk.length; + if (responseSize > 16 * 1024 * 1024) throw new Error('RPC response too large'); + result.push(Buffer.from(chunk)); + } + res.writeHead(response.status, { 'Content-Type': 'application/json' }).end(Buffer.concat(result)); + } catch { + if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Devnet read request failed' })); + } + }); + try { + const port = await listen(server, 0); + return { + url: `http://127.0.0.1:${port}`, + close: () => { + server.closeAllConnections(); + server.close(); + }, + }; + } catch (error) { + server.close(); + throw error; + } +} + +/** + * The plugin connects out to this private socket and serves HTTP on that connection. + * The host never follows a plugin-writable socket pathname to another local service. + */ +export async function createHttpBridge(socketPath: string, port: number): Promise<{ url: string; close: () => void }> { + const sockets = new Set(); + const idle: net.Socket[] = []; + const waiters: Array<(socket?: net.Socket) => void> = []; + const bridge = net.createServer((socket) => { + if (sockets.size >= 16) { + socket.destroy(); + return; + } + sockets.add(socket); + socket.on('error', () => socket.destroy()); + socket.once('close', () => { + sockets.delete(socket); + const i = idle.indexOf(socket); + if (i >= 0) idle.splice(i, 1); + }); + const waiter = waiters.shift(); + if (waiter) waiter(socket); + else idle.push(socket); + }); + const take = () => + new Promise((resolve, reject) => { + const ready = idle.shift(); + if (ready) { + resolve(ready); + return; + } + if (waiters.length >= 32) { + reject(new Error('Bridge is busy')); + return; + } + const done = (socket?: net.Socket) => { + clearTimeout(timer); + const i = waiters.indexOf(done); + if (i >= 0) waiters.splice(i, 1); + if (socket) resolve(socket); + else reject(new Error('Plugin HTTP service is unavailable')); + }; + const timer = setTimeout(() => done(), 3000); + waiters.push(done); + }); + const server = http.createServer(async (req, res) => { + try { + const socket = await take(); + const agent = new (class extends http.Agent { + createConnection() { + return socket; + } + })({ keepAlive: false }); + const upstream = http.request( + { + method: req.method, + path: req.url, + agent, + headers: { 'Content-Type': req.headers['content-type'] ?? '', Connection: 'close' }, + }, + (response) => { + res.writeHead(response.statusCode ?? 502, { + 'Content-Type': response.headers['content-type'] ?? 'application/octet-stream', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Content-Security-Policy': + "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox allow-scripts allow-same-origin", + }); + response.pipe(res); + }, + ); + upstream.setTimeout(10000, () => upstream.destroy()); + upstream.on('error', () => { + if (!res.headersSent) res.writeHead(502); + res.end('Plugin HTTP service is unavailable'); + }); + req.on('aborted', () => upstream.destroy()); + req.pipe(upstream); + } catch { + if (!res.headersSent) res.writeHead(503); + res.end('Plugin HTTP service is unavailable'); + } + }); + server.on('connect', (_req, socket) => socket.destroy()); + server.on('upgrade', (_req, socket) => socket.destroy()); + const close = () => { + for (const waiter of [...waiters]) waiter(); + for (const socket of sockets) socket.destroy(); + server.closeAllConnections(); + server.close(); + bridge.close(); + }; + try { + await new Promise((resolve, reject) => { + bridge.once('error', reject); + bridge.listen(socketPath, () => { + bridge.removeListener('error', reject); + resolve(); + }); + }); + return { url: `http://127.0.0.1:${await listen(server, port)}`, close }; + } catch (error) { + close(); + throw error; + } +} diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index 1cca892..5e3b85b 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -1,11 +1,32 @@ -import { Command } from 'commander'; +import { Command, InvalidArgumentError } from 'commander'; import { logger } from '../util/logger'; import { linkPlugin, listPlugins, unlinkPlugin } from './store'; import { runPlugin } from './run'; +export function coreCommandIndex(program: Command, argv: string[]): number { + for (let index = 2; index < argv.length; index++) { + const token = argv[index]; + if (!token.startsWith('-')) return index; + const option = program.options.find((candidate) => candidate.long === token || candidate.short === token); + if (option?.required && !token.includes('=')) index++; + } + return -1; +} + export function registerPluginManagement(program: Command): { prepare: (argv: string[]) => { argv: string[]; delegated: () => boolean }; } { + program.option( + '--plugin-port ', + 'Loopback web port for a sandboxed plugin (0 selects a free port)', + (value) => { + const port = Number(value); + if (!/^\d+$/.test(value) || !Number.isInteger(port) || port < 0 || port > 65535) + throw new InvalidArgumentError('plugin port must be an integer from 0 to 65535'); + return port; + }, + 7070, + ); const dynamic = new Set(); const reserved = () => new Set([ @@ -15,13 +36,30 @@ export function registerPluginManagement(program: Command): { .flatMap((command) => [command.name(), ...command.aliases()]), ]); const management = program.command('plugin').description('Link and run local tool plugins (experimental)'); - management + const link = management .command('link ') - .description('Explicitly trust and register a local plugin package; runs with your user permissions') - .action((directory: string) => { - const plugin = linkPlugin(directory, reserved()); + .description('Register a local plugin with explicit sandbox capability grants') + .option( + '--allow ', + 'Grant one declared capability (repeatable)', + (value, all: string[]) => [...all, value], + [], + ) + .option( + '--read ', + 'Grant an additional read-only path (repeatable)', + (value, all: string[]) => [...all, value], + [], + ) + .action((directory: string, options: { allow: string[]; read: string[] }) => { + const plugin = linkPlugin(directory, reserved(), options); logger.info(`Linked ${plugin.command} (${plugin.name}@${plugin.version})`); - logger.result({ command: 'plugin.link', plugin: plugin.command, directory: plugin.directory }); + logger.result({ + command: 'plugin.link', + plugin: plugin.command, + directory: plugin.directory, + permissions: plugin.permissions, + }); }); management .command('list') @@ -43,13 +81,17 @@ export function registerPluginManagement(program: Command): { return { prepare(argv) { + program.setOptionValueWithSource('pluginPort', 7070, 'default'); + // runCli is reusable: an earlier invocation cannot authorize a later link. + link.setOptionValueWithSource('allow', [], 'default'); + link.setOptionValueWithSource('read', [], 'default'); for (const command of dynamic) { const index = program.commands.indexOf(command); // Commander exposes a readonly view of its mutable registration array. if (index !== -1) (program.commands as Command[]).splice(index, 1); } dynamic.clear(); - const index = argv.findIndex((arg, i) => i >= 2 && !arg.startsWith('-')); + const index = coreCommandIndex(program, argv); const selected = index === -1 ? undefined : argv[index]; let delegated = false; // A broken registry must not break core commands, including help and repair commands. @@ -71,7 +113,7 @@ export function registerPluginManagement(program: Command): { const args = argv.slice(index + 1); if (args[0] === '--') args.shift(); delegated = true; - await runPlugin(plugin.command, args, argv.slice(2, index).includes('--json')); + await runPlugin(plugin.command, args, argv.slice(2, index).includes('--json'), program.opts().pluginPort); }); dynamic.add(command); if (plugin.command === selected) pluginSelected = true; diff --git a/src/plugins/context.ts b/src/plugins/context.ts index fde1f99..e1ce874 100644 --- a/src/plugins/context.ts +++ b/src/plugins/context.ts @@ -14,6 +14,7 @@ export interface PluginContext { output: 'json' | 'text'; cli: { node: string; entry: string | null }; plugin: { command: string; dataPath: string; cachePath: string }; + sandbox?: { backend: 'os'; permissions: PluginManifest['permissions']; http?: { socket: string; url: string } }; devnet: { rpcUrl: string; proxyUrl: string; @@ -71,6 +72,11 @@ export function createPluginContext(plugin: PluginManifest, json: boolean): { fi const cache = path.join(pluginHome(), 'cache', plugin.command); fs.mkdirSync(data, { recursive: true, mode: 0o700 }); fs.mkdirSync(cache, { recursive: true, mode: 0o700 }); + // A tool may leave symlinks in its storage. Never turn one into a new host grant. + for (const storage of [data, cache]) { + if (fs.lstatSync(storage).isSymbolicLink() || !fs.lstatSync(storage).isDirectory()) + throw new Error('Plugin storage must be a real directory, not a symlink'); + } const context: PluginContext = { apiVersion: 1, offckbVersion: require('../../package.json').version, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index ec920f0..5726df1 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -2,6 +2,9 @@ import fs from 'fs'; import path from 'path'; import semver from 'semver'; +export const PLUGIN_PERMISSIONS = ['devnet-read', 'project-deployment', 'serve'] as const; +export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number]; + export interface PluginManifest { directory: string; name: string; @@ -10,6 +13,7 @@ export interface PluginManifest { description: string; entry: string; node?: string; + permissions: PluginPermission[]; } export function isPluginCommand(value: unknown): value is string { @@ -48,6 +52,13 @@ export function readPluginManifest(directory: string): PluginManifest { if (node !== undefined && (typeof node !== 'string' || !semver.validRange(node))) { throw new Error('Invalid plugin engines.node range'); } + const permissions = declaration.permissions ?? []; + if ( + !Array.isArray(permissions) || + permissions.some((permission) => !PLUGIN_PERMISSIONS.includes(permission)) || + new Set(permissions).size !== permissions.length + ) + throw new Error(`offckb.permissions must contain only: ${PLUGIN_PERMISSIONS.join(', ')}`); return { directory: root, name: pkg.name, @@ -55,6 +66,7 @@ export function readPluginManifest(directory: string): PluginManifest { command: declaration.command, entry, description: typeof declaration.description === 'string' ? declaration.description : pkg.name, + permissions: [...permissions].sort(), ...(typeof node === 'string' ? { node } : {}), }; } diff --git a/src/plugins/run.ts b/src/plugins/run.ts index 5acd5c8..ccbfc3f 100644 --- a/src/plugins/run.ts +++ b/src/plugins/run.ts @@ -8,6 +8,7 @@ import { import { enterGracefulShutdown } from '../util/shutdown'; import { createPluginContext } from './context'; import { acquirePlugin } from './store'; +import { assertSandboxSupported, preparePluginSandbox } from './sandbox'; function signalChild(child: ChildProcess, signal: NodeJS.Signals): void { if (!child.pid) return; @@ -28,9 +29,11 @@ function signalChild(child: ChildProcess, signal: NodeJS.Signals): void { } } -export async function runPlugin(command: string, args: string[], json: boolean): Promise { +export async function runPlugin(command: string, args: string[], json: boolean, port = 7070): Promise { + assertSandboxSupported(); const lease = acquirePlugin(command); let context: ReturnType | undefined; + let sandbox: Awaited> | undefined; const guard = bindChildrenToProcessLifetime(); const listeners: Array<[LifetimeSignal, () => void]> = []; let child: ChildProcess | undefined; @@ -43,13 +46,11 @@ export async function runPlugin(command: string, args: string[], json: boolean): // Help is delegated without reading settings, exporting scripts or requiring a chain. const helpOnly = args.length === 1 && (args[0] === '--help' || args[0] === '-h'); if (!helpOnly) context = createPluginContext(lease.plugin, json); - const env = { ...process.env }; - delete env.OFFCKB_PLUGIN_CONTEXT; - if (context) env.OFFCKB_PLUGIN_CONTEXT = context.file; - child = spawn(process.execPath, [lease.plugin.entry, ...args], { + sandbox = await preparePluginSandbox(lease.plugin, context?.file, lease.readPaths, args, port); + child = spawn(sandbox.command, sandbox.args, { stdio: 'inherit', cwd: process.cwd(), - env, + env: sandbox.env, shell: false, detached: process.platform !== 'win32', }); @@ -88,9 +89,13 @@ export async function runPlugin(command: string, args: string[], json: boolean): guard.stopAll(); guard.dispose(); try { - context?.dispose(); + sandbox?.dispose(); } finally { - lease.release(); + try { + context?.dispose(); + } finally { + lease.release(); + } } } } diff --git a/src/plugins/sandbox.ts b/src/plugins/sandbox.ts new file mode 100644 index 0000000..255f497 --- /dev/null +++ b/src/plugins/sandbox.ts @@ -0,0 +1,163 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import semver from 'semver'; +import { createRequire } from 'module'; +import { PluginManifest } from './manifest'; +import { PluginContext } from './context'; +import { createHttpBridge, createReadRpcBroker } from './broker'; + +export function assertSandboxSupported(): void { + if (!['darwin', 'linux'].includes(process.platform)) + throw new Error('Plugin sandbox is supported on macOS/Linux only; refusing unsandboxed execution'); + if (!semver.satisfies(process.versions.node, '^22.21.0 || >=24.5.0')) + throw new Error('Sandboxed plugins require Node 22.21+ or 24.5+; core offckb commands still support Node 20'); +} + +function runtimeReadPaths(plugin: PluginManifest): string[] { + const paths = [ + '/usr', + '/bin', + '/lib', + '/lib64', + '/System', + '/dev', + '/etc/ssl', + '/etc/hosts', + '/etc/ld.so.cache', + '/opt/homebrew/Cellar', + path.dirname(fs.realpathSync(process.execPath)), + plugin.directory, + ]; + for (let directory = plugin.directory; directory !== path.dirname(directory); directory = path.dirname(directory)) { + const candidate = path.join(directory, 'node_modules'); + if (fs.existsSync(candidate)) paths.push(fs.realpathSync(candidate)); + if (path.basename(directory) === 'node_modules') paths.push(fs.realpathSync(directory)); + } + return paths.filter((file) => fs.existsSync(file)); +} + +export async function preparePluginSandbox( + plugin: PluginManifest, + contextFile: string | undefined, + readPaths: string[], + args: string[], + port = 7070, +): Promise<{ command: string; args: string[]; env: NodeJS.ProcessEnv; dispose: () => void }> { + assertSandboxSupported(); + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ocp-'))); + const cleanup: Array<() => void> = []; + const dispose = () => { + for (const close of cleanup.reverse()) close(); + fs.rmSync(root, { recursive: true, force: true }); + }; + try { + // Seatbelt evaluates literal paths. Do not pass /var aliases for /private/var grants. + if (contextFile) contextFile = fs.realpathSync(contextFile); + const context = contextFile ? (JSON.parse(fs.readFileSync(contextFile, 'utf8')) as PluginContext) : undefined; + const read = [...runtimeReadPaths(plugin), ...readPaths, root]; + const write = [root]; + const allowedDomains: string[] = []; + const sockets: string[] = []; + if (context) { + context.plugin.dataPath = fs.realpathSync(context.plugin.dataPath); + context.plugin.cachePath = fs.realpathSync(context.plugin.cachePath); + if (context.devnet.systemScriptsFile) + context.devnet.systemScriptsFile = fs.realpathSync(context.devnet.systemScriptsFile); + const sourceRpc = context.devnet.rpcUrl; + context.devnet.rpcUrl = ''; + context.devnet.proxyUrl = ''; + context.sandbox = { backend: 'os', permissions: plugin.permissions }; + read.push(path.dirname(contextFile!), context.plugin.dataPath, context.plugin.cachePath); + write.push(context.plugin.dataPath, context.plugin.cachePath); + if (plugin.permissions.includes('project-deployment')) { + const project = fs.realpathSync(context.cwd); + const deployment = path.join(project, 'deployment'); + if (fs.existsSync(deployment)) { + const real = fs.realpathSync(deployment); + if (!real.startsWith(project + path.sep)) + throw new Error('Project deployment path escapes the project; grant its real path explicitly'); + read.push(real); + } + } + if (plugin.permissions.includes('devnet-read')) { + const rpc = await createReadRpcBroker(sourceRpc); + cleanup.push(rpc.close); + context.devnet.rpcUrl = rpc.url; + context.devnet.proxyUrl = rpc.url; + allowedDomains.push(new URL(rpc.url).host); + } + if (plugin.permissions.includes('serve')) { + const socket = path.join(root, 'http.sock'); + const http = await createHttpBridge(socket, port); + cleanup.push(http.close); + context.sandbox.http = { socket, url: http.url }; + sockets.push(socket); + } + fs.writeFileSync(contextFile!, JSON.stringify(context), { mode: 0o600 }); + } + const literal = (file: string) => ({ path: fs.realpathSync(file), literal: true }); + const policy = path.join(root, 'policy.json'); + fs.writeFileSync( + policy, + JSON.stringify({ + filesystem: { + denyRead: ['/', '/usr/local/etc', '/usr/local/var'], + allowRead: [...new Set(read)].map(literal), + allowWrite: write.map(literal), + denyWrite: ['/tmp/claude', '/private/tmp/claude'], + }, + network: { + allowedDomains, + deniedDomains: [], + strictAllowlist: true, + allowUnixSockets: sockets, + allowLocalBinding: false, + }, + allowAppleEvents: false, + enableWeakerNestedSandbox: false, + enableWeakerNetworkIsolation: false, + allowNestedUserNamespaces: false, + }), + { mode: 0o600 }, + ); + const bootstrap = path.join(root, 'entry.cjs'); + fs.writeFileSync( + bootstrap, + // Node snapshots proxy configuration before loading its entry. Start the actual tool only + // after normalizing the sandbox proxy; the tool and all descendants keep the OS restrictions. + `process.env.NO_PROXY='';process.env.no_proxy='';for(const key of ['HTTP_PROXY','HTTPS_PROXY','http_proxy','https_proxy']){if(process.env[key])process.env[key]=process.env[key].replace('localhost:','127.0.0.1:');}const result=require('node:child_process').spawnSync(process.execPath,process.argv.slice(2),{cwd:${JSON.stringify(plugin.directory)},env:process.env,stdio:'inherit'});if(result.error)console.error(result.error);process.exitCode=result.status??(result.signal?128+require('node:os').constants.signals[result.signal]:1);`, + { mode: 0o600 }, + ); + // Retain only basic presentation/tool lookup variables, never caller credentials or preload hooks. + const env: NodeJS.ProcessEnv = { + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin`, + LANG: 'en_US.UTF-8', + TMPDIR: root, + CLAUDE_CODE_TMPDIR: root, + NODE_USE_ENV_PROXY: '1', + ...(contextFile ? { OFFCKB_PLUGIN_CONTEXT: contextFile } : {}), + }; + const externalRequire = createRequire(__filename); + let runtime: string; + try { + runtime = path.join( + path.dirname(externalRequire.resolve('@anthropic-ai/sandbox-runtime/package.json')), + 'dist/cli.js', + ); + } catch { + throw new Error( + 'Sandbox runtime is unavailable; reinstall offckb with optional dependencies on a supported Node version. Refusing unsandboxed execution.', + ); + } + return { + command: process.execPath, + args: [runtime, '--settings', policy, '--', process.execPath, bootstrap, plugin.entry, ...args], + env, + dispose, + }; + } catch (error) { + dispose(); + throw error; + } +} diff --git a/src/plugins/store.ts b/src/plugins/store.ts index 4aff0ca..f91f5b8 100644 --- a/src/plugins/store.ts +++ b/src/plugins/store.ts @@ -2,11 +2,24 @@ import fs from 'fs'; import path from 'path'; import { randomUUID } from 'crypto'; import { dataPath } from '../cfg/setting'; -import { assertPluginCompatible, isPluginCommand, PluginManifest, readJsonFile, readPluginManifest } from './manifest'; +import { + assertPluginCompatible, + isPluginCommand, + PLUGIN_PERMISSIONS, + PluginManifest, + PluginPermission, + readJsonFile, + readPluginManifest, +} from './manifest'; +interface Registration { + directory: string; + permissions: PluginPermission[]; + readPaths: string[]; +} interface Registry { - apiVersion: 1; - plugins: Record; + apiVersion: 2; + plugins: Record; } export interface PluginInfo { @@ -17,6 +30,8 @@ export interface PluginInfo { version?: string; description?: string; error?: string; + permissions?: PluginPermission[]; + readPaths?: string[]; } export function pluginHome(): string { @@ -32,18 +47,43 @@ function readRegistry(): Registry { try { value = readJsonFile(registryPath()); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { apiVersion: 1, plugins: {} }; + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { apiVersion: 2, plugins: {} }; throw new Error(`Cannot read plugin registry ${registryPath()}: ${(error as Error).message}`); } + const legacy = value as { apiVersion?: number; plugins?: Record }; + // Legacy links retain zero grants. Permission changes require an explicit relink. + if ( + legacy?.apiVersion === 1 && + legacy.plugins && + typeof legacy.plugins === 'object' && + !Array.isArray(legacy.plugins) + ) { + value = { + apiVersion: 2, + plugins: Object.fromEntries( + Object.entries(legacy.plugins).map(([command, directory]) => [ + command, + { directory, permissions: [], readPaths: [] }, + ]), + ), + }; + } const registry = value as Registry; if ( - registry?.apiVersion !== 1 || + registry?.apiVersion !== 2 || !registry.plugins || typeof registry.plugins !== 'object' || Array.isArray(registry.plugins) || Object.entries(registry.plugins).some( - ([command, directory]) => - !isPluginCommand(command) || typeof directory !== 'string' || !path.isAbsolute(directory), + ([command, entry]) => + !isPluginCommand(command) || + !entry || + typeof entry.directory !== 'string' || + !path.isAbsolute(entry.directory) || + !Array.isArray(entry.permissions) || + entry.permissions.some((permission) => !PLUGIN_PERMISSIONS.includes(permission)) || + !Array.isArray(entry.readPaths) || + entry.readPaths.some((file) => typeof file !== 'string' || !path.isAbsolute(file)), ) ) throw new Error(`Invalid plugin registry: ${registryPath()}`); @@ -96,17 +136,25 @@ function assertNotRunning(command: string): void { } export function listPlugins(reserved: ReadonlySet): PluginInfo[] { - return Object.entries(readRegistry().plugins).map(([command, directory]) => { + return Object.entries(readRegistry().plugins).map(([command, { directory, permissions, readPaths }]) => { const base = { command, directory }; if (reserved.has(command)) return { ...base, state: 'shadowed', error: 'A core command has priority' }; let plugin: PluginManifest; try { plugin = readPluginManifest(directory); if (plugin.command !== command) throw new Error('Plugin command changed; unlink and link the package again'); + assertPermissions(plugin, permissions); } catch (error) { return { ...base, state: 'invalid', error: (error as Error).message }; } - const info = { ...base, name: plugin.name, version: plugin.version, description: plugin.description }; + const info = { + ...base, + name: plugin.name, + version: plugin.version, + description: plugin.description, + permissions, + readPaths, + }; try { assertPluginCompatible(plugin); return { ...info, state: 'available' }; @@ -116,19 +164,34 @@ export function listPlugins(reserved: ReadonlySet): PluginInfo[] { }); } -export function linkPlugin(directory: string, reserved: ReadonlySet): PluginManifest { +function assertPermissions(plugin: PluginManifest, granted: readonly string[]): void { + if (plugin.permissions.some((permission) => !granted.includes(permission))) + throw new Error( + `${plugin.command} requires explicit grants: ${plugin.permissions.map((permission) => `--allow ${permission}`).join(' ')}. Relink to approve changed permissions.`, + ); +} + +export function linkPlugin( + directory: string, + reserved: ReadonlySet, + options: { allow?: string[]; read?: string[] } = {}, +): PluginManifest { return withRegistryLock(() => { const plugin = readPluginManifest(directory); assertPluginCompatible(plugin); + assertPermissions(plugin, options.allow ?? []); + if (options.allow?.some((permission) => !plugin.permissions.includes(permission as PluginPermission))) + throw new Error('Only permissions declared by this plugin may be granted'); + const readPaths = (options.read ?? []).map((file) => fs.realpathSync(file)); if (reserved.has(plugin.command)) throw new Error(`Plugin command ${plugin.command} conflicts with a core command`); const registry = readRegistry(); const existing = Object.prototype.hasOwnProperty.call(registry.plugins, plugin.command) ? registry.plugins[plugin.command] : undefined; - if (existing && existing !== plugin.directory) + if (existing && existing.directory !== plugin.directory) throw new Error(`Plugin command ${plugin.command} is already linked`); assertNotRunning(plugin.command); - registry.plugins[plugin.command] = plugin.directory; + registry.plugins[plugin.command] = { directory: plugin.directory, permissions: plugin.permissions, readPaths }; writeRegistry(registry); return plugin; }); @@ -146,19 +209,20 @@ export function unlinkPlugin(command: string): void { } /** A foreground lease prevents unlink/relink; it never authorizes signaling a saved PID. */ -export function acquirePlugin(command: string): { plugin: PluginManifest; release: () => void } { +export function acquirePlugin(command: string): { plugin: PluginManifest; readPaths: string[]; release: () => void } { return withRegistryLock(() => { const registry = readRegistry(); - const directory = Object.prototype.hasOwnProperty.call(registry.plugins, command) + const entry = Object.prototype.hasOwnProperty.call(registry.plugins, command) ? registry.plugins[command] : undefined; - if (!directory) throw new Error(`Plugin ${command} is no longer linked`); - const plugin = readPluginManifest(directory); + if (!entry) throw new Error(`Plugin ${command} is no longer linked`); + const plugin = readPluginManifest(entry.directory); if (plugin.command !== command) throw new Error('Plugin command changed; unlink and link the package again'); assertPluginCompatible(plugin); + assertPermissions(plugin, entry.permissions); assertNotRunning(command); const file = leasePath(command); fs.writeFileSync(file, JSON.stringify({ pid: process.pid }), { mode: 0o600, flag: 'wx' }); - return { plugin, release: () => fs.rmSync(file, { force: true }) }; + return { plugin, readPaths: entry.readPaths, release: () => fs.rmSync(file, { force: true }) }; }); } diff --git a/tests/cli-errors.test.ts b/tests/cli-errors.test.ts index 46a1037..83f7fe2 100644 --- a/tests/cli-errors.test.ts +++ b/tests/cli-errors.test.ts @@ -161,7 +161,7 @@ describe('CLI error output', () => { mockListPlugins.mockReturnValue([{ command: 'probe', state: 'available', directory: '/unused' }]); const { runCli } = loadCli(); await runCli(['node', 'offckb', '--json', 'probe', '--', '--json', '--json', '--path', 'a b']); - expect(mockRunPlugin).toHaveBeenCalledWith('probe', ['--json', '--json', '--path', 'a b'], true); + expect(mockRunPlugin).toHaveBeenCalledWith('probe', ['--json', '--json', '--path', 'a b'], true, 7070); const { logger } = require('../src/util/logger'); expect(logger.result).not.toHaveBeenCalled(); expect(mockFailure).not.toHaveBeenCalled(); @@ -171,7 +171,7 @@ describe('CLI error output', () => { mockListPlugins.mockReturnValue([{ command: 'probe', state: 'available', directory: '/unused' }]); const { runCli } = loadCli(); await runCli(['node', 'offckb', 'probe', '--json']); - expect(mockRunPlugin).toHaveBeenCalledWith('probe', ['--json'], false); + expect(mockRunPlugin).toHaveBeenCalledWith('probe', ['--json'], false, 7070); expect(jsonMode.value).toBe(false); }); @@ -184,8 +184,8 @@ describe('CLI error output', () => { await runCli(['node', 'offckb', 'probe', 'first']); await runCli(['node', 'offckb', 'probe', 'second']); expect(mockRunPlugin.mock.calls).toEqual([ - ['probe', ['first'], false], - ['probe', ['second'], false], + ['probe', ['first'], false, 7070], + ['probe', ['second'], false, 7070], ]); let help = ''; @@ -204,4 +204,13 @@ describe('CLI error output', () => { expect(mockRunPlugin).toHaveBeenCalledTimes(2); expect(mockFailure).not.toHaveBeenCalled(); }); + + it('parses a valued core option before the plugin boundary and resets it between invocations', async () => { + mockListPlugins.mockReturnValue([{ command: 'probe', state: 'available', directory: '/unused' }]); + const { runCli } = loadCli(); + await runCli(['node', 'offckb', '--plugin-port', '0', '--json', 'probe', '--plugin-port', '123']); + expect(mockRunPlugin).toHaveBeenLastCalledWith('probe', ['--plugin-port', '123'], true, 0); + await runCli(['node', 'offckb', 'probe']); + expect(mockRunPlugin).toHaveBeenLastCalledWith('probe', [], false, 7070); + }); }); diff --git a/tests/plugin-broker.test.ts b/tests/plugin-broker.test.ts new file mode 100644 index 0000000..c2cf649 --- /dev/null +++ b/tests/plugin-broker.test.ts @@ -0,0 +1,62 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import http from 'http'; +import net from 'net'; +import { createHttpBridge, createReadRpcBroker } from '../src/plugins/broker'; + +it('only forwards read RPC calls and keeps upstream credentials inside the host', async () => { + const seen: Array<{ authorization?: string; body: string }> = []; + const upstream = http.createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) body += chunk; + seen.push({ authorization: req.headers.authorization, body }); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '0x0' })); + }); + await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve)); + const port = (upstream.address() as net.AddressInfo).port; + const broker = await createReadRpcBroker(`http://user:secret@127.0.0.1:${port}`); + try { + expect(broker.url).not.toContain('secret'); + const call = (method: string) => + fetch(broker.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: [] }), + }); + expect((await call('get_tip_block_number')).status).toBe(200); + expect((await call('send_transaction')).status).toBe(403); + expect((await fetch(broker.url)).status).toBe(405); + expect(seen).toHaveLength(1); + expect(seen[0].authorization).toBe(`Basic ${Buffer.from('user:secret').toString('base64')}`); + } finally { + broker.close(); + upstream.closeAllConnections(); + upstream.close(); + } +}); + +const posixIt = process.platform === 'win32' ? it.skip : it; +posixIt('forwards HTTP over a plugin-initiated connection and enforces host response headers', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ocb-')); + const socketPath = path.join(root, 'http.sock'); + const bridge = await createHttpBridge(socketPath, 0); + const app = http.createServer((_req, res) => { + res.setHeader('Content-Security-Policy', 'default-src *'); + res.end('sandboxed page'); + }); + const connection = net.connect(socketPath); + connection.on('error', () => {}); + connection.once('connect', () => app.emit('connection', connection)); + try { + const response = await fetch(bridge.url); + expect(await response.text()).toBe('sandboxed page'); + expect(response.headers.get('Content-Security-Policy')).toContain("connect-src 'self'"); + expect(response.headers.get('Content-Security-Policy')).not.toContain('default-src *'); + } finally { + connection.destroy(); + bridge.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/plugin-run.test.ts b/tests/plugin-run.test.ts index 3de58fd..fa19d05 100644 --- a/tests/plugin-run.test.ts +++ b/tests/plugin-run.test.ts @@ -10,6 +10,20 @@ jest.mock('../src/plugins/context', () => ({ createPluginContext: (...args: unknown[]) => mockCreateContext(...args), })); jest.mock('../src/util/shutdown', () => ({ enterGracefulShutdown: jest.fn() })); +jest.mock('../src/plugins/sandbox', () => ({ + assertSandboxSupported: jest.fn(), + preparePluginSandbox: async ( + plugin: { entry: string }, + file: string | undefined, + _read: string[], + args: string[], + ) => { + const env = { ...process.env }; + delete env.OFFCKB_PLUGIN_CONTEXT; + if (file) env.OFFCKB_PLUGIN_CONTEXT = file; + return { command: process.execPath, args: [plugin.entry, ...args], env, dispose: jest.fn() }; + }, +})); let root: string; let originalHome: string | undefined; @@ -122,6 +136,7 @@ posixIt.each(['success', 'throw', 'error event'])( const before = process.listeners('SIGTERM'); const running = runPlugin('runner', [], false); const completion = fallback === 'error event' ? expect(running).rejects.toBe(failure) : running; + await Promise.resolve(); const handler = process.listeners('SIGTERM').find((listener) => !before.includes(listener))!; expect(() => handler('SIGTERM')).not.toThrow(); expect(dispose).not.toHaveBeenCalled(); // Cleanup still waits for termination or a child error. diff --git a/tests/plugin-sandbox.test.ts b/tests/plugin-sandbox.test.ts new file mode 100644 index 0000000..2e95863 --- /dev/null +++ b/tests/plugin-sandbox.test.ts @@ -0,0 +1,22 @@ +import { assertSandboxSupported } from '../src/plugins/sandbox'; + +it('refuses unsupported platforms and Node versions instead of starting a weaker runner', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + const node = Object.getOwnPropertyDescriptor(process.versions, 'node')!; + try { + Object.defineProperty(process, 'platform', { value: 'win32' }); + expect(assertSandboxSupported).toThrow(/refusing unsandboxed/); + Object.defineProperty(process, 'platform', { value: 'linux' }); + for (const version of ['20.19.0', '22.20.0', '24.4.0']) { + Object.defineProperty(process.versions, 'node', { value: version }); + expect(assertSandboxSupported).toThrow(/require Node/); + } + for (const version of ['22.21.0', '24.5.0']) { + Object.defineProperty(process.versions, 'node', { value: version }); + expect(assertSandboxSupported).not.toThrow(); + } + } finally { + Object.defineProperty(process, 'platform', platform); + Object.defineProperty(process.versions, 'node', node); + } +}); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index bfdcd18..e9c5f11 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -119,6 +119,36 @@ it('preserves a corrupt registry instead of overwriting it', () => { expect(fs.readFileSync(file, 'utf8')).toBe('{broken'); }); +it('requires explicit capability grants and refuses permission changes until relinked', () => { + const root = makePlugin('probe', { offckb: { permissions: ['devnet-read'] } }); + expect(() => linkPlugin(root, new Set())).toThrow('--allow devnet-read'); + expect(() => linkPlugin(root, new Set(), { allow: ['devnet-read', 'serve'] })).toThrow('Only permissions declared'); + linkPlugin(root, new Set(), { allow: ['devnet-read'], read: [directory] }); + expect(listPlugins(new Set())[0]).toMatchObject({ + state: 'available', + permissions: ['devnet-read'], + readPaths: [fs.realpathSync(directory)], + }); + const manifest = path.join(root, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')); + pkg.offckb.permissions.push('serve'); + fs.writeFileSync(manifest, JSON.stringify(pkg)); + expect(listPlugins(new Set())[0].state).toBe('invalid'); + expect(() => acquirePlugin('probe')).toThrow('explicit grants'); + linkPlugin(root, new Set(), { allow: ['devnet-read', 'serve'] }); + expect(listPlugins(new Set())[0].state).toBe('available'); +}); + +it('reads legacy links without silently granting capabilities or rewriting the registry', () => { + const root = makePlugin(); + fs.mkdirSync(process.env.OFFCKB_PLUGIN_HOME!); + const file = path.join(process.env.OFFCKB_PLUGIN_HOME!, 'registry.json'); + const content = JSON.stringify({ apiVersion: 1, plugins: { probe: fs.realpathSync(root) } }); + fs.writeFileSync(file, content); + expect(listPlugins(new Set())[0]).toMatchObject({ state: 'available', permissions: [], readPaths: [] }); + expect(fs.readFileSync(file, 'utf8')).toBe(content); +}); + it('exports current chain metadata without keys and removes per-run files', () => { fs.mkdirSync(mockSettings.devnet.configPath); fs.writeFileSync(path.join(mockSettings.devnet.configPath, 'ckb.toml'), ''); @@ -151,6 +181,16 @@ it('exports current chain metadata without keys and removes per-run files', () = expect(fs.existsSync(value.devnet.systemScriptsFile)).toBe(false); }); +it('refuses persistent storage symlinks instead of granting their targets on the next invocation', () => { + if (process.platform === 'win32') return; + const plugin = readPluginManifest(makePlugin()); + const home = process.env.OFFCKB_PLUGIN_HOME!; + fs.mkdirSync(path.join(home, 'data'), { recursive: true }); + fs.symlinkSync(directory, path.join(home, 'data', plugin.command)); + expect(() => createPluginContext(plugin, false)).toThrow(/storage must be a real directory/); + expect(fs.readdirSync(path.join(home, 'tmp'))).toEqual([]); +}); + it('represents an uninitialized chain as unknown and does not run CKB', () => { const run = createPluginContext(readPluginManifest(makePlugin()), false); expect(JSON.parse(fs.readFileSync(run.file, 'utf8')).devnet).toMatchObject({ @@ -213,7 +253,12 @@ it('lets Commander parse only the core prefix and keeps plugin arguments opaque' ]); await program.parseAsync(invocation.argv); expect(invocation.delegated()).toBe(true); - expect(mockRunPlugin).toHaveBeenCalledWith('probe', ['--json', '--json', '--project', 'a b', '--', 'tail'], true); + expect(mockRunPlugin).toHaveBeenCalledWith( + 'probe', + ['--json', '--json', '--project', 'a b', '--', 'tail'], + true, + 7070, + ); }); it('does not interpret a plugin --json as the core output format and delegates help', async () => { @@ -223,7 +268,7 @@ it('does not interpret a plugin --json as the core output format and delegates h for (const args of [['--json'], ['--help']]) { const invocation = plugins.prepare(['node', 'offckb', 'probe', ...args]); await program.parseAsync(invocation.argv); - expect(mockRunPlugin).toHaveBeenLastCalledWith('probe', args, false); + expect(mockRunPlugin).toHaveBeenLastCalledWith('probe', args, false, 7070); } }); @@ -237,3 +282,15 @@ it('keeps core commands usable with a corrupt registry', async () => { await program.parseAsync(plugins.prepare(['node', 'offckb', 'node']).argv); expect(action).toHaveBeenCalled(); }); + +it('does not carry capability grants into a later link in the same CLI process', async () => { + const first = makePlugin('first', { offckb: { permissions: ['devnet-read'] } }); + const second = makePlugin('second', { offckb: { permissions: ['devnet-read'] } }); + const program = new Command(); + const plugins = registerPluginManagement(program); + await program.parseAsync(plugins.prepare(['node', 'offckb', 'plugin', 'link', first, '--allow', 'devnet-read']).argv); + await expect(program.parseAsync(plugins.prepare(['node', 'offckb', 'plugin', 'link', second]).argv)).rejects.toThrow( + /explicit grants/, + ); + expect(listPlugins(new Set()).map((plugin) => plugin.command)).toEqual(['first']); +}); From c2c949eb861927c4d26007241c5fe54b74654c9c Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 30 Oct 2026 14:25:02 +0800 Subject: [PATCH 05/10] fix: align sandbox dependencies and current Explorer reads --- docs/plugins.md | 4 +++- packages/plugin-explorer/README.md | 2 +- pnpm-lock.yaml | 6 ++++++ scripts/plugin-smoke.mjs | 2 +- src/plugins/broker.ts | 20 ++++++++++++++------ tests/plugin-broker.test.ts | 14 +++++++++++++- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 070a89b..d609440 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -89,7 +89,7 @@ offckb --json my-tool -- --json -- literal In the second invocation `--json` belongs to the plugin. The host consumes only the immediately following `--` separator and preserves the rest. `--plugin-port` selects the host HTTP listener (default 7070, `0` selects a free port); only `serve` uses it. The plugin owns stdout after delegation, including its one JSON result. Diagnostics go to stderr; the parent propagates exit status without appending a fallback result. A malicious plugin can still violate this output convention. -The RPC gateway only accepts bounded JSON-RPC 2.0 POST requests with array params and known read methods. Redirects, batches, write/admin methods and oversized requests/responses are rejected. Browser-facing HTTP responses receive host-controlled CSP, no-referrer and nosniff headers; the bridge provides no CONNECT or WebSocket tunnel. It cannot protect information the user explicitly enters into a plugin UI or eliminate every browser-side risk. +The RPC gateway only accepts bounded JSON-RPC 2.0 POST requests with array params and known read methods. Redirects, empty/over-200-call or mixed-permission batches, write/admin methods and oversized requests/responses are rejected. Browser-facing HTTP responses receive host-controlled CSP, no-referrer and nosniff headers; the bridge provides no CONNECT or WebSocket tunnel. It cannot protect information the user explicitly enters into a plugin UI or eliminate every browser-side risk. ## Explorer through a public upstream CLI @@ -125,3 +125,5 @@ node scripts/plugin-smoke.mjs /path/to/ckb The native security test exercises the production bundle with canary secrets: forbidden reads/writes, symlink/child escape, direct network access, credential inheritance and write RPC rejection, plus positive cache/read-RPC access. CI runs it on native macOS/Linux with Node 24. The live test additionally checks Explorer page/API, ready output, SIGTERM, node survival, chain reset/restart and unknown-fork rejection. Tests isolate settings and override `os.homedir()` only in their worker, without changing HOME. POSIX foreground groups receive SIGINT/SIGTERM/SIGHUP, a three-second limit and final owned-process cleanup. No saved PID is signalled. Windows plugin execution is refused. Crash/SIGKILL can leave registry/run locks or temporary files: confirm the tool has stopped before manual cleanup. Automatic crash recovery, concurrent instances, resource quotas, background services and arbitrary network grants are deferred. A second-tool `ckb-tui` adapter still needs native TTY and sandbox network adaptation; its existing core `status` command is unchanged. + +The pinned sandbox runtime still depends on Zod 3, covered by [GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5) (collection-validation memory exhaustion). In this integration Zod validates only host-generated policy before plugin startup, not network payloads or plugin-supplied schemas; this limits the exposed path but does not make the dependency patched. Do not expose runtime settings parsing as an untrusted API. Track an upstream-compatible fix rather than forcing Zod 4 across its declared API. Resource exhaustion remains outside the sandbox guarantee. diff --git a/packages/plugin-explorer/README.md b/packages/plugin-explorer/README.md index 7272c6a..9a506aa 100644 --- a/packages/plugin-explorer/README.md +++ b/packages/plugin-explorer/README.md @@ -20,7 +20,7 @@ To reproduce pre-release integration tests, use the upstream PR commit as a norm ```sh git clone https://github.com/RetricSu/ckb-devnet-explorer.git .prototype/explorer-public-cli -git -C .prototype/explorer-public-cli checkout fecb659 +git -C .prototype/explorer-public-cli checkout a6c65b2 npm pack ./.prototype/explorer-public-cli --ignore-scripts --pack-destination .prototype npm install --prefix packages/plugin-explorer --no-save --package-lock=false \ --omit=dev --ignore-scripts ./.prototype/ckb-devnet-explorer-0.3.0.tgz diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 770d8cd..d45df32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,9 @@ importers: packages: + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} @@ -2943,6 +2946,9 @@ packages: snapshots: + zod@3.25.76: + optional: true + '@adraffy/ens-normalize@1.11.1': {} '@anthropic-ai/sandbox-runtime@0.0.81': diff --git a/scripts/plugin-smoke.mjs b/scripts/plugin-smoke.mjs index 648c049..9e88f6b 100644 --- a/scripts/plugin-smoke.mjs +++ b/scripts/plugin-smoke.mjs @@ -267,7 +267,7 @@ try { assert.match(info.rpcUrl, /^http:\/\/127\.0\.0\.1:/); assert.notEqual(info.rpcUrl, rpcUrl); assert.deepEqual(info.projects, [deployedProject]); - const overview = await fetch(`${ready.url}/api/overview`); + const overview = await fetch(`${ready.url}/api/home`); assert.equal(overview.status, 200); const summary = await overview.json(); assert.equal(summary.knownScripts.project, 1); diff --git a/src/plugins/broker.ts b/src/plugins/broker.ts index 8e508ee..2b47abd 100644 --- a/src/plugins/broker.ts +++ b/src/plugins/broker.ts @@ -20,6 +20,9 @@ const READ_METHODS = new Set([ 'get_epoch_by_number', 'get_fee_rate_statistics', 'estimate_fee_rate', + 'get_block_economic_state', + 'tx_pool_info', + 'get_raw_tx_pool', ]); async function listen(server: net.Server, port: number): Promise { @@ -61,12 +64,13 @@ export async function createReadRpcBroker(source: string): Promise<{ url: string chunks.push(bytes); } const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const calls = Array.isArray(body) ? body : [body]; if ( - !body || - Array.isArray(body) || - body.jsonrpc !== '2.0' || - !READ_METHODS.has(body.method) || - !Array.isArray(body.params) + calls.length === 0 || + calls.length > 200 || + calls.some( + (call) => !call || call.jsonrpc !== '2.0' || !READ_METHODS.has(call.method) || !Array.isArray(call.params), + ) ) { res .writeHead(403, { 'Content-Type': 'application/json' }) @@ -78,7 +82,11 @@ export async function createReadRpcBroker(source: string): Promise<{ url: string redirect: 'error', signal: AbortSignal.timeout(5000), headers: { 'Content-Type': 'application/json', ...(authorization ? { Authorization: authorization } : {}) }, - body: JSON.stringify({ jsonrpc: '2.0', id: body.id ?? null, method: body.method, params: body.params }), + body: JSON.stringify( + Array.isArray(body) + ? calls.map((call) => ({ jsonrpc: '2.0', id: call.id ?? null, method: call.method, params: call.params })) + : { jsonrpc: '2.0', id: body.id ?? null, method: body.method, params: body.params }, + ), }); const result: Buffer[] = []; let responseSize = 0; diff --git a/tests/plugin-broker.test.ts b/tests/plugin-broker.test.ts index c2cf649..f83ecff 100644 --- a/tests/plugin-broker.test.ts +++ b/tests/plugin-broker.test.ts @@ -28,7 +28,19 @@ it('only forwards read RPC calls and keeps upstream credentials inside the host' expect((await call('get_tip_block_number')).status).toBe(200); expect((await call('send_transaction')).status).toBe(403); expect((await fetch(broker.url)).status).toBe(405); - expect(seen).toHaveLength(1); + const read = { jsonrpc: '2.0', id: 2, method: 'get_block_by_number', params: ['0x0'] }; + const batch = (calls: unknown[]) => + fetch(broker.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(calls), + }); + expect((await batch([read, { ...read, method: 'send_transaction' }])).status).toBe(403); + expect((await batch([])).status).toBe(403); + expect((await batch(Array(201).fill(read))).status).toBe(403); + expect(seen).toHaveLength(1); // Mixed/oversized batches must forward no partial calls. + expect((await batch([read, read])).status).toBe(200); + expect(seen).toHaveLength(2); expect(seen[0].authorization).toBe(`Basic ${Buffer.from('user:secret').toString('base64')}`); } finally { broker.close(); From 6e40b757385a1946ef6b76758ef5cf292c567a07 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 30 Oct 2026 14:27:02 +0800 Subject: [PATCH 06/10] fix: expose the Linux sandbox seccomp helper --- src/plugins/sandbox.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/plugins/sandbox.ts b/src/plugins/sandbox.ts index 255f497..2faad98 100644 --- a/src/plugins/sandbox.ts +++ b/src/plugins/sandbox.ts @@ -52,10 +52,22 @@ export async function preparePluginSandbox( fs.rmSync(root, { recursive: true, force: true }); }; try { + const externalRequire = createRequire(__filename); + let runtimeDirectory: string; + try { + runtimeDirectory = path.dirname(externalRequire.resolve('@anthropic-ai/sandbox-runtime/package.json')); + } catch { + throw new Error( + 'Sandbox runtime is unavailable; reinstall offckb with optional dependencies on a supported Node version. Refusing unsandboxed execution.', + ); + } + const runtime = path.join(runtimeDirectory, 'dist/cli.js'); // Seatbelt evaluates literal paths. Do not pass /var aliases for /private/var grants. if (contextFile) contextFile = fs.realpathSync(contextFile); const context = contextFile ? (JSON.parse(fs.readFileSync(contextFile, 'utf8')) as PluginContext) : undefined; const read = [...runtimeReadPaths(plugin), ...readPaths, root]; + // Linux executes the runtime's seccomp helper after entering the restricted mount namespace. + if (process.platform === 'linux') read.push(path.join(runtimeDirectory, 'vendor/seccomp')); const write = [root]; const allowedDomains: string[] = []; const sockets: string[] = []; @@ -138,18 +150,6 @@ export async function preparePluginSandbox( NODE_USE_ENV_PROXY: '1', ...(contextFile ? { OFFCKB_PLUGIN_CONTEXT: contextFile } : {}), }; - const externalRequire = createRequire(__filename); - let runtime: string; - try { - runtime = path.join( - path.dirname(externalRequire.resolve('@anthropic-ai/sandbox-runtime/package.json')), - 'dist/cli.js', - ); - } catch { - throw new Error( - 'Sandbox runtime is unavailable; reinstall offckb with optional dependencies on a supported Node version. Refusing unsandboxed execution.', - ); - } return { command: process.execPath, args: [runtime, '--settings', policy, '--', process.execPath, bootstrap, plugin.entry, ...args], From 43fa9376c6a63ab56c6a3b5bdbcd83bdcf603764 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 30 Oct 2026 14:29:55 +0800 Subject: [PATCH 07/10] test: verify host write isolation across sandbox backends --- docs/plugins.md | 4 ++-- scripts/plugin-sandbox-smoke.mjs | 8 ++++++-- scripts/plugin-smoke.mjs | 4 +++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index d609440..d4c3dc5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -51,7 +51,7 @@ offckb plugin link /path/to/package --allow devnet-read --read /path/to/report.j | Grant | Access | | --- | --- | -| None | Read package/runtime dependencies and invocation context; write own data/cache and temporary directory | +| None | Read package/runtime dependencies and invocation context; write back to host only through own data/cache and temporary directory | | `devnet-read` | Reach only a host-owned JSON-RPC gateway with an explicit read-method allowlist; no transaction submission or node administration | | `project-deployment` | Read the invocation project's `deployment/` directory; an escaping directory symlink is rejected | | `serve` | Serve HTTP over a designated Unix socket to a host-owned `127.0.0.1` listener; no arbitrary TCP bind or loopback access | @@ -59,7 +59,7 @@ offckb plugin link /path/to/package --allow devnet-read --read /path/to/report.j Capabilities do not grant arbitrary Internet access, project writes, access to other plugins' data, SSH agents, keys or core settings. Manifest changes that add capabilities invalidate the registration until explicitly relinked. Local package source remains mutable; review changes before running it again. -The host uses the pinned [Anthropic sandbox runtime](https://github.com/anthropics/sandbox-runtime) as an external npm dependency. OS restrictions apply to descendants too. Caller environment variables are replaced with a small allowlist: no inherited tokens, proxy credentials, SSH agent socket, Node preload options or module search overrides. The sandbox can read required OS/runtime locations and its package/dependency tree, so keep credentials out of these locations and out of packages. This is a filesystem/network boundary, not a VM or a resource quota: CPU/memory exhaustion, terminal output and OS/runtime vulnerabilities remain risks. Trust and review plugin code, particularly before granting additional read paths. +The host uses the pinned [Anthropic sandbox runtime](https://github.com/anthropics/sandbox-runtime) as an external npm dependency. OS restrictions apply to descendants too. Linux may accept writes into private tmpfs views of hidden paths; these do not modify the corresponding host files. Native tests verify a pre-existing host canary remains unchanged, independently of the plugin's reported syscall result. Caller environment variables are replaced with a small allowlist: no inherited tokens, proxy credentials, SSH agent socket, Node preload options or module search overrides. The sandbox can read required OS/runtime locations and its package/dependency tree, so keep credentials out of these locations and out of packages. This is a filesystem/network boundary, not a VM or a resource quota: CPU/memory exhaustion, terminal output and OS/runtime vulnerabilities remain risks. Trust and review plugin code, particularly before granting additional read paths. ## Context and output diff --git a/scripts/plugin-sandbox-smoke.mjs b/scripts/plugin-sandbox-smoke.mjs index 39f4131..1266cb8 100644 --- a/scripts/plugin-sandbox-smoke.mjs +++ b/scripts/plugin-sandbox-smoke.mjs @@ -88,6 +88,7 @@ try { const secret = path.join(root, 'private.txt'); fs.writeFileSync(secret, 'file-canary'); const escapedWrite = path.join(root, 'escaped.txt'); + fs.writeFileSync(escapedWrite, 'host-write-canary'); const probe = plugin( 'malicious-probe', ` @@ -105,8 +106,11 @@ try { ); await invoke(['plugin', 'link', probe]); const denied = JSON.parse((await invoke(['--json', 'malicious-probe'])).stdout); + // Linux read-deny mounts expose private tmpfs views: writes there may succeed without + // reaching the host. Verify the real victim independently of the plugin's report. + assert.equal(fs.readFileSync(escapedWrite, 'utf8'), 'host-write-canary'); + if (process.platform === 'linux') delete denied.write; assert.ok(Object.values(denied).every((value) => value === true)); - assert.equal(fs.existsSync(escapedWrite), false); assert.equal(fs.readFileSync(path.join(home, 'cache/malicious-probe/own'), 'utf8'), 'allowed'); const bare = path.join(root, 'bare-bundle'); fs.cpSync(path.dirname(cli), bare, { recursive: true }); @@ -116,7 +120,7 @@ try { ); await invoke(['plugin', 'unlink', 'malicious-probe']); console.log( - 'PASS: denied file reads/writes, symlinks, child escape, direct networking and inherited/context credentials', + 'PASS: prevented host file reads/writes, symlinks, child escape, direct networking and inherited/context credentials', ); const reader = plugin( diff --git a/scripts/plugin-smoke.mjs b/scripts/plugin-smoke.mjs index 9e88f6b..c71b6e8 100644 --- a/scripts/plugin-smoke.mjs +++ b/scripts/plugin-smoke.mjs @@ -175,6 +175,7 @@ try { fs.mkdirSync(attacker); const secret = path.join(root, 'private.txt'); const escapedWrite = path.join(root, 'escaped.txt'); + fs.writeFileSync(escapedWrite, 'host-write-canary'); fs.writeFileSync(secret, 'private-canary'); const listener = net.createServer((socket) => { socket.destroy(); @@ -208,8 +209,9 @@ try { try { invoke(['plugin', 'link', attacker]); const result = JSON.parse(invoke(['--json', 'sandbox-probe'])); + assert.equal(fs.readFileSync(escapedWrite, 'utf8'), 'host-write-canary'); + if (process.platform === 'linux') delete result.write; assert.ok(Object.values(result).every((value) => value === true)); - assert.equal(fs.existsSync(escapedWrite), false); invoke(['plugin', 'unlink', 'sandbox-probe']); } finally { await new Promise((resolve) => listener.close(resolve)); From db6c91c6b31c4ed40a30f31df167ea28d6eababb Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 30 Oct 2026 14:33:07 +0800 Subject: [PATCH 08/10] test: let the host assert sandbox probe results --- scripts/plugin-sandbox-smoke.mjs | 2 +- scripts/plugin-smoke.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/plugin-sandbox-smoke.mjs b/scripts/plugin-sandbox-smoke.mjs index 1266cb8..721aa61 100644 --- a/scripts/plugin-sandbox-smoke.mjs +++ b/scripts/plugin-sandbox-smoke.mjs @@ -100,7 +100,7 @@ try { const result={read:denied(()=>fs.readFileSync(${JSON.stringify(secret)})),write:denied(()=>fs.writeFileSync(${JSON.stringify(escapedWrite)},'bad')),symlink:denied(()=>fs.readFileSync(c.plugin.dataPath+'/escape')),child:denied(()=>cp.execFileSync(process.execPath,['-e','require("fs").readFileSync('+JSON.stringify(${JSON.stringify(secret)})+')'],{stdio:'pipe'})),env:process.env.OFFCKB_TEST_SECRET===undefined&&process.env.SSH_AUTH_SOCK===undefined,context:!JSON.stringify(c).includes('rpc-canary')&&c.devnet.rpcUrl===''}; const socket=net.connect(${forbidden.address().port},'127.0.0.1');let finished=false; const timer=setTimeout(()=>{socket.destroy();done(true);},1000); - function done(blocked){if(finished)return;finished=true;clearTimeout(timer);result.network=blocked;console.log(JSON.stringify(result));if(Object.values(result).some(value=>value!==true))process.exitCode=1;} + function done(blocked){if(finished)return;finished=true;clearTimeout(timer);result.network=blocked;console.log(JSON.stringify(result));} socket.once('connect',()=>{socket.destroy();done(false);});socket.once('error',()=>done(true)); `, ); diff --git a/scripts/plugin-smoke.mjs b/scripts/plugin-smoke.mjs index c71b6e8..44c3411 100644 --- a/scripts/plugin-smoke.mjs +++ b/scripts/plugin-smoke.mjs @@ -201,7 +201,7 @@ try { const result={read:denied(()=>fs.readFileSync(${JSON.stringify(secret)})),write:denied(()=>fs.writeFileSync(${JSON.stringify(escapedWrite)},'bad')),symlink:denied(()=>fs.readFileSync(link)),child:denied(()=>cp.execFileSync('/bin/cat',[${JSON.stringify(secret)}],{stdio:'pipe'})),env:process.env.OFFCKB_TEST_SECRET===undefined&&process.env.SSH_AUTH_SOCK===undefined,rpc:context.devnet.rpcUrl===''}; const socket=net.connect(${listener.address().port},'127.0.0.1'); const timer=setTimeout(()=>{socket.destroy();result.network=true;finish();},1000); - function finish(){clearTimeout(timer);console.log(JSON.stringify(result));if(Object.values(result).some(value=>value!==true))process.exitCode=1;} + function finish(){clearTimeout(timer);console.log(JSON.stringify(result));} socket.once('connect',()=>{socket.destroy();result.network=false;finish();}); socket.once('error',()=>{result.network=true;finish();}); `, From 6cb7f6048ba40b35013d5f601701820ef1ee9256 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 30 Oct 2026 18:34:59 +0800 Subject: [PATCH 09/10] refactor: keep application adapters out of the plugin host --- .changeset/plugin-system-proposal.md | 2 +- .gitignore | 2 +- README.md | 2 +- docs/plugin-system-proposal.md | 222 +++++------------- docs/plugins.md | 33 ++- examples/plugins/context-probe/cli.cjs | 2 +- packages/plugin-explorer/README.md | 31 --- packages/plugin-explorer/cli.mjs | 146 ------------ packages/plugin-explorer/package.json | 29 --- scripts/plugin-sandbox-smoke.mjs | 88 ++++++- scripts/plugin-smoke.mjs | 303 ------------------------- 11 files changed, 147 insertions(+), 713 deletions(-) delete mode 100644 packages/plugin-explorer/README.md delete mode 100644 packages/plugin-explorer/cli.mjs delete mode 100644 packages/plugin-explorer/package.json delete mode 100644 scripts/plugin-smoke.mjs diff --git a/.changeset/plugin-system-proposal.md b/.changeset/plugin-system-proposal.md index f3d552d..e5f95dc 100644 --- a/.changeset/plugin-system-proposal.md +++ b/.changeset/plugin-system-proposal.md @@ -2,4 +2,4 @@ '@offckb/cli': minor --- -Add experimental local tool plugins with explicit link/list/unlink commands, static manifest validation, versioned devnet context, opaque argument forwarding and foreground process cleanup. Enforce OS sandboxing with explicit filesystem/read-RPC/HTTP capabilities and filtered environment. Include a context probe and an unpublished Devnet Explorer adapter using only the proposed public upstream CLI contract; registry installation and background services remain deferred. +Add experimental local tool plugins with explicit link/list/unlink commands, static manifest validation, versioned devnet context, opaque argument forwarding and foreground process cleanup. Enforce OS sandboxing with explicit filesystem/read-RPC/HTTP capabilities and filtered environment. Include a generic context probe and protocol/security tests; registry installation and background services remain deferred. diff --git a/.gitignore b/.gitignore index 233d17b..ef71988 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,5 @@ coverage/ # Sisyphus plans and sessions .sisyphus/ -# Local upstream sources for the experimental Explorer adapter +# Local research, extracted adapters and validation artifacts .prototype/ diff --git a/README.md b/README.md index 9e284bf..b3711af 100644 --- a/README.md +++ b/README.md @@ -608,7 +608,7 @@ npm install -g @offckb/cli ## Experimental local plugins -Local tool packages can be explicitly linked with `offckb plugin link `, inspected with `offckb plugin list`, and run as `offckb `. The host enforces an OS sandbox with explicit capability grants, supplies restricted devnet context and owns foreground cleanup. Plugins currently require macOS/Linux and Node 22.21+ (22.x) or 24.5+; unsupported environments fail closed. See the [plugin guide](docs/plugins.md) for the context probe, public-CLI Explorer adapter (awaiting upstream release), and security limits. Registry installation and background plugins are not implemented yet. +Local tool packages can be explicitly linked with `offckb plugin link `, inspected with `offckb plugin list`, and run as `offckb `. The host enforces an OS sandbox with explicit capability grants, supplies restricted devnet context and owns foreground cleanup. Plugins currently require macOS/Linux and Node 22.21+ (22.x) or 24.5+; unsupported environments fail closed. See the [plugin guide](docs/plugins.md) for the context probe, plugin contract, and security limits. Registry installation and background plugins are not implemented yet. ## Contributing diff --git a/docs/plugin-system-proposal.md b/docs/plugin-system-proposal.md index 816b431..c012ebb 100644 --- a/docs/plugin-system-proposal.md +++ b/docs/plugin-system-proposal.md @@ -1,212 +1,96 @@ -# offckb 工具插件系统:以 Devnet Explorer 为首个案例 +# offckb 工具插件协议与宿主边界 -状态:**设计讨论稿;本地协议已实现,正在补齐强制沙箱与公开 CLI 接入;未获人类验收。** 初始调研日期:2026-10-28;实现基于 2026-10-29 rebase 后的 `96094c8`。实际可用范围和操作方式见[插件指南](plugins.md)。 +状态:**实现草案,供代码审阅;不构成人类验收或接受的 Context。** 实际操作、字段和平台限制见[插件指南](plugins.md)。 -## 本轮实现调整(2026-10-30) +## 目标与归属 -用户要求强制安全沙箱与 Explorer 公开稳定接入。本轮以固定 sandbox-runtime 隔离文件及网络(作为可选安装依赖保留核心 Node 20 安装兼容性,但执行插件时必须存在),显式授权 `devnet-read`、`project-deployment`、`serve` 和额外 `--read` 路径;RPC 凭据留在宿主,只暴露只读网关,HTTP 经指定 Unix 连接由宿主监听 loopback。不继承调用者凭据环境。进程 cwd 改为插件目录,原目录用 `context.cwd`。插件要求 macOS/Linux 与 Node 22.21+(22.x)或 24.5+;不支持时拒绝执行,核心 Node 20 不变。无不安全降级开关。 +让第三方工具在不修改 offckb 核心源码的情况下,获得当前开发环境、注册独立命令并受控运行。核心负责发现、兼容检查、授权、环境快照和前台生命周期;插件作者负责自己的功能、依赖、界面、就绪判断和发布。 -Explorer 迁到 `packages/plugin-explorer`,仅调用 npm 公开 bin 的版本化能力查询、脚本输入、JSON ready 和 Unix HTTP bridge。旧私有源码实验已删除。[上游贡献 PR #1](https://github.com/wildanrhmn/ckb-devnet-explorer/pull/1) 尚待合并发布;适配包保持 private,不能将本地 tarball 验证算作正式分发。沙箱不提供 VM 或 CPU/内存配额,仍需审查代码及授权路径。以下早期跨平台/运行边界以本段及[实际指南](plugins.md)为准。 +所有实际应用插件地位相同,均应独立维护和发布。宿主不内置首选插件,不固定某个应用的包名、版本、参数或链支持策略,也不把它的上游发布作为宿主交付门槛。本 PR 只保留协议探针和合成测试;实际适配包及其业务端到端测试放在独立插件仓库。 -## 建议 +采用 **npm 包形态 + 静态声明 + 独立子进程 + OS 沙箱**。先验证本地接入与安全边界,不引入 Cordis DI、事件总线、热重载、profile、bundle 或插件间依赖,不迁移现有核心工具命令。 -值得做一个轻量的工具插件系统。offckb 负责提供当前开发链的环境信息、管理安装和启动进程;Explorer 等工具独立发布,负责自己的功能和界面。第一个目标是让一个第三方工具接入后,不再需要修改 `src/cli.ts` 或增加核心依赖。 +## 借鉴 dsh 的边界 -首版采用 **npm 包 + 静态声明 + 独立子进程**。先用 Explorer 验证协议,再用另一个真实工具检验是否需要更多扩展点。暂不引入 Cordis、通用事件总线、插件互相依赖、热重载或多套 profile,也不迁移现有 Fiber。 +调研基于 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 `f3803c96974ad4a94195fb97e5c637e9c2a3afe3`。它通过 Cordis 服务依赖、bundle 分发及 profile 组合配置来组织整个产品,需求范围大于当前外部工具接入。 -如果目标仅仅是提供一个启动 Explorer 的快捷命令,普通子命令就足够;插件机制的收益在于第三方工具可以独立发布,并获得一致的环境信息和生命周期。判断首版是否成功,应看接入第二个工具是否还需要改核心。 - -## 当前源码告诉我们什么 - -以下以 offckb `60655db35135ce73106f8ac7c5cfd94e2cc03504` 为基线,没有依赖未接受的 Context。 - -| 当前证据 | 对设计的影响 | -| --- | --- | -| [`src/cli.ts`](../src/cli.ts) 集中注册 Commander 命令,直接接入 Fiber、debugger 和 TUI | 命令发现可以增加一个明确入口;无需改写整套 CLI | -| [`src/cmd/node.ts`](../src/cmd/node.ts) 显式管理 CKB、miner、proxy、可选 FNN;组合启动时 FNN 退出会触发整体清理 | 新的可选工具不能直接复用“任一组件失败就停止整组”的语义 | -| [`src/fiber/`](../src/fiber/) 已有专门的安装、就绪检查、锁、daemon、状态及清理代码 | 可参考进程身份和清理经验;不要一开始把这些领域逻辑抽成通用框架 | -| [`src/tools/ckb-tui.ts`](../src/tools/ckb-tui.ts) 已处理平台资产、校验和及原子安装 | 二进制分发已有经验,但它不等于第三方命令和服务的扩展协议 | -| [`src/cfg/setting.ts`](../src/cfg/setting.ts) 已统一 RPC、proxy、数据和配置路径 | 插件应接收已解析的环境,避免自己猜端口或读取 settings.json 内部结构 | -| [`src/cmd/devnet-info.ts`](../src/cmd/devnet-info.ts)、[`src/devnet/readiness.ts`](../src/devnet/readiness.ts) 已区分链类型、RPC 就绪与 indexer 就绪 | 可以复用其数据构造逻辑,形成版本化的插件上下文 | -| [`src/scripts/private.ts`](../src/scripts/private.ts) 已根据 genesis 识别 fork 并解析系统脚本 | 插件上下文应保留实际地址前缀和链身份,不能把所有 devnet 都当成普通 `ckt` 链 | -| [`scripts/build.js`](../scripts/build.js) 将 CLI 编译并用 ncc 打包 | 外部插件应从独立安装目录启动,不依赖被打包的动态 import 或核心内部模块路径 | - -现有工具说明扩展需求已经出现,但 Fiber 的复杂度不应成为所有插件的入场门槛。 - -## 从 dsh 借鉴什么 - -这里的 dsh 指 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。调研固定在 `f3803c96974ad4a94195fb97e5c637e9c2a3afe3`,避免把快速变化的上游设计当作永久约定。 - -dsh 的体系包含三个层次:Cordis 提供服务依赖及可撤销的注册;bundle 分发代码和配置;profile 组合 bundle 及用户覆盖层。它用这套机制组合整个产品,适用范围明显大于 offckb 当前的外部工具接入需求。依据:[Cordis Primer](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/docs/cordis-primer.md)、[Architecture](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/docs/architecture.md)。 - -| dsh 的设计 | offckb 的取舍 | +| 借鉴点 | 当前实现 | | --- | --- | -| 依赖通过服务声明,避免靠偶然的加载顺序 | 首版只提供核心环境协议;Explorer 自己检查 RPC 和 indexer,暂不支持插件依赖插件 | -| 注册和副作用随插件卸载而回收 | 子进程、临时上下文文件由启动它们的命令负责清理 | -| 安装、选择启用和实际激活有不同状态 | 借鉴状态分离原则:包是否存在、命令是否可发现、兼容性诊断分别报告;首版没有 dsh 的配置激活过程,“可启动”也不表示服务已就绪 | -| 配置可预览,失败能定位到插件和原始原因 | 首版在 `plugin list` 提供诊断,独立 doctor 命令按需要增加;错误包含插件版本、执行阶段及日志位置 | -| 包管理委托现成工具 | 使用 npm 安装已发布包,不自建下载器、依赖解析器或注册中心 | -| 版本兼容在导入前检查 | 读取静态声明,检查协议版本和 Node engines 后才启动插件 | -| profile、bundle、配置覆盖、服务容器及 HMR | 暂缓;实际出现多开发环境和插件协作需求后再评估 | - -dsh 当前的插件管理已包含锁、兼容性检查、安装后选择 bundle 及失败恢复,不能简单概括成一层 `pnpm add`。offckb 应学它明确报告剩余状态的做法,避免一次复制整个管理体系。依据:[CLI 实现](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/apps/cli/src/plugin.ts)、[Plugin Manager](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/packages/boot/plugin-manager/README.md)。 +| 声明与运行分离 | 发现阶段只读 package.json,不执行插件取得名称和摘要 | +| 安装、可发现、兼容与运行状态分离 | 包由用户准备;登记由 link 管理;list 报告声明、兼容及撞名问题;实际就绪由插件负责 | +| 副作用随卸载或退出回收 | 当前调用拥有子进程、网关和临时文件;不提供跨进程 stop 或后台服务 | +| 使用成熟包管理工具 | 插件可独立发布 npm;首阶段没有内置 registry 安装器 | +| 兼容前置检查 | 执行前检查协议、入口路径、Node engines 和显式授权 | +| 服务容器、profile、bundle、HMR | 等多个真实插件提出共同需求后再评估 | -## 实现路径与安装归属 +依据:[架构](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/docs/architecture.md)、[插件 CLI](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/apps/cli/src/plugin.ts)、[Plugin Manager](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/packages/boot/plugin-manager/README.md)。这些参考不意味着复刻其完整生命周期或配置模型。 -| 路径 | 收益与成本 | 判断 | -| --- | --- | --- | -| 按 PATH 查找 `offckb-` 并转发 | 实现最少,但安装来源、版本、上下文和进程归属难统一 | 可做快捷入口;不足以完成这次期待的集成 | -| 用户自行安装,显式登记包路径,再使用相同的上下文及子进程协议 | 能获得一致集成体验,无需核心先实现安装器;用户自行处理升级和依赖 | Phase 1 采用本地路径验证;它是正式可考虑的简化方案,内置安装器并非插件协议的前提 | -| 静态声明 + 环境协议 + 子进程 | 保留独立 CLI,增加可控的发现、兼容性及生命周期约定 | **推荐** | -| 动态加载 JS 插件并提供 `register(ctx)` | 便于共享 Commander、事件和服务,但会把核心内部 API、模块解析和异常边界变成长期协议 | 等出现必须在进程内协作的需求再考虑 | - -子进程本身只解决依赖、ESM/CJS 和故障边界。当前实现必须再套 OS 沙箱,文件及网络访问按显式授权限制;不能以子进程隔离替代沙箱。 - -用户自装与内置安装器是安装职责的选择,可以共用同一套插件协议。Phase 1 结束后应根据实际安装困难决定是否启动 Phase 2,不能仅因协议可行就认定必须开发安装器。 - -## 首版用户体验 - -以下都是拟议接口;插件包名及版本为示意,不代表已发布。 - -```sh -# 固定版本安装;不改当前项目的 package.json -offckb plugin add @offckb/plugin-explorer@ -offckb plugin list - -# 在已部署的项目目录中,连接 offckb 当前配置的开发链 -offckb node --daemon -offckb explorer --project . - -# 也能显式传入多个项目 -offckb explorer --project ./project-a --project ./project-b --port 7070 - -offckb plugin remove explorer -``` +## 包与安装 -`explorer` 在前台运行,Ctrl+C 停止本次启动的 Explorer,CKB daemon 继续运行。首版没有后台 Explorer,也没有自动伴随 `offckb node` 启动;因此移除正在运行的插件应拒绝并提示先退出它。 - -安装到用户级 offckb 数据目录,调用时以当前工作目录为默认项目。只有显式安装登记的插件参与命令发现,不自动执行当前目录或 PATH 里碰巧出现的插件。第三方包无需使用 `@offckb` scope。 - -## 最小插件协议 - -一个包对应一个命令命名空间,子命令和参数由插件自己解析。示意 `package.json`: +一个包对应一个简单命令命名空间;子命令由插件自行解析。 ```json { - "name": "@offckb/plugin-explorer", + "name": "my-offckb-tool", "version": "0.1.0", - "engines": { "node": ">=22" }, + "engines": { "node": "^22.21.0 || >=24.5.0" }, "offckb": { "apiVersion": 1, - "command": "explorer", - "entry": "dist/cli.js", - "description": "Browse the current offckb devnet" + "command": "my-tool", + "entry": "dist/cli.mjs", + "description": "Inspect the current development environment", + "permissions": ["devnet-read"] } } ``` -核心只读取声明,不执行插件代码来取得名称或帮助摘要。`entry` 是包内已构建的 JS 文件,用当前 `process.execPath` 启动,绕过 `.bin`、shell quoting 和额外的 Node 查找;启动前确认其 realpath 在包目录内且为普通文件。首版要求交付可直接运行的包,不在用户安装时编译插件。 - -`command` 限制为简单名称,禁止覆盖核心命令;两个插件声明相同命令时,安装明确失败,不采用“后装覆盖”。协议版本或 Node engines 不兼容时阻止执行;某个插件损坏仍须允许使用核心命令及 `plugin remove`。 - -如果 offckb 升级后新增的核心命令与已装插件撞名,发现阶段始终优先注册核心命令,将该插件标记为 `shadowed` 并说明冲突。保留其安装及移除能力,不让冲突导致整个 CLI 启动失败,也不自动改写第三方插件名称。 - -核心通过 `OFFCKB_PLUGIN_CONTEXT` 指向一个临时 JSON 文件,提供本次命令的只读快照,内容限于: - -- `apiVersion`、offckb 版本、当前工作目录、插件专属数据及缓存目录。 -- 当前 devnet 的 RPC URL、proxy URL、链类型、genesis hash、实际地址前缀。 -- 本地 `list-hashes` 失败时,链身份和脚本降级为未知,并附可选的 `devnet.diagnostic`(固定错误码及脱敏说明);不需要链的工具仍可运行,需要链的工具自行拒绝未知状态。 -- 本次生成的系统脚本 JSON 文件路径;沿用现有导出格式,明确记录格式版本。 -- 当前 CLI 的 Node 和入口文件绝对路径,作为必要时调用核心命令的备用方式。 -- 输出格式;结构化输出模式必须由适配器遵守,不能直接混入上游的人类日志。 - -不复制完整 settings、账户私钥或不相关配置。当前运行器以 OS 文件隔离约束可见范围,RPC URL 替换为只读网关或空字符串,认证留在宿主;临时快照在子进程退出后清理。协议快照仍应视为可能被插件修改,不能回读其内容作为宿主授权依据。 - -核心在实际调用时解析上下文;顶层帮助、`plugin list` 只读声明,插件 `--help` 不要求链在线。插件拿到上下文后进行业务所需的就绪检查。所有参数作为 argv 数组转发;沙箱中 cwd 为插件包目录,项目相对路径按上下文 cwd 解析,禁止拼接 shell 命令。 +`entry` 必须是包内普通、已构建的 JS 文件,其 realpath 不得越界。宿主读取静态声明并用当前 Node 启动独立进程,不把插件 import 进 ncc 核心包。 -### JSON 输出与参数边界 +当前只提供 `plugin link/list/unlink`。用户在独立目录安装精确版本、禁用生命周期脚本并检查包后,显式登记目录;link 本身不安装或执行代码。未来是否需要内置安装器,取决于实际安装困难,不由协议已经可用推导。 -插件调用时,核心的 `--json` 必须放在插件命令名之前,例如 `offckb --json explorer --project .`。插件名之后的 argv 属于插件,核心不得提升或删除其中的 `--json`;命令边界后的首个 `--` 用作分隔符,消费后剩余 token 原样传递。现有内建命令的标志兼容行为不在本次改动范围内。 +若增加安装器,先保持固定 registry 版本、独立安装目录、staging 校验、原子登记和失败保留旧版本;完整性使用 npm lockfile,不另建重复校验系统。Git/tarball 安装源及任意构建脚本不在当前范围。Windows npm 入口及带空格路径仍须单独验证,不能把 npm.cmd 当作可直接用 shell:false 执行的原生程序。 -这要求先识别插件分派边界,再处理核心前缀,不能直接将整条插件命令交给现有 `normalizeGlobalJsonFlag()`:该函数会扫描并删除所有位置的 `--json`。Phase 1 必须验证重复标志、分隔符、带空格路径和未知插件参数的透传。 - -在核心选择 JSON 格式且成功启动插件后,**适配器拥有 stdout 的结果记录**:诊断写 stderr,成功时按约定输出一条结果;父进程禁止再执行 `runCli()` 的兜底成功输出,也不因插件退出而补一条成功记录。启动前失败由核心报告,启动后由适配器报告业务错误,父进程保留退出码和必要的进程诊断。帮助请求单独委托,返回后也不补结果记录。首版不通过解析上游人类日志判断 ready。 - -依据:[`src/cli.ts`](../src/cli.ts) 的 `normalizeGlobalJsonFlag()`、`runCli()`,以及 [`src/util/logger.ts`](../src/util/logger.ts) 的 `result()`/`hasResult()`。父子进程的 logger 状态并不共享,继承同一个 stdout 也无法抑制父进程的兜底输出。 - -这比让插件 import `src/cfg/setting` 稳定,也不要求插件依赖另一个版本的 `@offckb/cli`。后续可提供少量 TypeScript 类型帮助作者开发,无需先建立运行时 SDK。 - -### 安装与进程管理的边界 - -- 每个插件使用独立的安装目录及 npm lockfile;核心只登记命令、包名、精确版本、来源和安装位置,包及依赖的完整性以 npm lockfile 为准,不再维护一份重复的校验清单。Phase 1 直接使用本地路径,正式安装器首版接受固定版本的 registry 包;Git URL、tarball 和任意安装脚本暂缓。 -- 包管理调用发生在 offckb 专属目录,使用 `--save-exact --omit=dev --ignore-scripts`。安装脚本被禁用也不代表运行时代码可信;需要安装期构建的包应在发布前完成构建。依据:[npm install 文档](https://docs.npmjs.com/cli/v11/commands/npm-install/)。 -- Windows 上不能以为解析到 `npm.cmd` 的绝对路径后就能用 `shell:false` 直接执行。安装器优先验证通过 Node 启动当前 npm 安装的 JS 入口;无法可靠解析时明确失败,不拼接用户输入回退到 shell。该入口的发现规则及带空格路径行为必须通过 Windows 原型验证后才能作为实施依据。依据:[Node 子进程文档](https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows)。 -- 安装先进入同一数据卷的 staging 目录,再校验静态声明、入口和兼容性。登记更新加锁并原子替换;失败保留原登记及旧版本。不在每次执行时联网更新,也不在 `offckb node` 时自动安装。 -- 首版保留防止运行中移除或覆盖的最小互斥记录,因为它支撑上述 remove 语义;不抽象通用 runtime 状态机,也不提供基于持久 PID 的 stop/status。互斥与异常退出后的恢复必须在 Phase 1 原型验证,不能用“Windows 删除文件可能报错”替代跨平台运行保护。完整身份验证及跨进程停止流程留待服务托管阶段。 -- 前台启动器负责转发 SIGINT/SIGTERM、等待退出及清理自己创建的进程和临时文件。退出码应反映插件失败;超时终止只针对本次拥有的进程。Windows 进程树清理需要专项验证,不能只靠 POSIX 信号假设。 -- 管理命令的 `--json` 由核心输出;插件执行按上节由适配器拥有结果记录,两条路径不得混用。 - -## Explorer:适合接入,但需要明确适配 - -历史调研版本(以下是旧版事实,当前实现只接公开 CLI):[`477af4400a716353d140fc1a5b8fee8175f74fed`](https://github.com/wildanrhmn/ckb-devnet-explorer/tree/477af4400a716353d140fc1a5b8fee8175f74fed),包内版本为 `0.2.0`。以下保留初始源码检查结果;当前本地实验的端到端验证及局限见[插件指南](plugins.md)。 - -| 上游现状 | 接入处理 | -| --- | --- | -| 单个 Node HTTP 服务,提供网页及 JSON API,不需要数据库或独立索引服务 | 保持独立进程及浏览器入口;不需要嵌入 offckb 的终端界面 | -| 需要 Node 22+,使用 tsx 运行 TS;offckb 当前支持 Node 20+ | 只限制 Explorer 插件的运行环境,不为此直接抬高核心要求;Node 20 下给出明确诊断 | -| 已支持 `--rpc`、重复 `--project`、`--port` | 适配器从上下文补上当前 RPC,项目目录保留调用者语义;首版不开放指向任意链的覆盖,避免系统脚本与 RPC 不匹配 | -| `loadSystemScripts()` 从 PATH 调用 `offckb system-scripts -o ...` | 改为优先读取核心传入的系统脚本文件,避免调用到另一版本的 offckb,也避免为单次调用安装 PATH shim | -| 项目脚本从 `/deployment/scripts.json` 加载,启动时读入 | 可直接复用;部署或清链后提示重启 Explorer,首版不增加部署事件总线 | -| 地址编码前缀写死为 `ckt` | 首版仅支持普通本地 devnet,检测到 fork 或无法确认支持的链身份就明确拒绝启动;后续 fork 支持需改用上下文前缀并验证脚本、地址及链切换 | -| 地址、脚本及部分交易查询调用 CKB 内置 indexer | RPC 可达不等于功能全部可用;必须检查 indexer 缺失或落后并说明结果可能不完整 | -| `server.listen(port)` 没有 host 参数,打印 localhost 并不限制监听范围 | 增加 host 参数,正式集成默认显式绑定 `127.0.0.1`;不能声称外层包装已经解决 | +核心命令优先。重复插件名明确失败;升级后核心新增同名命令时,插件标为 `shadowed`,保留移除能力。损坏登记或声明不应阻断核心命令,也不应被发现流程自动覆盖。 -依据:[README](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/README.md)、[package.json](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/package.json)、[CLI](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/cli.ts)、[scripts.ts](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/scripts.ts)、[server.ts](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/server.ts)、[explorer.ts](https://github.com/wildanrhmn/ckb-devnet-explorer/blob/477af4400a716353d140fc1a5b8fee8175f74fed/src/explorer.ts)。Node 未指定 host 时的行为见 [Node net 文档](https://nodejs.org/api/net.html#serverlistenport-host-backlog-callback)。 +## 环境与参数协议 -推荐优先向上游贡献两个小入口:系统脚本文件输入、显式 host;上游仍可保留原 CLI 的默认行为。offckb 的独立适配包负责协议转换和就绪检查。不能承诺现有仓库不改代码就完整符合协议;临时适配分支只用于实验,不能以明确了维护者或固定了 commit 为由继续依赖私有 TS 路径正式分发。 +`OFFCKB_PLUGIN_CONTEXT` 指向本次临时 JSON,包含 API/offckb 版本、调用目录、输出格式、插件专属 data/cache 目录、链身份及系统脚本快照。不会复制完整 settings 或账户私钥。字段详情以[插件指南](plugins.md#context-and-output)为准。 -建议职责分配为:offckb 侧维护协议及适配包,Explorer 上游维护业务及 UI。具体维护者尚未确定,也没有获得上游承诺。Phase 1 的命令协议原型先使用最小假插件,不等待上游;本地 Explorer 实验可使用明确标注的临时适配分支。正式分发还必须满足下节门槛,否则保留实验结果、暂缓插件发布。 +- 无 `devnet-read` 授权时 RPC 字段为空;有授权时只暴露宿主只读网关,原始 URL 和认证留在宿主。 +- 链元数据由托管 CKB 本地 `list-hashes` 提供,限时三秒;缺失或失败降级为 `unknown`,必要时附脱敏诊断。需要链身份的插件自行决定是否拒绝,并验证 RPC genesis。 +- `project-deployment` 授予调用项目 deployment 的只读访问;额外路径用 `--read` 显式授权。路径字符串本身不授予访问权。 +- `serve` 允许插件通过指定 Unix socket 主动提供 HTTP 连接,由宿主监听 loopback 并设置响应安全头。宿主不规定第三方应用的启动参数或内部服务 API。 +- 沙箱进程 cwd 是插件包目录,项目相对路径按 context.cwd 解释。Node/CLI 路径只是信息,不代表能够绕过权限调用核心。 -此外,上游缓存只在 tip 回退时清除,脚本名也在启动时加载。清链或换链后统一重启 Explorer,避免把“tip 没回退”误认为链未变化。支持普通 devnet 的首版也应验证这一场景。 +所有参数以 argv 数组传递,不拼 shell 命令。核心 `--json` 和 `--plugin-port` 必须位于插件命令之前;命令之后的 token 属于插件,仅消费紧随命令名的第一个 `--`。现有核心命令的标志兼容逻辑保持原有行为。 -### Explorer 正式分发门禁 +委托成功后,插件拥有 stdout,包括它的一条 JSON 结果;宿主不追加成功兜底。诊断写 stderr,退出码透传。独立 `--help`/`-h` 仍在沙箱执行,但不生成上下文或要求链在线。插件业务 ready 的具体字段属于插件协议,核心不解析第三方的人类日志。 -这是 Explorer 正式分发的合并前置条件。当前选择第 2 条,私有源码接入已删除;未发布的适配包不等于门禁已通过。正式分发必须先确定维护者,并完成以下二选一: +## 安全与平台范围 -1. 上游合并并发布显式 host、系统脚本文件输入及受 semver 约束的 `startServer` 等公开入口;适配包通过正常包解析使用这些入口。 -2. 适配包只调用上游公开 CLI,固定发布版本,并以契约测试覆盖能力版本、loopback 监听、脚本输入、JSON ready、Unix bridge 及退出清理。当前已准备上游实现和契约测试,暂定 provider 0.3.0;须等待上游合并、发布并确认精确版本后才能发布适配包。若公开 CLI 尚不能满足这些行为,仍不能毕业。 +子进程不能代替安全沙箱。执行插件必须经过固定版本的 sandbox-runtime;缺少运行时、后端或不满足环境要求时拒绝执行,没有不安全回退。 -毕业时删除源码 magic-string replace、私有 `src/*.ts` import、对 tsx export map 内部形状的解析,以及 `OFFCKB_EXPLORER_SOURCE` 实验路径覆盖。不将 `.prototype`、准备脚本或这些私有接入方式包装为正式发布方案。固定 commit 和通过本地冒烟测试不能代替公开兼容契约;保留上述依赖的正式分发 PR 不应合并。 +默认只读运行所需的系统/包文件和上下文,只能经专属 data/cache/临时目录回写宿主。清除调用者的凭据、代理、SSH agent、Node preload 和模块查找环境。能力请求必须在 link 时逐项授权;声明增加权限后旧登记失效,需重新授权。持久目录不得通过符号链接变成下次调用的新授权。 -## 分阶段落地 +只读 RPC 网关只接受白名单方法、受限大小的 JSON-RPC POST 和最多 200 条的全只读批次;混入写方法整批拒绝。网络默认关闭,网页服务通过宿主桥接,不赋予任意 Internet、loopback 或 bind 权限。 -1. **先验证协议,不做安装器。** 先用最小假插件验证 Commander 动态注册、JSON 结果所有权、argv 透传及前台退出,覆盖生产 ncc 包、macOS 和 Windows。再用本地 Explorer 适配验证上下文文件往返、显式 loopback 监听、普通 devnet、fork 拒绝、清链重启和临时文件清理。这两组实验通过才算本阶段完成。 -2. **按需要交付最小插件管理。** 实现固定 npm 版本安装、list、remove、命令发现和前台运行;诊断先放进 list,不要求独立 doctor 命令才能交付。开始前先以具名第二工具 `ckb-tui` 做纸面走查:通过 Node 适配器启动原生二进制,核对 TTY、argv、上下文和信号是否足够;这不是承诺迁移现有 status 命令。核心目录预计新增 `src/plugins/` 与 `src/cmd/plugin.ts`,`src/cli.ts` 增加管理命令及统一发现入口。 -3. **按实际使用增加服务托管。** 再讨论 `offckb node --with explorer`、插件 start/stop/status/logs 和 daemon。届时增加独立于人类日志的 ready 通道,HTTP 监听成功不等于 CKB 业务就绪;端口冲突及启动超时必须返回可定位的错误。 -4. **有第二个真实案例再抽象。** 优先选择另一个外部工具验证协议;Fiber 作为较晚迁移对象,保留现有命令兼容性与 fork 限制。只有出现插件间协作或多环境复用需求,再引入服务依赖、profiles 或 bundle。 +当前运行器只支持 macOS/Linux,分别使用 sandbox-exec 和 bubblewrap。Windows 的静态包协议及核心命令不因此失效,但插件执行尚未实现。所选后端的 Windows 机制涉及专用账户、ACL 和机器级 WFP 安装,并公开列有令牌切换、系统服务代发网络等旁路;当前宿主也没有完成原生路径、HTTP 传输和进程树适配。因此不能仅删除平台检查就宣布同等隔离。[固定版本后端说明](https://github.com/anthropics/sandbox-runtime/blob/v0.0.81/README.md#known-limitations)。 -组合服务阶段必须明确所有权:连接已有 CKB 的插件命令不能关闭 CKB;同一个 node 管理器启动的插件随它清理;独立启动的插件由自己的管理器停止。可选 Explorer 失败只降级并报告,核心 CKB/miner 失败触发所拥有组件的反向清理;如以后提供 required 模式,再显式约定整体失败。现有 `--fiber` 行为保持到专门迁移时再讨论。 +启用 Windows 前须确定满足权限边界的后端、明确安装影响,并通过原生文件、凭据、网络及后代进程清理测试。任何第三方插件的存在都不是启用或禁用平台的依据。 -## Kimi 独立评审回应 +沙箱不是 VM,也不提供 CPU/内存配额或可信 UI。授权目录、包内容、终端输出、OS/运行时漏洞仍需审查。固定运行时的 Zod 3 资源耗尽告警及当前仅解析宿主策略的受限路径见[指南](plugins.md#validation-and-limits);不能因此把依赖视为已修复。 -Sentinel 使用 `kimi-coding/kimi-for-coding` 做了只读评审,认为选型及分期合理,提出 M1–M4 等问题。评审核对了本地源码,未独立复核 dsh 和 Explorer 外部仓库;这些意见是协作材料,不构成人类验收或接受的 Context。 +## 生命周期与验证 -- **M1,输出所有权:** 已补适配器拥有结果、父进程抑制兜底及标志边界;命令回归测试及生产 ncc 包冒烟已验证这些边界。 -- **M2,升级撞名:** 已补核心优先、插件 `shadowed` 及保留移除能力。 -- **M3,Windows npm:** 采纳必须验证的风险,纠正“npm.cmd 绝对路径 + shell:false 即可”的修法;当前 npm JS 入口解析及跨平台实测仍未完成。 -- **M4,维护归属:** 已提出职责及正式分发门禁;具体维护者与公开兼容契约未定,不能视为已关闭。 -- **S1/S2,复杂度:** 保留支撑“运行中不可移除”的最小互斥,通用运行状态机推迟;完整性复用 npm lockfile,不另建清单。 -- **第二工具与验证次序:** Phase 2 前以 `ckb-tui` 走查;按命令协议、Explorer 上下文往返、安装器跨平台的顺序验证。 +当前调用拥有前台进程组,转发 SIGINT/SIGTERM/SIGHUP,限时等待并清理网关、上下文和最小互斥记录。仅对本次拥有的进程操作,不向持久 PID 发信号。插件不得自行脱离前台生命周期创建 daemon。 -## 实施时的验收点与未决项 +注册写入使用 wx 锁和原子替换。运行租约拒绝同一插件并发启动和运行中 relink/unlink。崩溃、SIGKILL 或掉电仍可能留下锁和临时文件;自动恢复、持久进程身份与跨进程停止留待服务托管阶段。 -- 未登记插件时,核心启动不加载插件代码,也不为插件创建 registry 或发起 RPC;命令发现允许只读探测 `registry.json` 是否存在。新增一个合规插件不用修改核心命令源码。 -- 在打包后的 CLI、带空格的路径、macOS/Linux/Windows 验证命令发现、argv、cwd、Node 版本检查、信号退出及清理。 -- 损坏声明、重复命令、升级后核心撞名、路径越界、并发安装/移除、安装中断和运行中移除都能明确处理;旧版本仍可使用,核心帮助和移除命令仍可运行。 -- 自定义 RPC 端口、多个项目、空 deployment、错误 JSON、端口占用、RPC 离线、indexer 缺失/落后、清链重启和 `--json` 输出均有覆盖。 -- Node 20 仍能使用 offckb 核心;Explorer 明确拒绝不满足 engines 的运行环境。普通 devnet 通过后,再单独验证 mainnet/testnet fork,不能依据 HTTP 能打开就宣布支持。 -- 未决:确认适配包维护者、上游入口及分发方式;验证当前 npm JS 入口的可靠发现、禁用安装脚本后的交付形态、最小运行互斥和 Windows 进程清理;以 `ckb-tui` 检验协议适用性。以上事项尚未因文档修订而获得验证。 +宿主验收使用合成插件,不下载实际第三方应用: -侧聊审查补充:本地元数据失败已改为降级,重复 `runCli()` 的命令刷新有回归测试。Phase 2 再评估失效租约恢复与自定义 binary 的 RPC 链身份探测:仅有持久 PID 的存活检查不能证明进程身份或子进程组已退出,不能直接据此删除互斥记录,更不能向该 PID 发信号。当前人工确认后清理的限制保留。现有三平台核心 CI 不代表沙箱验证完成。当前 Windows 插件执行明确关闭;恢复支持须另行验证可靠的文件、网络及进程隔离,不能仅以 `taskkill` 测试代替。macOS/Linux 增加原生沙箱拒绝测试。本文不构成阶段签收。 +- 静态声明、越界入口、撞名、损坏登记、授权变化和重复 CLI 调用。 +- 生产 ncc 包中的 argv/JSON 边界、离线 help、上下文和前台退出清理。 +- 原生 OS 沙箱下的文件读取、宿主写保护、符号链接、子进程、凭据和直接网络拒绝。 +- 授权后的正向 data/cache、只读 RPC 与 HTTP 服务;失败后无权限扩张或残留运行状态。 -以上保留整体设计与后续验收标准,不表示它们已经全部通过。第一阶段实现采用显式本地 link/list/unlink;registry 安装、后台服务与正式 Explorer 分发仍未交付。实现及验证方式见[插件指南](plugins.md)。 +实际插件各自负责业务就绪、上游兼容和真实链端到端测试;宿主通过该插件的业务测试不等于接受它为核心组成部分。后续安装器、服务托管及第二案例抽象需要单独确定范围。 diff --git a/docs/plugins.md b/docs/plugins.md index d4c3dc5..2f38752 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,9 +2,17 @@ Plugins are independent npm-shaped packages with a static declaration and a prebuilt JavaScript entry. The host supplies a versioned context and runs each tool in an OS sandbox. This stage implements explicit `link/list/unlink`, foreground execution and capability grants. Registry installation, background services and plugin dependencies are deferred. +## Platform boundary + +This runner currently supports macOS/Linux sandbox execution. The static package protocol itself is portable. Windows users can use core offckb commands and manage registrations; running a plugin fails before acquiring its lease or starting its code. + +The pinned sandbox-runtime 0.0.81 Windows backend uses a dedicated account, ACLs and machine-wide WFP filters. Its [documented limitations](https://github.com/anthropics/sandbox-runtime/blob/v0.0.81/README.md#known-limitations) include token-based network-fence bypasses and traffic carried by services such as SMB/WSL outside that fence. Our runner also needs native Windows adaptation for paths, HTTP transport and process-tree lifetime. Merely removing the platform check or passing ordinary Windows child-process tests would not validate the file/network boundary. + +Windows enablement requires an explicitly chosen isolation backend, user-visible installation requirements, and native positive/negative tests for file access, credentials, direct/loopback networking and descendant cleanup. There is no fallback that silently runs with the user's unrestricted permissions. This is a current host implementation limit; it is independent of any plugin's functionality or release schedule. + ## Try the protocol -Use macOS or Linux, Node **22.21+ (22.x) or 24.5+**, and a working sandbox backend. macOS uses `sandbox-exec`; Linux requires bubblewrap, socat, ripgrep and permitted unprivileged user namespaces. The runtime is an optional npm dependency so core Node 20 installations remain supported; plugin users must install it on a supported Node version without omitting optional dependencies. A missing runtime or backend failure stops execution. There is no unsandboxed fallback. Windows plugin execution is disabled until a suitable backend is validated. Core offckb commands still support Node 20 on all three platforms. +Use macOS or Linux, Node **22.21+ (22.x) or 24.5+**, and a working sandbox backend. macOS uses `sandbox-exec`; Linux requires bubblewrap, socat, ripgrep and permitted unprivileged user namespaces. The runtime is an optional npm dependency so core Node 20 installations remain supported; plugin users must install it on a supported Node version without omitting optional dependencies. A missing runtime or backend failure stops execution. There is no unsandboxed fallback. Windows plugin execution is not implemented in this runner. See the platform boundary below. Core offckb commands still support Node 20 on all three platforms. ```sh pnpm build @@ -91,24 +99,11 @@ In the second invocation `--json` belongs to the plugin. The host consumes only The RPC gateway only accepts bounded JSON-RPC 2.0 POST requests with array params and known read methods. Redirects, empty/over-200-call or mixed-permission batches, write/admin methods and oversized requests/responses are rejected. Browser-facing HTTP responses receive host-controlled CSP, no-referrer and nosniff headers; the bridge provides no CONNECT or WebSocket tunnel. It cannot protect information the user explicitly enters into a plugin UI or eliminate every browser-side risk. -## Explorer through a public upstream CLI - -The adapter is now [packages/plugin-explorer](../packages/plugin-explorer). It exclusively resolves upstream's declared npm `bin` and invokes its public CLI; it does not import private `src/*.ts`, inspect tsx exports, replace source strings, or accept a source-path override. The former private-source prototype and preparation script have been removed. +## Third-party adapters -The adapter requires a pinned upstream release with integration API v1: offline `--integration-info`, `--system-scripts`, explicit `--host`, outbound Unix `--bridge` transport, and a single versioned JSON ready record. Its provisional dependency is `ckb-devnet-explorer@0.3.0`. **That upstream release is not yet available ([upstream PR #1](https://github.com/wildanrhmn/ckb-devnet-explorer/pull/1)); the adapter package is private and must not be published until the upstream contract is merged/released and the exact version is confirmed.** Local tests use an npm tarball of the upstream contribution, never a private import fallback. See the [distribution gate](plugin-system-proposal.md#explorer-正式分发门禁). - -Once the released provider is available: - -```sh -npm install --prefix packages/plugin-explorer --omit=dev --ignore-scripts -node build/index.js plugin link ./packages/plugin-explorer \ - --allow devnet-read --allow project-deployment --allow serve -node build/index.js node --daemon -node build/index.js --plugin-port 7070 explorer --project . -# Other project deployment directories require explicit --read grants at link time; use canonical absolute paths for --project (symlink aliases outside the granted tree are unavailable). -``` +Each adapter is maintained and released independently by its author. The host has no built-in package list, preferred plugin, upstream-version pin or application-specific readiness logic. No particular adapter's upstream merge or npm release gates delivery of this generic plugin host. -Only ordinary devnet with `ckt` is supported. Unknown chains/forks fail before ready. RPC genesis must match context; missing/lagging indexer is reported separately. The adapter receives only read RPC access; the host exposes the page on loopback. Ctrl+C stops the tool without stopping a separately running CKB node. Restart after redeploy, reset or chain switching to reload scripts/caches. There is no arbitrary RPC override or automatic browser launch. +Adapters consume the declared context/capabilities and keep their application's CLI flags, supported chains, version checks, readiness/output translation and dependencies in their own package. Use a dependency's public, versioned interfaces and maintain its integration tests in the adapter repository. offckb tests the host with synthetic fixtures, without installing or starting any third-party application. ## Validation and limits @@ -118,11 +113,9 @@ pnpm typecheck pnpm lint pnpm build node scripts/plugin-sandbox-smoke.mjs -# Optional: public provider installed and official CKB 0.208.0 binary: -node scripts/plugin-smoke.mjs /path/to/ckb ``` -The native security test exercises the production bundle with canary secrets: forbidden reads/writes, symlink/child escape, direct network access, credential inheritance and write RPC rejection, plus positive cache/read-RPC access. CI runs it on native macOS/Linux with Node 24. The live test additionally checks Explorer page/API, ready output, SIGTERM, node survival, chain reset/restart and unknown-fork rejection. Tests isolate settings and override `os.homedir()` only in their worker, without changing HOME. +The native security test exercises the production bundle with canary secrets: forbidden reads/writes, symlink/child escape, direct network access, credential inheritance and write RPC rejection, plus positive cache/read-RPC access. CI runs it on native macOS/Linux with Node 24. Synthetic fixtures also verify argv/JSON ownership, offline help, sandboxed HTTP serving, foreground shutdown and cleanup. Tests isolate settings and override `os.homedir()` only in their worker, without changing HOME. POSIX foreground groups receive SIGINT/SIGTERM/SIGHUP, a three-second limit and final owned-process cleanup. No saved PID is signalled. Windows plugin execution is refused. Crash/SIGKILL can leave registry/run locks or temporary files: confirm the tool has stopped before manual cleanup. Automatic crash recovery, concurrent instances, resource quotas, background services and arbitrary network grants are deferred. A second-tool `ckb-tui` adapter still needs native TTY and sandbox network adaptation; its existing core `status` command is unchanged. diff --git a/examples/plugins/context-probe/cli.cjs b/examples/plugins/context-probe/cli.cjs index 4007058..b90c570 100644 --- a/examples/plugins/context-probe/cli.cjs +++ b/examples/plugins/context-probe/cli.cjs @@ -4,7 +4,7 @@ if (process.argv.slice(2).includes('--help')) { console.log('Usage: offckb context-probe [arguments...]\nPrints the invocation context and unmodified arguments.'); } else { const context = JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT, 'utf8')); - // Do not dump the whole context: RPC URLs can contain credentials. + // Print only the fields this protocol probe needs. const result = { ok: true, command: 'context-probe', diff --git a/packages/plugin-explorer/README.md b/packages/plugin-explorer/README.md deleted file mode 100644 index 9a506aa..0000000 --- a/packages/plugin-explorer/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# @offckb/plugin-explorer - -Unpublished adapter for the **public** ckb-devnet-explorer CLI integration API v1. This package is private until [the upstream interface contribution](https://github.com/wildanrhmn/ckb-devnet-explorer/pull/1) is merged and released. `0.3.0` is the proposed provider version, not a claim that npm already contains it. - -Requires sandboxed offckb, macOS/Linux and Node 22.21+ (22.x) or 24.5+. Install the exact provider dependency with scripts disabled, then: - -```sh -offckb plugin link /path/to/this/package \ - --allow devnet-read --allow project-deployment --allow serve -offckb --plugin-port 7070 explorer --project . -``` - -Only an initialized ordinary devnet is supported. Forks/unknown chain identity or a mismatching genesis are rejected. Indexer lag is reported separately. Restart after deploy/reset. Additional projects require explicit `--read /absolute/project/deployment` grants when linking. - -The adapter invokes the provider's declared npm bin with `--integration-info`, then `serve --json --rpc --system-scripts --bridge `. It checks API v1 and the capabilities `system-scripts`, `host`, `bridge`, `json-ready`; one JSON ready record is required. No private imports, PATH invocation of offckb or source overrides are used. The host owns loopback serving and descendant cleanup. - -See [the plugin guide](../../docs/plugins.md) for grants, context, security limits and validation. Do not publish this package or substitute an unrelated upstream version to bypass its compatibility check. - -To reproduce pre-release integration tests, use the upstream PR commit as a normal npm provider tarball (not a source-import override): - -```sh -git clone https://github.com/RetricSu/ckb-devnet-explorer.git .prototype/explorer-public-cli -git -C .prototype/explorer-public-cli checkout a6c65b2 -npm pack ./.prototype/explorer-public-cli --ignore-scripts --pack-destination .prototype -npm install --prefix packages/plugin-explorer --no-save --package-lock=false \ - --omit=dev --ignore-scripts ./.prototype/ckb-devnet-explorer-0.3.0.tgz -pnpm build -node scripts/plugin-smoke.mjs /path/to/ckb-0.208.0 -``` - -This validates the proposed contract locally; it does not satisfy the upstream release gate. Keep these test artifacts out of the published package. diff --git a/packages/plugin-explorer/cli.mjs b/packages/plugin-explorer/cli.mjs deleted file mode 100644 index 7fe7e9b..0000000 --- a/packages/plugin-explorer/cli.mjs +++ /dev/null @@ -1,146 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { createRequire } from 'node:module'; -import { execFileSync, spawn } from 'node:child_process'; -import { parseArgs } from 'node:util'; - -const args = process.argv.slice(2); -if (args.length === 1 && ['--help', '-h'].includes(args[0])) { - console.log( - 'Usage: offckb [--plugin-port ] explorer [--project ]...\nRequires the public Devnet Explorer CLI integration API v1.', - ); - process.exit(0); -} - -let json = false; -let child; -try { - const context = JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT, 'utf8')); - json = context.output === 'json'; - if (context.apiVersion !== 1 || !context.sandbox?.http) - throw new Error('A sandboxed offckb context with HTTP serving is required'); - if ( - context.devnet.kind !== 'pure-devnet' || - context.devnet.addressPrefix !== 'ckt' || - !context.devnet.systemScriptsFile - ) - throw new Error('Explorer requires an initialized ordinary devnet; forks and unknown chains are unsupported'); - const { values } = parseArgs({ args, options: { project: { type: 'string', multiple: true } } }); - const projects = (values.project ?? [context.cwd]).map((file) => { - try { - return fs.realpathSync(path.resolve(context.cwd, file)); - } catch { - throw new Error( - 'Project path is unavailable in the sandbox; use a canonical path and grant its deployment directory', - ); - } - }); - const require = createRequire(import.meta.url); - const manifest = require.resolve('ckb-devnet-explorer/package.json'); - const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')); - if (pkg.name !== 'ckb-devnet-explorer' || pkg.version !== '0.3.0') - throw new Error('Expected ckb-devnet-explorer 0.3.0'); - const root = fs.realpathSync(path.dirname(manifest)); - const bin = fs.realpathSync(path.resolve(root, pkg.bin['ckb-devnet-explorer'])); - if (!bin.startsWith(root + path.sep) || !fs.statSync(bin).isFile()) - throw new Error('Invalid Explorer public CLI entry'); - const info = JSON.parse( - execFileSync(process.execPath, [bin, '--integration-info'], { - encoding: 'utf8', - timeout: 5000, - stdio: ['ignore', 'pipe', 'pipe'], - }), - ); - if ( - info.apiVersion !== 1 || - !['system-scripts', 'host', 'bridge', 'json-ready'].every((value) => info.capabilities?.includes(value)) - ) - throw new Error('Explorer does not provide the required public CLI integration contract'); - const rpc = async (method, params = []) => { - const response = await fetch(context.devnet.rpcUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - signal: AbortSignal.timeout(5000), - }); - const result = await response.json(); - if (!response.ok || result.error) throw new Error(`Devnet RPC ${method} is unavailable`); - return result.result; - }; - if ((await rpc('get_block_hash', ['0x0'])) !== context.devnet.genesisHash) - throw new Error('RPC genesis does not match the supplied scripts'); - const tip = BigInt(await rpc('get_tip_block_number')); - let indexerReady = false; - try { - indexerReady = BigInt((await rpc('get_indexer_tip')).block_number) >= tip; - } catch { - /* Report separately. */ - } - if (!indexerReady) console.error('Indexer is missing or behind; some queries may be incomplete'); - child = spawn( - process.execPath, - [ - bin, - 'serve', - '--json', - '--rpc', - context.devnet.rpcUrl, - '--system-scripts', - context.devnet.systemScriptsFile, - '--bridge', - context.sandbox.http.socket, - ...projects.flatMap((directory) => ['--project', directory]), - ], - { stdio: ['ignore', 'pipe', 'inherit'] }, - ); - const closed = new Promise((resolve, reject) => { - child.once('error', reject); - child.once('close', (code, signal) => resolve({ code, signal })); - }); - let requested; - for (const [signal, code] of [ - ['SIGINT', 130], - ['SIGTERM', 143], - ['SIGHUP', 129], - ]) - process.once(signal, () => { - requested = code; - child.kill(signal); - }); - let count = 0; - let buffer = ''; - let invalid = false; - child.stdout.on('data', (chunk) => { - buffer += chunk.toString(); - if (buffer.length > 65536) { - invalid = true; - child.kill('SIGTERM'); - return; - } - let newline; - while ((newline = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, newline); - buffer = buffer.slice(newline + 1); - try { - const ready = JSON.parse(line); - if (++count !== 1 || ready.ok !== true || ready.apiVersion !== 1 || ready.transport !== 'bridge') - throw new Error('Unexpected ready record'); - if (json) - console.log(JSON.stringify({ ok: true, command: 'explorer', url: context.sandbox.http.url, indexerReady })); - else console.error(`Explorer: ${context.sandbox.http.url}`); - } catch { - invalid = true; - child.kill('SIGTERM'); - } - } - }); - const exit = await closed; - if (invalid || count !== 1 || buffer.trim()) throw new Error('Explorer violated its public ready/output contract'); - process.exitCode = requested ?? exit.code ?? 1; -} catch (error) { - child?.kill('SIGTERM'); - const message = - error.code === 'MODULE_NOT_FOUND' ? 'Install the pinned Explorer provider; see the plugin README' : error.message; - console.error(json ? JSON.stringify({ ok: false, code: 'EXPLORER_FAILED', message }) : message); - process.exitCode = 1; -} diff --git a/packages/plugin-explorer/package.json b/packages/plugin-explorer/package.json deleted file mode 100644 index a6d0bdd..0000000 --- a/packages/plugin-explorer/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@offckb/plugin-explorer", - "version": "0.1.0", - "description": "Devnet Explorer through offckb's restricted plugin interface", - "license": "MIT", - "type": "module", - "files": [ - "cli.mjs", - "README.md" - ], - "engines": { - "node": "^22.21.0 || >=24.5.0" - }, - "dependencies": { - "ckb-devnet-explorer": "0.3.0" - }, - "offckb": { - "apiVersion": 1, - "command": "explorer", - "entry": "cli.mjs", - "description": "Browse the current devnet in the sandbox", - "permissions": [ - "devnet-read", - "project-deployment", - "serve" - ] - }, - "private": true -} diff --git a/scripts/plugin-sandbox-smoke.mjs b/scripts/plugin-sandbox-smoke.mjs index 721aa61..ea2dd8b 100644 --- a/scripts/plugin-sandbox-smoke.mjs +++ b/scripts/plugin-sandbox-smoke.mjs @@ -25,28 +25,39 @@ const env = { }; const makeWorker = (entry) => `require('os').homedir=()=>process.env.OFFCKB_SANDBOX_TEST_ROOT;process.argv=[process.execPath,${JSON.stringify(entry)},...process.argv.slice(1)];require(${JSON.stringify(entry)}).runCli(process.argv);`; -async function invoke(args, expected = 0, entry = cli) { +function start(args, entry = cli) { const child = spawn(process.execPath, ['-e', makeWorker(entry), '--', ...args], { cwd: project, env, stdio: ['ignore', 'pipe', 'pipe'], }); - let stdout = '', - stderr = ''; + const running = { child, stdout: '', stderr: '', code: undefined }; child.stdout.on('data', (value) => { - stdout += value; + running.stdout += value; }); child.stderr.on('data', (value) => { - stderr += value; + running.stderr += value; }); - const timer = setTimeout(() => child.kill('SIGKILL'), 20000); - const code = await new Promise((resolve, reject) => { + running.closed = new Promise((resolve, reject) => { child.once('error', reject); - child.once('close', resolve); + child.once('close', (code) => { + running.code = code; + resolve(code); + }); }); - clearTimeout(timer); - assert.equal(code, expected, stderr || stdout); - return { stdout, stderr }; + running.closed.catch(() => {}); + return running; +} +async function invoke(args, expected = 0, entry = cli) { + const running = start(args, entry); + const timer = setTimeout(() => running.child.kill('SIGKILL'), 20000); + try { + const code = await running.closed; + assert.equal(code, expected, running.stderr || running.stdout); + return { stdout: running.stdout, stderr: running.stderr }; + } finally { + clearTimeout(timer); + } } function plugin(name, code, permissions = []) { const directory = path.join(root, name); @@ -85,6 +96,19 @@ try { settings, JSON.stringify({ devnet: { rpcUrl: `http://user:rpc-canary@127.0.0.1:${rpc.address().port}` } }), ); + const probePackage = path.join(project, 'examples/plugins/context-probe'); + await invoke(['plugin', 'link', probePackage]); + const contextProbe = JSON.parse( + (await invoke(['--json', 'context-probe', '--', '--json', 'path with spaces', '--', '$literal'])).stdout, + ); + assert.deepEqual(contextProbe.argv, ['--json', 'path with spaces', '--', '$literal']); + assert.equal(contextProbe.output, 'json'); + assert.equal(contextProbe.cwd, project.replace(/\/$/, '')); + assert.equal(contextProbe.kind, 'unknown'); + assert.match((await invoke(['context-probe', '--help'])).stdout, /Usage/); + await invoke(['plugin', 'unlink', 'context-probe']); + console.log('PASS: production argv/JSON boundaries, invocation context and offline help'); + const secret = path.join(root, 'private.txt'); fs.writeFileSync(secret, 'file-canary'); const escapedWrite = path.join(root, 'escaped.txt'); @@ -145,6 +169,48 @@ try { assert.equal(upstreamCalls[0].body.method, 'get_tip_block_number'); assert.equal(upstreamCalls[0].authorization, `Basic ${Buffer.from('user:rpc-canary').toString('base64')}`); await invoke(['plugin', 'unlink', 'rpc-reader']); + const web = plugin( + 'web-fixture', + ` + const fs=require('fs'),http=require('http'),net=require('net'); + const c=JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT,'utf8')); + const server=http.createServer((req,res)=>{res.setHeader('Content-Type','text/plain');res.setHeader('Content-Security-Policy','default-src *');res.end('generic plugin fixture');}); + let ready=false; + function connect(){const socket=net.connect(c.sandbox.http.socket);socket.once('connect',()=>{server.emit('connection',socket);if(!ready){ready=true;console.log(JSON.stringify({url:c.sandbox.http.url,rpc:c.devnet.rpcUrl}));}});socket.on('error',()=>socket.destroy());socket.once('close',()=>setTimeout(connect,20));} + connect();connect(); + `, + ['serve'], + ); + await invoke(['plugin', 'link', web, '--allow', 'serve']); + const serving = start(['--json', '--plugin-port', '0', 'web-fixture']); + try { + const deadline = Date.now() + 15000; + while (!serving.stdout.includes('\n')) { + if (serving.code !== undefined || Date.now() >= deadline) + throw new Error(serving.stderr || 'HTTP fixture did not become ready'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + const ready = JSON.parse(serving.stdout); + assert.match(ready.url, /^http:\/\/127\.0\.0\.1:/); + assert.equal(ready.rpc, ''); // Serving does not imply a devnet-read grant. + const response = await fetch(ready.url, { signal: AbortSignal.timeout(5000) }); + assert.equal(await response.text(), 'generic plugin fixture'); + assert.ok(response.headers.get('Content-Security-Policy').includes("connect-src 'self'")); + assert.match((await invoke(['plugin', 'unlink', 'web-fixture'], 1)).stderr, /running/); + } finally { + serving.child.kill('SIGTERM'); + const timer = setTimeout(() => serving.child.kill('SIGKILL'), 10000); + try { + assert.equal(await serving.closed, 143, serving.stderr); + } finally { + clearTimeout(timer); + } + } + assert.equal(serving.stdout.trim().split('\n').length, 1); + assert.equal(fs.existsSync(path.join(home, 'web-fixture.running')), false); + await invoke(['plugin', 'unlink', 'web-fixture']); + console.log('PASS: generic sandboxed HTTP serving, separate capabilities, running lease and signal cleanup'); + const signalled = plugin('signal-exit', "process.kill(process.pid,'SIGTERM');"); await invoke(['plugin', 'link', signalled]); await invoke(['signal-exit'], 143); diff --git a/scripts/plugin-smoke.mjs b/scripts/plugin-smoke.mjs deleted file mode 100644 index 44c3411..0000000 --- a/scripts/plugin-smoke.mjs +++ /dev/null @@ -1,303 +0,0 @@ -// Opt-in live integration test. Requires a build, CKB 0.208.0 and the public Explorer provider installed in packages/plugin-explorer. -// All settings, ports, chain data and registrations belong to a fresh temporary directory. -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import net from 'node:net'; -import { execFileSync, spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const project = path.resolve(fileURLToPath(new URL('../', import.meta.url))); -const cli = path.join(project, 'build/index.js'); -const ckb = process.argv[2]; -if (!ckb || !fs.statSync(ckb).isFile()) throw new Error('Usage: node scripts/plugin-smoke.mjs '); -const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'offckb plugin smoke '))); -const chain = path.join(root, 'devnet'); -const home = path.join(root, 'plugins'); -const env = { - ...process.env, - OFFCKB_TEST_SECRET: 'sandbox-canary', - SSH_AUTH_SOCK: '/private/sandbox-canary-agent.sock', - OFFCKB_SMOKE_ROOT: root, - OFFCKB_PLUGIN_HOME: home, - OFFCKB_CLI_PATH: cli, - XDG_CONFIG_HOME: path.join(root, 'config'), - XDG_DATA_HOME: path.join(root, 'data'), - XDG_CACHE_HOME: path.join(root, 'cache'), - APPDATA: path.join(root, 'appdata'), - LOCALAPPDATA: path.join(root, 'localappdata'), -}; -// Redirect os.homedir in this test worker before the production bundle loads. HOME itself stays unchanged. -const worker = `require('os').homedir = () => process.env.OFFCKB_SMOKE_ROOT; process.argv = [process.execPath, ${JSON.stringify(cli)}, ...process.argv.slice(1)]; require(${JSON.stringify(cli)}).runCli(process.argv);`; -const children = new Set(); -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -async function until(check, description) { - const deadline = Date.now() + 20000; - while (Date.now() < deadline) { - if (await check()) return; - await wait(50); - } - throw new Error(`Timed out: ${description}`); -} -async function freePort() { - const server = net.createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const port = server.address().port; - await new Promise((resolve) => server.close(resolve)); - return port; -} -function invoke(args) { - return execFileSync(process.execPath, ['-e', worker, '--', ...args], { - cwd: project, - env, - encoding: 'utf8', - timeout: 15000, - stdio: ['ignore', 'pipe', 'pipe'], - }); -} -function start(args) { - const child = spawn(process.execPath, ['-e', worker, '--', ...args], { - cwd: project, - env, - stdio: ['ignore', 'pipe', 'pipe'], - }); - const state = { child, stdout: '', stderr: '', code: undefined }; - children.add(state); - child.stdout.on('data', (chunk) => { - state.stdout += chunk; - }); - child.stderr.on('data', (chunk) => { - state.stderr += chunk; - }); - state.closed = new Promise((resolve) => - child.once('close', (code) => { - state.code = code; - children.delete(state); - resolve(code); - }), - ); - return state; -} -async function stop(state) { - if (state.code !== undefined) return; - state.child.kill('SIGTERM'); - const timer = setTimeout(() => state.child.kill('SIGKILL'), 10000); - try { - await state.closed; - } finally { - clearTimeout(timer); - } -} -async function rpc(url, method) { - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: [] }), - signal: AbortSignal.timeout(3000), - }); - const value = await response.json(); - if (value.error) throw new Error(JSON.stringify(value.error)); - return value.result; -} - -try { - const [rpcPort, proxyPort, tcpPort, p2pPort] = await Promise.all([freePort(), freePort(), freePort(), freePort()]); - const rpcUrl = `http://127.0.0.1:${rpcPort}`; - fs.cpSync(path.join(project, 'ckb/devnet'), chain, { - recursive: true, - filter: (file) => file !== path.join(project, 'ckb/devnet/data'), - }); - const config = path.join(chain, 'ckb.toml'); - fs.writeFileSync( - config, - fs - .readFileSync(config, 'utf8') - .replaceAll('127.0.0.1:8114', `127.0.0.1:${rpcPort}`) - .replaceAll('127.0.0.1:18114', `127.0.0.1:${tcpPort}`) - .replaceAll('/ip4/0.0.0.0/tcp/8115', `/ip4/127.0.0.1/tcp/${p2pPort}`), - ); - const miner = path.join(chain, 'ckb-miner.toml'); - fs.writeFileSync(miner, fs.readFileSync(miner, 'utf8').replaceAll('http://ckb:8114/', rpcUrl)); - const bins = path.join(root, 'bins'); - const binary = path.join(bins, '0.208.0', process.platform === 'win32' ? 'ckb.exe' : 'ckb'); - fs.mkdirSync(path.dirname(binary), { recursive: true }); - fs.copyFileSync(ckb, binary); - fs.chmodSync(binary, 0o755); - const settings = - process.platform === 'darwin' - ? path.join(root, 'Library/Preferences/offckb-nodejs/settings.json') - : process.platform === 'win32' - ? path.join(env.APPDATA, 'offckb-nodejs/Config/settings.json') - : path.join(env.XDG_CONFIG_HOME, 'offckb-nodejs/settings.json'); - fs.mkdirSync(path.dirname(settings), { recursive: true }); - fs.writeFileSync( - settings, - JSON.stringify({ - bins: { rootFolder: bins, defaultCKBVersion: '0.208.0' }, - devnet: { - rpcUrl, - rpcProxyPort: proxyPort, - configPath: chain, - dataPath: path.join(chain, 'data'), - transactionsPath: path.join(chain, 'transactions'), - debugFullTransactionsPath: path.join(chain, 'full-transactions'), - }, - }), - ); - - invoke(['--json', 'plugin', 'link', path.join(project, 'examples/plugins/context-probe')]); - const probe = JSON.parse(invoke(['--json', 'context-probe', '--', '--json', 'a b', '--', 'tail'])); - assert.deepEqual(probe.argv, ['--json', 'a b', '--', 'tail']); - assert.equal(probe.output, 'json'); - assert.equal(probe.cliEntry, cli); - assert.equal(probe.kind, 'pure-devnet'); - assert.match(invoke(['context-probe', '--help']), /Usage/); - console.log('PASS: production bundle, exact argv, single JSON result, offline help'); - - const validConfig = fs.readFileSync(config, 'utf8'); - try { - fs.writeFileSync(config, 'invalid = ['); - const unavailable = JSON.parse(invoke(['--json', 'context-probe'])); - assert.equal(unavailable.kind, 'unknown'); - assert.equal(unavailable.diagnosticCode, 'LIST_HASHES_FAILED'); - assert.equal(fs.existsSync(path.join(home, 'context-probe.running')), false); - assert.deepEqual(fs.readdirSync(path.join(home, 'tmp')), []); - } finally { - fs.writeFileSync(config, validConfig); - } - console.log('PASS: failed local list-hashes does not block a chain-independent plugin, clean context'); - - const attacker = path.join(root, 'attacker'); - fs.mkdirSync(attacker); - const secret = path.join(root, 'private.txt'); - const escapedWrite = path.join(root, 'escaped.txt'); - fs.writeFileSync(escapedWrite, 'host-write-canary'); - fs.writeFileSync(secret, 'private-canary'); - const listener = net.createServer((socket) => { - socket.destroy(); - throw new Error('Sandbox escaped network isolation'); - }); - await new Promise((resolve) => listener.listen(0, '127.0.0.1', resolve)); - fs.writeFileSync( - path.join(attacker, 'package.json'), - JSON.stringify({ - name: 'sandbox-probe', - version: '1.0.0', - offckb: { apiVersion: 1, command: 'sandbox-probe', entry: 'cli.cjs' }, - }), - ); - fs.writeFileSync( - path.join(attacker, 'cli.cjs'), - ` - const fs=require('fs'),cp=require('child_process'),net=require('net'); - const context=JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT,'utf8')); - const denied=(fn)=>{try{fn();return false;}catch{return true;}}; - const link=context.plugin.dataPath+'/escape';fs.symlinkSync(${JSON.stringify(secret)},link); - fs.writeFileSync(context.plugin.cachePath+'/own','allowed'); - const result={read:denied(()=>fs.readFileSync(${JSON.stringify(secret)})),write:denied(()=>fs.writeFileSync(${JSON.stringify(escapedWrite)},'bad')),symlink:denied(()=>fs.readFileSync(link)),child:denied(()=>cp.execFileSync('/bin/cat',[${JSON.stringify(secret)}],{stdio:'pipe'})),env:process.env.OFFCKB_TEST_SECRET===undefined&&process.env.SSH_AUTH_SOCK===undefined,rpc:context.devnet.rpcUrl===''}; - const socket=net.connect(${listener.address().port},'127.0.0.1'); - const timer=setTimeout(()=>{socket.destroy();result.network=true;finish();},1000); - function finish(){clearTimeout(timer);console.log(JSON.stringify(result));} - socket.once('connect',()=>{socket.destroy();result.network=false;finish();}); - socket.once('error',()=>{result.network=true;finish();}); - `, - ); - try { - invoke(['plugin', 'link', attacker]); - const result = JSON.parse(invoke(['--json', 'sandbox-probe'])); - assert.equal(fs.readFileSync(escapedWrite, 'utf8'), 'host-write-canary'); - if (process.platform === 'linux') delete result.write; - assert.ok(Object.values(result).every((value) => value === true)); - invoke(['plugin', 'unlink', 'sandbox-probe']); - } finally { - await new Promise((resolve) => listener.close(resolve)); - } - console.log( - 'PASS: OS sandbox blocks secret reads, writes, symlink/child escapes, inherited credentials and direct networking', - ); - - const deployedProject = path.join(root, 'deployed project'); - const deployment = path.join(deployedProject, 'deployment'); - fs.mkdirSync(deployment, { recursive: true }); - fs.writeFileSync( - path.join(deployment, 'scripts.json'), - JSON.stringify({ - devnet: { - 'sandbox-fixture': { script: { codeHash: '0x' + 'ab'.repeat(32), hashType: 'type' } }, - }, - }), - ); - invoke([ - '--json', - 'plugin', - 'link', - path.join(project, 'packages/plugin-explorer'), - '--read', - deployment, - '--allow', - 'devnet-read', - '--allow', - 'project-deployment', - '--allow', - 'serve', - ]); - for (let cycle = 0; cycle < 2; cycle++) { - const node = start(['--json', 'node']); - try { - await until(async () => { - if (node.code !== undefined) throw new Error(`Node exited: ${node.stderr}`); - try { - return (await rpc(rpcUrl, 'get_tip_block_number')) !== undefined; - } catch { - return false; - } - }, 'devnet RPC'); - const explorer = start(['--json', '--plugin-port', '0', 'explorer', '--project', deployedProject]); - try { - await until(() => { - if (explorer.code !== undefined) throw new Error(`Explorer exited: ${explorer.stderr}`); - return explorer.stdout.trim().startsWith('{'); - }, 'Explorer ready result'); - const ready = JSON.parse(explorer.stdout); - assert.match(ready.url, /^http:\/\/127\.0\.0\.1:/); - assert.equal((await fetch(ready.url)).status, 200); - const info = await (await fetch(`${ready.url}/api/info`)).json(); - assert.match(info.rpcUrl, /^http:\/\/127\.0\.0\.1:/); - assert.notEqual(info.rpcUrl, rpcUrl); - assert.deepEqual(info.projects, [deployedProject]); - const overview = await fetch(`${ready.url}/api/home`); - assert.equal(overview.status, 200); - const summary = await overview.json(); - assert.equal(summary.knownScripts.project, 1); - assert.equal(summary.scripts[0].name, 'sandbox-fixture'); - assert.throws(() => invoke(['--json', 'plugin', 'unlink', 'explorer']), /Command failed/); - } finally { - await stop(explorer); - } - assert.equal(explorer.code, 143); - assert.equal(explorer.stdout.trim().split('\n').length, 1); - assert.equal(fs.existsSync(path.join(home, 'explorer.running')), false); - assert.deepEqual(fs.readdirSync(path.join(home, 'tmp')), []); - assert.ok(await rpc(rpcUrl, 'get_tip_block_number')); - console.log( - `PASS: live Explorer cycle ${cycle + 1}, page/API, RPC context, SIGTERM, CKB remains up, clean context`, - ); - } finally { - await stop(node); - } - if (cycle === 0) invoke(['--json', 'clean', '--data']); - } - fs.writeFileSync(path.join(chain, 'fork.json'), JSON.stringify({ source: 'custom', firstRunPending: false })); - assert.throws(() => invoke(['--json', '--plugin-port', '0', 'explorer']), /Command failed/); - fs.unlinkSync(path.join(chain, 'fork.json')); - invoke(['--json', 'plugin', 'unlink', 'explorer']); - assert.deepEqual(fs.readdirSync(path.join(home, 'tmp')), []); - console.log('PASS: chain reset/restart, unknown fork rejection, unlink'); -} finally { - await Promise.all([...children].map(stop)); - fs.rmSync(root, { recursive: true, force: true }); -} From 1900099f416848b59f26ceb463e3c7bf386dfa3b Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 30 Oct 2026 18:48:12 +0800 Subject: [PATCH 10/10] fix: route plugin HTTP serving through the sandbox proxy --- docs/plugin-system-proposal.md | 2 +- docs/plugins.md | 17 +++++++++--- scripts/plugin-sandbox-smoke.mjs | 32 ++++++++++++++++++++++- src/plugins/broker.ts | 44 ++++++++++++++++++++++---------- src/plugins/context.ts | 6 ++++- src/plugins/sandbox.ts | 16 +++++++----- tests/plugin-broker.test.ts | 36 ++++++++++++++++++-------- 7 files changed, 116 insertions(+), 37 deletions(-) diff --git a/docs/plugin-system-proposal.md b/docs/plugin-system-proposal.md index c012ebb..d55e4e3 100644 --- a/docs/plugin-system-proposal.md +++ b/docs/plugin-system-proposal.md @@ -59,7 +59,7 @@ - 无 `devnet-read` 授权时 RPC 字段为空;有授权时只暴露宿主只读网关,原始 URL 和认证留在宿主。 - 链元数据由托管 CKB 本地 `list-hashes` 提供,限时三秒;缺失或失败降级为 `unknown`,必要时附脱敏诊断。需要链身份的插件自行决定是否拒绝,并验证 RPC genesis。 - `project-deployment` 授予调用项目 deployment 的只读访问;额外路径用 `--read` 显式授权。路径字符串本身不授予访问权。 -- `serve` 允许插件通过指定 Unix socket 主动提供 HTTP 连接,由宿主监听 loopback 并设置响应安全头。宿主不规定第三方应用的启动参数或内部服务 API。 +- `serve` 允许插件通过受限代理向宿主建立带随机令牌认证的反向 HTTP 连接(Unix socket 限制保持开启),由宿主监听 loopback 并设置响应安全头。宿主不规定第三方应用的启动参数或内部服务 API。 - 沙箱进程 cwd 是插件包目录,项目相对路径按 context.cwd 解释。Node/CLI 路径只是信息,不代表能够绕过权限调用核心。 所有参数以 argv 数组传递,不拼 shell 命令。核心 `--json` 和 `--plugin-port` 必须位于插件命令之前;命令之后的 token 属于插件,仅消费紧随命令名的第一个 `--`。现有核心命令的标志兼容逻辑保持原有行为。 diff --git a/docs/plugins.md b/docs/plugins.md index 2f38752..ffbae0a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -6,7 +6,7 @@ Plugins are independent npm-shaped packages with a static declaration and a preb This runner currently supports macOS/Linux sandbox execution. The static package protocol itself is portable. Windows users can use core offckb commands and manage registrations; running a plugin fails before acquiring its lease or starting its code. -The pinned sandbox-runtime 0.0.81 Windows backend uses a dedicated account, ACLs and machine-wide WFP filters. Its [documented limitations](https://github.com/anthropics/sandbox-runtime/blob/v0.0.81/README.md#known-limitations) include token-based network-fence bypasses and traffic carried by services such as SMB/WSL outside that fence. Our runner also needs native Windows adaptation for paths, HTTP transport and process-tree lifetime. Merely removing the platform check or passing ordinary Windows child-process tests would not validate the file/network boundary. +The pinned sandbox-runtime 0.0.81 Windows backend uses a dedicated account, ACLs and machine-wide WFP filters. Its [documented limitations](https://github.com/anthropics/sandbox-runtime/blob/v0.0.81/README.md#known-limitations) include token-based network-fence bypasses and traffic carried by services such as SMB/WSL outside that fence. Our runner also needs native Windows adaptation and validation for paths, transport and process-tree lifetime. Merely removing the platform check or passing ordinary Windows child-process tests would not validate the file/network boundary. Windows enablement requires an explicitly chosen isolation backend, user-visible installation requirements, and native positive/negative tests for file access, credentials, direct/loopback networking and descendant cleanup. There is no fallback that silently runs with the user's unrestricted permissions. This is a current host implementation limit; it is independent of any plugin's functionality or release schedule. @@ -62,7 +62,7 @@ offckb plugin link /path/to/package --allow devnet-read --read /path/to/report.j | None | Read package/runtime dependencies and invocation context; write back to host only through own data/cache and temporary directory | | `devnet-read` | Reach only a host-owned JSON-RPC gateway with an explicit read-method allowlist; no transaction submission or node administration | | `project-deployment` | Read the invocation project's `deployment/` directory; an escaping directory symlink is rejected | -| `serve` | Serve HTTP over a designated Unix socket to a host-owned `127.0.0.1` listener; no arbitrary TCP bind or loopback access | +| `serve` | Serve HTTP over an authenticated reverse connection to a host-owned `127.0.0.1` listener; no arbitrary TCP bind or loopback access | | `--read ` | Read the canonical file/directory, recursively for directories; treat this as disclosure of its contents | Capabilities do not grant arbitrary Internet access, project writes, access to other plugins' data, SSH agents, keys or core settings. Manifest changes that add capabilities invalidate the registration until explicitly relinked. Local package source remains mutable; review changes before running it again. @@ -78,7 +78,7 @@ The host uses the pinned [Anthropic sandbox runtime](https://github.com/anthropi - `plugin.command`, `plugin.dataPath`, `plugin.cachePath`: plugin-specific persistent storage. - `devnet.kind`, `genesisHash`, `addressPrefix`, `systemScriptsFile`, `systemScriptsVersion: 1`. - `devnet.rpcUrl` and `proxyUrl`: empty without `devnet-read`; otherwise the restricted gateway URL. Original RPC URLs, userinfo and credentials stay in the host. -- `sandbox.backend: "os"`, granted `permissions`, and optional `http: { socket, url }` for `serve`. +- `sandbox.backend: "os"`, granted `permissions`, and optional `http: { transport: "proxy-connect-v1", endpoint, token, url }` for `serve`. - Optional `devnet.diagnostic: { code: "LIST_HASHES_FAILED", message: string }`. Consumers must tolerate additive API v1 fields. The process working directory is the package directory. Resolve project-relative arguments against **context.cwd**; that path alone grants no filesystem access. Only the listed paths/capabilities are available. The system scripts file uses the existing `devnet` record shape. Context/scripts and sandbox scratch storage are removed on normal/handled exit; data/cache remain. No account keys or full settings are exported. @@ -99,6 +99,17 @@ In the second invocation `--json` belongs to the plugin. The host consumes only The RPC gateway only accepts bounded JSON-RPC 2.0 POST requests with array params and known read methods. Redirects, empty/over-200-call or mixed-permission batches, write/admin methods and oversized requests/responses are rejected. Browser-facing HTTP responses receive host-controlled CSP, no-referrer and nosniff headers; the bridge provides no CONNECT or WebSocket tunnel. It cannot protect information the user explicitly enters into a plugin UI or eliminate every browser-side risk. +## HTTP serving transport + +For `serve`, `sandbox.http.url` is the public loopback URL. `endpoint` is a separate host-owned relay, and `token` authenticates this invocation. Keep the token private; it is not a browser URL or a persisted permission. + +1. Read the sandbox-injected `HTTP_PROXY`/`http_proxy`. Open an HTTP CONNECT to `endpoint` through that proxy, including its proxy credentials. Only this relay destination is allowed by the `serve` grant. +2. After the proxy returns 200, send the UTF-8 line `offckb-http/1 \n`. The host rejects a wrong/missing token and limits handshake size/time. +3. Wait for `OK\n`, then serve ordinary HTTP/1.1 requests on the established connection, in the reverse direction. Handle partial frames and preserve any bytes following the acknowledgement. Maintain several connections and reconnect closed ones; the host uses one request per connection with `Connection: close`. +4. Attach the HTTP handler before announcing readiness. The plugin chooses its own output record; the host never parses application logs. + +Both macOS and Linux keep Unix-socket creation/access blocked. No arbitrary bind or direct loopback permission is added to support serving. The public listener rejects CONNECT/Upgrade requests; the internal relay accepts only authenticated reverse channels and never dials a plugin-selected destination. The synthetic HTTP fixture in [the native smoke test](../scripts/plugin-sandbox-smoke.mjs) demonstrates this transport without an application dependency. + ## Third-party adapters Each adapter is maintained and released independently by its author. The host has no built-in package list, preferred plugin, upstream-version pin or application-specific readiness logic. No particular adapter's upstream merge or npm release gates delivery of this generic plugin host. diff --git a/scripts/plugin-sandbox-smoke.mjs b/scripts/plugin-sandbox-smoke.mjs index ea2dd8b..60473e7 100644 --- a/scripts/plugin-sandbox-smoke.mjs +++ b/scripts/plugin-sandbox-smoke.mjs @@ -175,13 +175,40 @@ try { const fs=require('fs'),http=require('http'),net=require('net'); const c=JSON.parse(fs.readFileSync(process.env.OFFCKB_PLUGIN_CONTEXT,'utf8')); const server=http.createServer((req,res)=>{res.setHeader('Content-Type','text/plain');res.setHeader('Content-Security-Policy','default-src *');res.end('generic plugin fixture');}); + const unixBlocked=new Promise(resolve=>{const socket=net.connect(__dirname+'/u');socket.once('error',()=>resolve(true));socket.once('connect',()=>{socket.destroy();resolve(false);});}); let ready=false; - function connect(){const socket=net.connect(c.sandbox.http.socket);socket.once('connect',()=>{server.emit('connection',socket);if(!ready){ready=true;console.log(JSON.stringify({url:c.sandbox.http.url,rpc:c.devnet.rpcUrl}));}});socket.on('error',()=>socket.destroy());socket.once('close',()=>setTimeout(connect,20));} + function connect(){ + const proxy=new URL(process.env.HTTP_PROXY||process.env.http_proxy); + const auth=Buffer.from(decodeURIComponent(proxy.username)+':'+decodeURIComponent(proxy.password)).toString('base64'); + const request=http.request({hostname:proxy.hostname,port:proxy.port,method:'CONNECT',path:c.sandbox.http.endpoint,headers:{Host:c.sandbox.http.endpoint,'Proxy-Authorization':'Basic '+auth},agent:false}); + request.once('connect',(response,socket,head)=>{ + if(response.statusCode!==200||head.length){socket.destroy();return;} + socket.on('error',()=>socket.destroy());socket.once('close',()=>setTimeout(connect,20)); + let ack=Buffer.alloc(0); + const authenticate=data=>{ + ack=Buffer.concat([ack,data]);const end=ack.indexOf(10); + if(end<0){if(ack.length>3)socket.destroy();return;} + if(end!==2||ack.subarray(0,3).toString()!=='OK\\n'){socket.destroy();return;} + socket.removeListener('data',authenticate);socket.pause();server.emit('connection',socket); + if(ack.length>3)socket.unshift(ack.subarray(3));socket.resume(); + if(!ready){ready=true;unixBlocked.then(blocked=>console.log(JSON.stringify({url:c.sandbox.http.url,rpc:c.devnet.rpcUrl,unixBlocked:blocked})));} + }; + socket.on('data',authenticate); + socket.write('offckb-http/1 '+c.sandbox.http.token+'\\n'); + }); + request.once('error',error=>{console.error(error.message);setTimeout(connect,100);});request.end(); + } connect();connect(); `, ['serve'], ); await invoke(['plugin', 'link', web, '--allow', 'serve']); + let unixConnections = 0; + const unixCanary = net.createServer((socket) => { + unixConnections++; + socket.destroy(); + }); + await new Promise((resolve) => unixCanary.listen(path.join(web, 'u'), resolve)); const serving = start(['--json', '--plugin-port', '0', 'web-fixture']); try { const deadline = Date.now() + 15000; @@ -193,11 +220,14 @@ try { const ready = JSON.parse(serving.stdout); assert.match(ready.url, /^http:\/\/127\.0\.0\.1:/); assert.equal(ready.rpc, ''); // Serving does not imply a devnet-read grant. + assert.equal(ready.unixBlocked, true); + assert.equal(unixConnections, 0); // HTTP serving must not open unrelated Unix IPC. const response = await fetch(ready.url, { signal: AbortSignal.timeout(5000) }); assert.equal(await response.text(), 'generic plugin fixture'); assert.ok(response.headers.get('Content-Security-Policy').includes("connect-src 'self'")); assert.match((await invoke(['plugin', 'unlink', 'web-fixture'], 1)).stderr, /running/); } finally { + unixCanary.close(); serving.child.kill('SIGTERM'); const timer = setTimeout(() => serving.child.kill('SIGKILL'), 10000); try { diff --git a/src/plugins/broker.ts b/src/plugins/broker.ts index 2b47abd..edd359f 100644 --- a/src/plugins/broker.ts +++ b/src/plugins/broker.ts @@ -1,5 +1,6 @@ import http from 'http'; import net from 'net'; +import { randomBytes, timingSafeEqual } from 'crypto'; const READ_METHODS = new Set([ 'get_block', @@ -121,10 +122,17 @@ export async function createReadRpcBroker(source: string): Promise<{ url: string } /** - * The plugin connects out to this private socket and serves HTTP on that connection. - * The host never follows a plugin-writable socket pathname to another local service. + * The plugin reaches this endpoint through the restricted egress proxy, authenticates, + * then serves HTTP in the reverse direction. No plugin-selected destination is dialled. */ -export async function createHttpBridge(socketPath: string, port: number): Promise<{ url: string; close: () => void }> { +export async function createHttpBridge(port: number): Promise<{ + endpoint: string; + token: string; + url: string; + close: () => void; +}> { + const token = randomBytes(32).toString('hex'); + const hello = Buffer.from(`offckb-http/1 ${token}\n`); const sockets = new Set(); const idle: net.Socket[] = []; const waiters: Array<(socket?: net.Socket) => void> = []; @@ -140,9 +148,22 @@ export async function createHttpBridge(socketPath: string, port: number): Promis const i = idle.indexOf(socket); if (i >= 0) idle.splice(i, 1); }); - const waiter = waiters.shift(); - if (waiter) waiter(socket); - else idle.push(socket); + socket.setTimeout(3000, () => socket.destroy()); + let received = Buffer.alloc(0); + const authenticate = (chunk: Buffer) => { + received = Buffer.concat([received, chunk]); + if (received.length > hello.length) return socket.destroy(); + if (!received.includes(10)) return; + if (received.length !== hello.length || !timingSafeEqual(received, hello)) return socket.destroy(); + socket.removeListener('data', authenticate); + socket.setTimeout(0); + socket.pause(); + socket.write('OK\n'); + const waiter = waiters.shift(); + if (waiter) waiter(socket); + else idle.push(socket); + }; + socket.on('data', authenticate); }); const take = () => new Promise((resolve, reject) => { @@ -199,6 +220,7 @@ export async function createHttpBridge(socketPath: string, port: number): Promis res.end('Plugin HTTP service is unavailable'); }); req.on('aborted', () => upstream.destroy()); + socket.resume(); req.pipe(upstream); } catch { if (!res.headersSent) res.writeHead(503); @@ -215,14 +237,8 @@ export async function createHttpBridge(socketPath: string, port: number): Promis bridge.close(); }; try { - await new Promise((resolve, reject) => { - bridge.once('error', reject); - bridge.listen(socketPath, () => { - bridge.removeListener('error', reject); - resolve(); - }); - }); - return { url: `http://127.0.0.1:${await listen(server, port)}`, close }; + const bridgePort = await listen(bridge, 0); + return { endpoint: `127.0.0.1:${bridgePort}`, token, url: `http://127.0.0.1:${await listen(server, port)}`, close }; } catch (error) { close(); throw error; diff --git a/src/plugins/context.ts b/src/plugins/context.ts index e1ce874..b60cd51 100644 --- a/src/plugins/context.ts +++ b/src/plugins/context.ts @@ -14,7 +14,11 @@ export interface PluginContext { output: 'json' | 'text'; cli: { node: string; entry: string | null }; plugin: { command: string; dataPath: string; cachePath: string }; - sandbox?: { backend: 'os'; permissions: PluginManifest['permissions']; http?: { socket: string; url: string } }; + sandbox?: { + backend: 'os'; + permissions: PluginManifest['permissions']; + http?: { transport: 'proxy-connect-v1'; endpoint: string; token: string; url: string }; + }; devnet: { rpcUrl: string; proxyUrl: string; diff --git a/src/plugins/sandbox.ts b/src/plugins/sandbox.ts index 2faad98..ec74d5a 100644 --- a/src/plugins/sandbox.ts +++ b/src/plugins/sandbox.ts @@ -70,7 +70,6 @@ export async function preparePluginSandbox( if (process.platform === 'linux') read.push(path.join(runtimeDirectory, 'vendor/seccomp')); const write = [root]; const allowedDomains: string[] = []; - const sockets: string[] = []; if (context) { context.plugin.dataPath = fs.realpathSync(context.plugin.dataPath); context.plugin.cachePath = fs.realpathSync(context.plugin.cachePath); @@ -100,11 +99,15 @@ export async function preparePluginSandbox( allowedDomains.push(new URL(rpc.url).host); } if (plugin.permissions.includes('serve')) { - const socket = path.join(root, 'http.sock'); - const http = await createHttpBridge(socket, port); + const http = await createHttpBridge(port); cleanup.push(http.close); - context.sandbox.http = { socket, url: http.url }; - sockets.push(socket); + context.sandbox.http = { + transport: 'proxy-connect-v1', + endpoint: http.endpoint, + token: http.token, + url: http.url, + }; + allowedDomains.push(http.endpoint); } fs.writeFileSync(contextFile!, JSON.stringify(context), { mode: 0o600 }); } @@ -123,7 +126,8 @@ export async function preparePluginSandbox( allowedDomains, deniedDomains: [], strictAllowlist: true, - allowUnixSockets: sockets, + allowUnixSockets: [], + allowAllUnixSockets: false, allowLocalBinding: false, }, allowAppleEvents: false, diff --git a/tests/plugin-broker.test.ts b/tests/plugin-broker.test.ts index f83ecff..4f741d6 100644 --- a/tests/plugin-broker.test.ts +++ b/tests/plugin-broker.test.ts @@ -1,6 +1,3 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; import http from 'http'; import net from 'net'; import { createHttpBridge, createReadRpcBroker } from '../src/plugins/broker'; @@ -49,19 +46,36 @@ it('only forwards read RPC calls and keeps upstream credentials inside the host' } }); -const posixIt = process.platform === 'win32' ? it.skip : it; -posixIt('forwards HTTP over a plugin-initiated connection and enforces host response headers', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ocb-')); - const socketPath = path.join(root, 'http.sock'); - const bridge = await createHttpBridge(socketPath, 0); +it('forwards HTTP over a plugin-initiated connection and enforces host response headers', async () => { + const bridge = await createHttpBridge(0); const app = http.createServer((_req, res) => { res.setHeader('Content-Security-Policy', 'default-src *'); res.end('sandboxed page'); }); - const connection = net.connect(socketPath); + const connect = () => net.connect(Number(bridge.endpoint.split(':')[1]), '127.0.0.1'); + const invalid = connect(); + const refused = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Unauthenticated connection was not rejected')), 1000); + invalid.on('error', () => {}); + invalid.once('close', () => { + clearTimeout(timer); + resolve(); + }); + invalid.once('connect', () => invalid.write('offckb-http/1 wrong-token\n')); + }); + const connection = connect(); connection.on('error', () => {}); - connection.once('connect', () => app.emit('connection', connection)); + const authenticated = new Promise((resolve) => { + connection.once('connect', () => connection.write(`offckb-http/1 ${bridge.token}\n`)); + connection.once('data', (data) => { + expect(data.toString()).toBe('OK\n'); + app.emit('connection', connection); + resolve(); + }); + }); try { + await refused; + await authenticated; const response = await fetch(bridge.url); expect(await response.text()).toBe('sandboxed page'); expect(response.headers.get('Content-Security-Policy')).toContain("connect-src 'self'"); @@ -69,6 +83,6 @@ posixIt('forwards HTTP over a plugin-initiated connection and enforces host resp } finally { connection.destroy(); bridge.close(); - fs.rmSync(root, { recursive: true, force: true }); + invalid.destroy(); } });