diff --git a/.changeset/plugin-system-proposal.md b/.changeset/plugin-system-proposal.md new file mode 100644 index 0000000..f3d552d --- /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. 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/.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..9e284bf 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 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 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..816b431 --- /dev/null +++ b/docs/plugin-system-proposal.md @@ -0,0 +1,212 @@ +# offckb 工具插件系统:以 Devnet Explorer 为首个案例 + +状态:**设计讨论稿;本地协议已实现,正在补齐强制沙箱与公开 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)为准。 + +## 建议 + +值得做一个轻量的工具插件系统。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 和故障边界。当前实现必须再套 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", + "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、实际地址前缀。 +- 本地 `list-hashes` 失败时,链身份和脚本降级为未知,并附可选的 `devnet.diagnostic`(固定错误码及脱敏说明);不需要链的工具仍可运行,需要链的工具自行拒绝未知状态。 +- 本次生成的系统脚本 JSON 文件路径;沿用现有导出格式,明确记录格式版本。 +- 当前 CLI 的 Node 和入口文件绝对路径,作为必要时调用核心命令的备用方式。 +- 输出格式;结构化输出模式必须由适配器遵守,不能直接混入上游的人类日志。 + +不复制完整 settings、账户私钥或不相关配置。当前运行器以 OS 文件隔离约束可见范围,RPC URL 替换为只读网关或空字符串,认证留在宿主;临时快照在子进程退出后清理。协议快照仍应视为可能被插件修改,不能回读其内容作为宿主授权依据。 + +核心在实际调用时解析上下文;顶层帮助、`plugin list` 只读声明,插件 `--help` 不要求链在线。插件拿到上下文后进行业务所需的就绪检查。所有参数作为 argv 数组转发;沙箱中 cwd 为插件包目录,项目相对路径按上下文 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:适合接入,但需要明确适配 + +历史调研版本(以下是旧版事实,当前实现只接公开 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`;不能声称外层包装已经解决 | + +依据:[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 侧维护协议及适配包,Explorer 上游维护业务及 UI。具体维护者尚未确定,也没有获得上游承诺。Phase 1 的命令协议原型先使用最小假插件,不等待上游;本地 Explorer 实验可使用明确标注的临时适配分支。正式分发还必须满足下节门槛,否则保留实验结果、暂缓插件发布。 + +此外,上游缓存只在 tip 回退时清除,脚本名也在启动时加载。清链或换链后统一重启 Explorer,避免把“tip 没回退”误认为链未变化。支持普通 devnet 的首版也应验证这一场景。 + +### Explorer 正式分发门禁 + +这是 Explorer 正式分发的合并前置条件。当前选择第 2 条,私有源码接入已删除;未发布的适配包不等于门禁已通过。正式分发必须先确定维护者,并完成以下二选一: + +1. 上游合并并发布显式 host、系统脚本文件输入及受 semver 约束的 `startServer` 等公开入口;适配包通过正常包解析使用这些入口。 +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 不应合并。 + +## 分阶段落地 + +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,输出所有权:** 已补适配器拥有结果、父进程抑制兜底及标志边界;命令回归测试及生产 ncc 包冒烟已验证这些边界。 +- **M2,升级撞名:** 已补核心优先、插件 `shadowed` 及保留移除能力。 +- **M3,Windows npm:** 采纳必须验证的风险,纠正“npm.cmd 绝对路径 + shell:false 即可”的修法;当前 npm JS 入口解析及跨平台实测仍未完成。 +- **M4,维护归属:** 已提出职责及正式分发门禁;具体维护者与公开兼容契约未定,不能视为已关闭。 +- **S1/S2,复杂度:** 保留支撑“运行中不可移除”的最小互斥,通用运行状态机推迟;完整性复用 npm lockfile,不另建清单。 +- **第二工具与验证次序:** Phase 2 前以 `ckb-tui` 走查;按命令协议、Explorer 上下文往返、安装器跨平台的顺序验证。 + +## 实施时的验收点与未决项 + +- 未登记插件时,核心启动不加载插件代码,也不为插件创建 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 增加原生沙箱拒绝测试。本文不构成阶段签收。 + +以上保留整体设计与后续验收标准,不表示它们已经全部通过。第一阶段实现采用显式本地 link/list/unlink;registry 安装、后台服务与正式 Explorer 分发仍未交付。实现及验证方式见[插件指南](plugins.md)。 diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..d4c3dc5 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,129 @@ +# Sandboxed local tool plugins + +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 + +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 +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 +``` + +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. + +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 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. + +## Declaration and grants + +```json +{ + "name": "my-offckb-tool", + "version": "0.1.0", + "engines": { "node": "^22.21.0 || >=24.5.0" }, + "offckb": { + "apiVersion": 1, + "command": "my-tool", + "entry": "dist/cli.mjs", + "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. 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. + +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 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 | +| `--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. 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 + +`OFFCKB_PLUGIN_CONTEXT` points to a temporary JSON file containing: + +- `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 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 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 `--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 flags must precede the plugin name: + +```sh +offckb --json --plugin-port 7070 my-tool --project . +offckb my-tool --json +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, 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. + +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). +``` + +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 limits + +```sh +pnpm test --runInBand +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. + +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/examples/plugins/context-probe/cli.cjs b/examples/plugins/context-probe/cli.cjs new file mode 100644 index 0000000..4007058 --- /dev/null +++ b/examples/plugins/context-probe/cli.cjs @@ -0,0 +1,19 @@ +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, + diagnosticCode: context.devnet.diagnostic?.code, + 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/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..9a506aa --- /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 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 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..d45df32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,15 +140,26 @@ 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 packages: + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + '@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 +815,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 +2293,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==} @@ -2928,8 +2946,19 @@ packages: snapshots: + zod@3.25.76: + optional: true + '@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 +3872,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 +5496,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..721aa61 --- /dev/null +++ b/scripts/plugin-sandbox-smoke.mjs @@ -0,0 +1,159 @@ +// 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'); + fs.writeFileSync(escapedWrite, 'host-write-canary'); + 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));} + 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); + // 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.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: prevented host 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 new file mode 100644 index 0000000..44c3411 --- /dev/null +++ b/scripts/plugin-smoke.mjs @@ -0,0 +1,303 @@ +// 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 }); +} diff --git a/src/cli.ts b/src/cli.ts index dc765c9..5e88dc1 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 { coreCommandIndex, 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 = 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. + 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/broker.ts b/src/plugins/broker.ts new file mode 100644 index 0000000..2b47abd --- /dev/null +++ b/src/plugins/broker.ts @@ -0,0 +1,230 @@ +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', + 'get_block_economic_state', + 'tx_pool_info', + 'get_raw_tx_pool', +]); + +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')); + const calls = Array.isArray(body) ? body : [body]; + if ( + 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' }) + .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( + 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; + 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 new file mode 100644 index 0000000..5e3b85b --- /dev/null +++ b/src/plugins/cli.ts @@ -0,0 +1,125 @@ +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([ + '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)'); + const link = management + .command('link ') + .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, + permissions: plugin.permissions, + }); + }); + 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) { + 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 = 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. + 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'), program.opts().pluginPort); + }); + 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..e1ce874 --- /dev/null +++ b/src/plugins/context.ts @@ -0,0 +1,111 @@ +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 }; + sandbox?: { backend: 'os'; permissions: PluginManifest['permissions']; http?: { socket: string; url: 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; + diagnostic?: { code: 'LIST_HASHES_FAILED'; message: string }; + }; +} + +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. + let scripts: ReturnType = null; + let diagnostic: PluginContext['devnet']['diagnostic']; + if ( + isFile(path.join(settings.devnet.configPath, 'ckb.toml')) && + isFile(getCKBBinaryPath(settings.bins.defaultCKBVersion)) + ) { + 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 }); + 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 }); + // 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, + 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, + ...(diagnostic ? { diagnostic } : {}), + }, + }; + 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..5726df1 --- /dev/null +++ b/src/plugins/manifest.ts @@ -0,0 +1,78 @@ +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; + version: string; + command: string; + description: string; + entry: string; + node?: string; + permissions: PluginPermission[]; +} + +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'); + } + 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, + version: String(pkg.version), + command: declaration.command, + entry, + description: typeof declaration.description === 'string' ? declaration.description : pkg.name, + permissions: [...permissions].sort(), + ...(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..ccbfc3f --- /dev/null +++ b/src/plugins/run.ts @@ -0,0 +1,101 @@ +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'; +import { assertSandboxSupported, preparePluginSandbox } from './sandbox'; + +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 ((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. + } + } +} + +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; + 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); + sandbox = await preparePluginSandbox(lease.plugin, context?.file, lease.readPaths, args, port); + child = spawn(sandbox.command, sandbox.args, { + stdio: 'inherit', + cwd: process.cwd(), + env: sandbox.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) => { + // 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 + ? 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 { + sandbox?.dispose(); + } finally { + try { + context?.dispose(); + } finally { + lease.release(); + } + } + } +} diff --git a/src/plugins/sandbox.ts b/src/plugins/sandbox.ts new file mode 100644 index 0000000..2faad98 --- /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 { + 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[] = []; + 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 } : {}), + }; + 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 new file mode 100644 index 0000000..f91f5b8 --- /dev/null +++ b/src/plugins/store.ts @@ -0,0 +1,228 @@ +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import { dataPath } from '../cfg/setting'; +import { + assertPluginCompatible, + isPluginCommand, + PLUGIN_PERMISSIONS, + PluginManifest, + PluginPermission, + readJsonFile, + readPluginManifest, +} from './manifest'; + +interface Registration { + directory: string; + permissions: PluginPermission[]; + readPaths: string[]; +} +interface Registry { + apiVersion: 2; + plugins: Record; +} + +export interface PluginInfo { + command: string; + directory: string; + state: 'available' | 'invalid' | 'incompatible' | 'shadowed'; + name?: string; + version?: string; + description?: string; + error?: string; + permissions?: PluginPermission[]; + readPaths?: 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: 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 !== 2 || + !registry.plugins || + typeof registry.plugins !== 'object' || + Array.isArray(registry.plugins) || + Object.entries(registry.plugins).some( + ([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()}`); + 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, 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, + permissions, + readPaths, + }; + try { + assertPluginCompatible(plugin); + return { ...info, state: 'available' }; + } catch (error) { + return { ...info, state: 'incompatible', error: (error as Error).message }; + } + }); +} + +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.directory !== plugin.directory) + throw new Error(`Plugin command ${plugin.command} is already linked`); + assertNotRunning(plugin.command); + registry.plugins[plugin.command] = { directory: plugin.directory, permissions: plugin.permissions, readPaths }; + 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; readPaths: string[]; release: () => void } { + return withRegistryLock(() => { + const registry = readRegistry(); + const entry = Object.prototype.hasOwnProperty.call(registry.plugins, command) + ? registry.plugins[command] + : undefined; + 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, readPaths: entry.readPaths, 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..83f7fe2 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,61 @@ 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, 7070); + 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, 7070); + 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, 7070], + ['probe', ['second'], false, 7070], + ]); + + 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(); + }); + + 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..f83ecff --- /dev/null +++ b/tests/plugin-broker.test.ts @@ -0,0 +1,74 @@ +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); + 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(); + 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 new file mode 100644 index 0000000..fa19d05 --- /dev/null +++ b/tests/plugin-run.test.ts @@ -0,0 +1,193 @@ +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'; + +const mockCreateContext = jest.fn(); +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; +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(); +}); + +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; + 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. + 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>(); + 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/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 new file mode 100644 index 0000000..e9c5f11 --- /dev/null +++ b/tests/plugins.test.ts @@ -0,0 +1,296 @@ +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 mockExecFileSync = 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: (...args: unknown[]) => mockExecFileSync(...args), +})); +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(); + mockExecFileSync.mockReset().mockReturnValue('test hashes'); + 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('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'), ''); + 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'); + expect(value.devnet).not.toHaveProperty('diagnostic'); + run.dispose(); + expect(fs.existsSync(run.file)).toBe(false); + 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({ + kind: 'unknown', + addressPrefix: null, + 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'); + 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, + 7070, + ); +}); + +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, 7070); + } +}); + +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(); +}); + +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']); +});