从脚本到站点:Open Nua 如何让 Agent 的交付真正可运行
Open Nua Engineering
Agent 生成一段 Markdown 并不难。真正困难的是把它变成一个用户可以继续使用的交付物:DOCX 里的表格要能正常打开,PPTX 里的图表要由真实依赖生成,一个 HTML Dashboard 要能加载自己的 CSS、JavaScript 和图片,用户还要在部署前看到它、确认它,并在之后收回访问权。
这条链路看似只是“给 Agent 多装几个库,再加一个发布按钮”,实际横跨两个容易被忽视的工程问题:脚本依赖必须可复现,交付后的代码必须被隔离。
在 Open Nua 中,我们用两项相邻的改造打通了这段最后一公里。第一项把 Node.js/Python 依赖从 SKILL.md 的自然语言说明迁入标准 Plugin 项目,由 aube 和 uv 在安装期完成锁定准备;第二项把 workspace 中已经生成的 HTML Output Artifact 变成可预览、需确认、可撤销的静态站点部署。
这并不是在 Desktop 里建设一个新的 build farm 或托管平台。相反,我们刻意收窄了边界:Plugin 负责可复现地运行脚本,Artifact 负责声明结果,Desktop 负责预览与用户确认,Backend 负责不可变部署事实,Gateway 只负责受控交付,浏览器则在 opaque sandbox 中运行站点代码。
一次成功的报告,暴露了不可靠的运行时
问题最初来自一次真实的文档任务。报告最终成功交付,但生成过程经历了 Python ABI 与 module resolution 问题、临时依赖准备、NODE_PATH 调整和多轮降级。
这类任务最危险的地方,是终态“看起来成功”。如果只看最终文件,我们很容易把中间的试错当成 Agent 的正常推理;但从产品角度看,每一次临时安装和环境猜测都意味着结果依赖当前机器状态,也意味着下一次运行可能以完全不同的方式失败。
当时的 DOCX、PDF、PPTX、XLSX Skill 还是彼此独立的资源目录。它们会在说明里提醒模型安装全局 npm package、创建临时 Python 环境,或寻找 LibreOffice 与 Poppler。问题不在于文档写得不够详细,而在于一份自然语言说明同时承担了知识、依赖声明和运行时修复三种职责。
我们最终冻结了一条更简单的规则:
Skill 教 Agent 如何完成任务;需要第三方脚本依赖时,依赖必须属于 Plugin,而不是属于 Prompt。
Instruction-only Skill 仍然可以独立存在。只有当它携带需要第三方依赖的 Node.js 或 Python 脚本时,才必须进入 Plugin 包;即使只有一个 Skill,也使用 skill-only Plugin,而不再创造“可执行 standalone Skill”的平行格式。
不发明依赖格式,直接采用语言生态的事实源
Agent 平台很容易走向自研:设计一份跨语言 dependency manifest,再定义 entrypoint、cache key、环境状态机和安装 API。这样看起来统一,长期却会复制 npm、uv 和语言生态已经解决过的问题。
Open Nua 选择了相反方向。一个带双栈脚本的 Plugin 使用这样的根目录:
builtin-document/
├── plugin.json
├── package.json
├── package-lock.json
├── pyproject.toml
├── uv.lock
└── skills/
├── docx/
├── pdf/
├── pptx/
└── xlsx/
JavaScript/TypeScript 的 dependencies、Node 版本和命名 script 来自 package.json 与单一 canonical lockfile;Python 的 dependencies 与命名 command 来自 pyproject.toml 和 uv.lock。plugin.json 继续只保存 Plugin 身份与 contributions,不复制依赖或 entrypoint。
Node 侧使用 aube 管理现有 lockfile、隔离的 node_modules、内容寻址 store 和 runtime;Python 侧使用 uv 管理 lock、.venv、cache 与命令执行。Open Nua 只定义标准项目文件如何进入归档、何时同步、Host 最低安全策略是什么,以及 Agent 回合可以执行什么。
这个决定消除了三份容易漂移的事实:Skill prose 不再描述安装步骤,Host manifest 不再重复 dependency,平台也不维护另一套 resolver。
把依赖准备移出 Agent 回合
依赖可声明,还不等于运行可控。真正关键的边界,是把“准备环境”和“执行业务脚本”分成两个生命周期。
| 生命周期 | 允许发生的事 | 明确禁止的事 |
|---|---|---|
| pack / publish | 校验标准项目与 lock,执行 frozen conformance | 发布缺 lock、多 lock、path/git dependency 或弱化 Host policy 的包 |
| install / update / repair / local reload | 由 Host 调用 pinned aube/uv 准备依赖,完成后原子切换 active version | 依赖准备失败时破坏仍可运行的旧版本 |
| Agent run | 只调用已声明的命名 script / command | 联网安装、更新 lock、创建临时环境、猜测 NODE_PATH / PYTHONPATH |
实际执行分别收敛为:
aube run --no-install <script> -- <args>
uv run --locked --no-sync <command> <args>
如果依赖目录损坏,Agent 得到的是“需要修复或重新加载 Plugin 依赖”,而不是一张空白支票,让模型连续尝试 npm install -g、pip install 或 uv add。网络、registry credential、proxy、磁盘不足和 native build 失败都属于 Host 安装生命周期,不应该伪装成文档任务的一部分。
Desktop 随候选制品固定 aube 与 uv,并复用已有的 bundled Python 3.12。Node.js 不直接 bundle:Host 先校验 allowlisted PATH 中满足 Plugin semver、LTS、platform 和 architecture 要求的 Node.js 22 以上版本;没有合规 runtime 时,aube 只能在安装、更新或修复阶段下载并缓存,Agent run 期间不会触发下载。
用真实文档 Plugin 验证双栈,而不是只验证 schema
格式设计最容易通过的测试,是拿一个假的 hello world Plugin 验证 parser。它能证明字段可读,却不能证明 native addon、两套 lock、候选制品和真实文档任务能够共同工作。
因此我们把四个既有文档 Skill 聚合成 builtin document Plugin,作为第一位真实 adopter:
- DOCX 与 PPTX 富生成路径使用 locked Node dependencies;
- 图片与图表链路加载 native
sharp; - 编辑、检查与验证 helper 使用 Python
[project.scripts]; - LibreOffice、Poppler 继续作为系统 capability 独立 preflight,不伪装成 uv dependency;
- 分发归档不携带
node_modules、.venv、cache 或 Python bytecode。
在隔离的 macOS arm64 packaged profile 中,Desktop 完成了发现四个 Skill、aube/uv frozen sync、Node 生成 DOCX/PPTX、Python inspect、native sharp load、repair 与卸载。环境预热后,真实任务在 --no-install / --no-sync 约束下运行。
这组证据比“安装成功”更重要。它证明 Plugin 归档保持可移植,安装副本可以拥有本机环境,而 Agent 回合不需要重新变成一个包管理器。
脚本可运行之后,结果仍然只是 workspace 文件
解决依赖只打通了前半程。Agent 可以稳定生成 DOCX、PPTX 或 HTML,但 HTML 的价值经常来自交互:筛选 Dashboard、切换图表、展开证据、播放本地动画。把它压成截图会丢失交互,直接用 file:// 打开又会暴露不必要的本机边界。
Open Nua 因而把静态站点定义为一种 Output Artifact 交付方式,而不是新的 Plugin runtime。输入只有两种:
summary.html
report-site/
├── index.html
└── assets/
├── site.css
├── site.js
└── chart.svg
任意独立 HTML 都可以成为单页;只有 index.html 才把同目录视为多资源站点根。Skill 与用户都不制作 ZIP。Desktop Main 在用户确认部署后,才从可信 Artifact 重新解析 workspace 路径、读取文件并生成临时传输 ZIP。
这里的 ownership 很重要:static-site Skill 只约定交付结构,不拥有内容生成;Output Artifact 连接生成结果与预览/部署;Desktop 不复制一个站点生成器,Backend 也不运行 build command。
预览不是一次微型部署
用户应该先看到结果,再决定是否产生远端副作用。因此内部预览与部署使用两条不同的路径。
预览发生在本机进程内。Desktop Main 为每次预览签发随机的 openneo-preview:// URL,Renderer 不接收宿主绝对路径。协议 handler 对每个 CSS、JavaScript、图片、字体和媒体文件重新执行 realpath、目录包含关系、类型与大小检查;symlink escape 和 path traversal 会在读取前被拒绝。
iframe 使用 sandbox="allow-scripts"。站点可以运行 bundle 内 JavaScript,却没有 allow-same-origin,也不能通过 fetch、WebSocket、worker、frame 或 form 接触外部网络和 Desktop 顶层能力。预览不会创建 Backend deployment,也不会上传 RustFS。
只有用户点击部署并再次确认后,Desktop 才会打包与上传。Agent、Plugin、Renderer、automation 和历史消息都不能代替这次 Main-owned confirmation。
部署的目标不是“把 ZIP 放到对象存储”
一个 ZIP 上传成功,并不代表它可以安全地成为网站。Backend 在 finalize 时重新执行 authoritative validation,拒绝路径穿越、绝对路径、反斜线、重复路径、symlink、加密条目、未知压缩算法、压缩炸弹、超额文件以及不在 allowlist 中的扩展名。
验证通过后,每个资源被写入私有 RustFS bucket 的 immutable deployment prefix;manifest 记录 path、content type、size、SHA-256 和 object locator,原始传输 ZIP 随后删除。
Gateway 不获得任意 bucket/object 下载能力。用户侧只暴露:
GET /site/{token}/
GET /site/{token}/{asset_path}
每次读取都由 Backend 重新解析 deployment、token、visibility、expiry 与站内 path,并签发 60 秒读取授权。Gateway 在返回正文前再次核对 size 与 SHA-256;digest 不符时,正文不会泄漏。
最终 HTML 使用 CSP sandbox allow-scripts,但没有 allow-same-origin。这让 CSS、JavaScript、图片和字体继续工作,同时阻止站点脚本读取 Gateway cookie、localStorage 或同源 API。connect-src 'none'、service worker 拒绝、no-store、noindex 和 Redis rate limit 进一步收窄交付面;Redis 不可用时 delivery fail closed。
分享是一种需要持续治理的副作用
第一版只支持两种 visibility:owner_only 与 public,默认前者。
owner_only 每次读取都要求当前 Gateway session 的 actor 等于 deployment owner。public 使用至少 256-bit、不可枚举的 bearer token,数据库只保存 HMAC hash,不保存明文 token 或 presigned URL。
范围变化不是改一个布尔值。切换 visibility 会增加 version 并轮换 token,旧链接立即失效;revoke 与 expiry 同样立即阻止后续解析。扩大为 public、收窄为 owner-only 和撤销部署都要经过用户当前操作确认。分享 UI 只在组件 state 中保留 raw token,不把它写回 Artifact metadata 或聊天历史。
这使静态站点发布符合与其它 Agent 副作用相同的原则:默认私有、显式确认、能力最小、结果可撤销、未知中间态不伪装成成功。
这条链路现在仍然不是什么
Open Nua 当前交付的是“已构建静态资源的受控预览与部署”,不是通用应用托管平台。
它不提供服务端 build command、SSR、Functions、WebSocket backend、数据库、CMS、自定义域名或任意外部网络连接。站点 JavaScript 可以做本地交互、图表、筛选和动画,但不能直接调用 Gateway API。未来如果需要数据能力,也必须设计独立的 capability proxy,而不是给 opaque sandbox 打开同源权限。
同样,aube/uv 解决的是 Plugin 内 Node/Python 项目的可复现安装与运行,不承诺自动安装任意系统 binary,不允许 Python sdist 在用户机器上执行任意 build backend,也没有把 instruction-only Skill 全部包装成 Plugin。
边界写清楚,才能避免把两项已经交付的本地能力误写成尚未存在的 Managed Runtime 或云端应用平台。
我们最终学到的六件事
1. 生成文件不是完成交付。 用户能否预览、理解、分享和撤销,决定了 Artifact 是否真正进入产品生命周期。
2. 依赖是包的事实,不是 Prompt 的建议。 一旦脚本需要第三方库,就应该由 Plugin 的标准项目与 lockfile 拥有。
3. 安装期和运行期必须分开。 frozen sync 可以访问受控依赖源;Agent run 只能执行已经准备好的入口。
4. 预览和部署是不同副作用。 本机进程内预览不应悄悄创建远端对象,部署必须由用户在看到结果后明确确认。
5. 能运行 JavaScript,不等于获得同源权限。 opaque sandbox 让交互继续存在,同时切断 cookie、storage、API 与外部网络。
6. 最好的平台抽象通常更少。 复用 package.json、lockfile、pyproject.toml、uv、Artifact、对象存储和 HTTP,而不是再造 resolver、runtime manifest、build farm 与发布协议。
从锁定一段脚本的依赖,到让一份交互报告安全地出现在浏览器里,这条链路没有一个万能组件。它依靠每一层只拥有自己的事实:Plugin 拥有代码与依赖,Host 拥有安装和确认,Artifact 拥有结果引用,Backend 拥有部署,Gateway 拥有交付策略,浏览器 sandbox 拥有最后一道隔离。
Agent 的最后一公里,不是让它“生成更多东西”,而是让生成结果可运行、可检查、可分享,也可被收回。