diff --git a/.gitignore b/.gitignore index 200267d..084d28d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ node_modules/ # local tool state (omc) /.omc/ +/.lab/ diff --git a/README.md b/README.md index 50a2f19..c33a39c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,35 @@ API key,代码执行跑在由应用自管的本地 Docker 容器里,登录 image image +## Node Agent API 0.3.0 + +**Node Agent API 0.3.0** 将现有 GrokNode 的 bot、Linux 桌面和项目操作开放为本机 HTTP API,并提供网页工作台与 Codex CLI 入口。它适合连接自己的网页或薄桌面客户端:模型调用、对话和工具路由由 Mac 上的 Codex Harness 负责,shell、改文件、编译和测试进入 GrokNode 已经运行的 Linux 盒子。 + +| 能力 | 0.3.0 提供的接口 | +| --- | --- | +| 对话与任务 | bot 查询、独立 Harness 会话、消息、SSE 事件、回合结果、审批、定向取消 | +| 桌面与单应用 | 整桌面及终端/浏览器窗口的独立观看、接管与交还;手动复制粘贴 | +| 项目与恢复 | 文件导入、Git 差异、归档导出、项目与会话备份、网页/CLI 续接同一 thread | +| 本机授权 | Bearer 密钥、用户与 bot 权限、短期桌面链接、控制租约、配额、审计、签名 webhook | + +保持原 GrokNode 的定义:**各 bot 使用不同桌面,容器和数据共用**。API 是独立服务,不改原 App 的登录、启动或盒子管理逻辑,不自动创建、替换或重启容器;权限范围也不构成共享文件系统的 bot 间隔离。 + +已有运行中的 GrokNode 盒子,并准备好 Node 26.5.x 与 Mac/Linux 两端 Codex 0.160.0 后,在仓库根目录启动: + +```sh +npm ci +npm run node-agent-api -- --backend codex --port 18770 --recover true +``` + +打开 [网页工作台](http://127.0.0.1:18770/ui/),手动输入启动输出中 `owner_key_file` 指向的密钥。服务默认只监听 `127.0.0.1`;桌面链接须先通过 API 签发,观看与接管由服务端授权。 + +- [Node Agent API 0.3.0 详细文档](docs/NODE_AGENT_API_0_3.md):前置配置、完整接口索引、请求示例、单应用 VNC、CLI、恢复与排障。 +- [安装与运行说明](tools/node-agent-api/README.md):执行包、代理、启动选项及运行边界。 +- [OpenAPI 3.0 契约](tools/node-agent-api/openapi.json):机器可读的路径、参数、请求和响应 schema。 +- [验证记录](tools/node-agent-api/VERIFICATION.md):离线回归与真实共享盒子验证。 + +这是 GrokNode 的外部适配 API;版本号为 0.3.0,不表示兼容 OpenAI Agents SDK。运行中的进程按中断处理,恢复保留的文件与会话后,由用户决定重跑任务。 + ## 本地化改造做了什么? - 推理路由(Inference Router):Codex 与 OpenRouter 两档 provider,替代原有的 @@ -214,7 +243,8 @@ dist/Grok Node.app - `source/node-agent-coordinator/` —— 转录路由、流式活动、表情回应与路由 MCP 桥; - `source/shared/` —— 共享契约、设置、协议与 provider 辅助; - `frontend/` —— 可读的 React/TypeScript 渲染器重建与设计工作区; -- `scripts/` —— bootstrap、编译、渲染器补丁、打包、签名与校验;以及 +- `scripts/` —— bootstrap、编译、渲染器补丁、打包、签名与校验; +- `tools/node-agent-api/` —— 独立的 Node Agent API、网页工作台、CLI 与 OpenAPI 契约;以及 - `tests/` —— 发布与路由回归测试。 更多细节见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。 @@ -230,6 +260,8 @@ npm run package # 构建、签名并校验 macOS 应用 npm run verify # 校验已打包的应用 npm run smoke # 有界的原生冒烟检查 npm run publication:check # 证明干净历史导出无损 +npm run node-agent-api # 启动独立 Node Agent API 和网页工作台 +npm run test:node-agent-api # API 离线回归 ``` 生成目录包括 `.cache`、`.build`、`dist`、`src/app/dist`、`recovered`、`recovery` 与 diff --git a/docs/NODE_AGENT_API_0_3.md b/docs/NODE_AGENT_API_0_3.md new file mode 100644 index 0000000..b3eeb59 --- /dev/null +++ b/docs/NODE_AGENT_API_0_3.md @@ -0,0 +1,507 @@ +# Node Agent API 0.3.0 + +[返回仓库首页](../README.md) · [安装与运行说明](../tools/node-agent-api/README.md) · [OpenAPI 3.0 契约](../tools/node-agent-api/openapi.json) · [验证记录](../tools/node-agent-api/VERIFICATION.md) + +Node Agent API 是运行在 Mac 上的独立适配服务。网页、CLI 和自己的客户端可以查询原 GrokNode bot、创建 Codex 会话、提交任务、查看或接管 Linux 桌面,并审查和导出项目成果。模型循环与工具路由交给 Codex Harness,执行端进入现有 GrokNode 容器。 + +本文对应仓库发布的 **0.3.0 共享盒子实现**。字段类型与机器可读契约见 OpenAPI;后端是否支持某项操作,启动后以 `GET /v1/capabilities` 为准。 + +## 目录 + +- [概要与架构](#概要与架构) +- [快速开始](#快速开始) +- [通用约定](#通用约定) +- [完整接口索引](#完整接口索引) +- [对话与项目示例](#对话与项目示例) +- [桌面与单应用授权](#桌面与单应用授权) +- [CLI 与恢复](#cli-与恢复) +- [多用户与 webhook](#多用户与-webhook) +- [错误与排障](#错误与排障) + +## 概要与架构 + +```mermaid +flowchart LR + Client["网页 / CLI / 自己的客户端"] --> API["Mac:Node Agent API"] + API --> Harness["Mac:Codex Harness、模型调用、会话"] + Harness -->|Docker stdio| Box["Linux:共享盒子内的 exec-server"] + API -->|授权桌面与单应用观看或接管| Desktop["Linux:各 bot 的桌面和窗口"] + Grok["原 GrokNode"] -->|管理 bot、桌面、容器和数据| Desktop + Grok --> Box +``` + +| 部分 | 职责 | +| --- | --- | +| 原 GrokNode | 创建和管理 bot、分配桌面、管理共享容器与数据 | +| Node Agent API | 本机认证、会话附件、HTTP/SSE、桌面授权、项目操作与治理 | +| Mac Codex Harness | 原生 thread/turn、模型调用、工具路由和审批 | +| Linux 执行端 | shell、修改文件、编译、测试与运行应用 | + +所有 bot 保持原定义:**桌面不同,容器和数据共用**。独立 Harness thread 可以区分对话,但不会隔离 Linux 文件;默认项目目录也由所有 bot 共用。API 不修改原 GrokNode 程序,不替代登录、host 或 daemon,不接管容器生命周期。 + +0.3.0 支持 Bearer 授权、持久事件游标、回合状态与审批、整桌面和单应用窗口、手动剪贴板、项目导入/差异/导出、备份、用户与密钥管理、配额/审计和签名 webhook。它不是 OpenAI SDK 兼容服务,trace 输出是本 API 的 JSON 记录;不承诺恢复进程内存。 + +## 快速开始 + +### 前置条件 + +| 项目 | 要求 | +| --- | --- | +| 宿主 | Apple Silicon Mac,Node.js 26.5.x,仓库锁定依赖 | +| 原程序 | GrokNode 已运行,已有 bot 和可用的 `grok-node-local-vm` | +| Codex | Mac CLI 与 Linux 执行包均为 0.160.0;版本不符时拒绝连接,不自动升级 | +| 模型入口 | Mac 已登录 Codex;默认代理 `http://127.0.0.1:10100/v1`,已有 `~/.codex/opencodex-catalog.json` | +| 执行包 | Linux 持久目录 `/home/box/sand-data/codex-vm/bin/codex` | + +首次准备执行包,可在已运行的盒子中安装: + +```sh +docker exec --user box grok-node-local-vm npm install -g @openai/codex@0.160.0 --prefix /home/box/sand-data/codex-vm +``` + +容器不需要复制 Mac 的 Codex 登录文件或模型密钥。此版本按连接附加 `exec-server --listen stdio`,不要求另开 `8765` WebSocket 服务。 + +在仓库根目录运行: + +```sh +npm ci +npm run node-agent-api -- --backend codex --port 18770 --recover true +``` + +启动输出包含 `origin`、`ui`、`backend`、`owner_key_file`,不会打印密钥。打开 [网页工作台](http://127.0.0.1:18770/ui/),从 `owner_key_file` 指向的本机私有文件手动取出密钥并填入网页。网页只在内存保留密钥;刷新后重新输入。直接双击 `web/index.html` 不会提供 API。 + +### 配置选项 + +| 选项 | 默认值 / 含义 | +| --- | --- | +| `--backend` | `codex`:独立 Mac Harness;`grok`:原 gateway/transcript 的 legacy 后端 | +| `--port` | `18770`;`0` 使用系统分配端口,以启动输出为准 | +| `--container` | `grok-node-local-vm`,必须是已有 GrokNode 盒子 | +| `--workspace` | `/workspace/codex-projects/node-agent-api`,可选择 `/workspace` 下的项目路径 | +| `--state` | `.lab/node-agent-shared`:API 密钥、会话、事件、治理与 artifact 状态 | +| `--runtime-state` | `.lab/shared-runtime`:Mac Codex profile、会话与备份 | +| `--namespace` | `grok-node-lab-shared`,区分 API 自己的运行资料,不用于创建额外容器 | +| `--model` / `--base-url` | 选择 Mac Harness 的模型与代理入口 | +| `--recover true` | 启动前检查并回收已死亡进程留下的服务锁;不抢占正在运行的服务 | +| `--allow-writes true` | legacy `grok` 后端的显式写策略;默认关闭,仍须满足后端安全检查 | + +服务固定监听 `127.0.0.1`。`codex` 是本文示例使用的后端;legacy 后端不提供独立对话、原生取消、审批、项目和剪贴板能力,调用前应查询 capabilities。停止 API 只关闭它自己的连接与辅助进程,不停止原 GrokNode 或共享容器。 + +## 通用约定 + +### 地址与认证 + +默认 API 地址为 `http://127.0.0.1:18770`。REST 请求使用: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +密钥属于 Node Agent API,不是 ChatGPT 登录文件或 GrokNode gateway token。`/health` 和 `/ui/` 静态资源无须 Bearer;桌面入口先用短期 ticket 换取 viewer cookie。其余接口均须认证,服务精确检查 Host/Origin,不提供跨域 CORS。默认实例的地址应使用 `127.0.0.1`,不能随意替换成 `localhost`、LAN 地址或 `file://`。 + +| 权限 | 用途 | +| --- | --- | +| `agents.read` | 读取获授权 bot | +| `agents.write` | 通过原接口创建 bot;同时要求 `bot_ids` 包含 `*` | +| `sessions.read` | 读取会话、事件、回合、项目差异和导出文件 | +| `sessions.write` | 新建会话、消息、取消、审批、项目写入和恢复 | +| `desktop.view` | 观看、读取应用列表与剪贴板 | +| `desktop.control` | 接管、交还、启动应用及手动写剪贴板 | +| `keys.manage` | 签发、轮换或撤销权限范围内的派生密钥 | + +权限表中的 `+` 表示同时需要。所有会话路径还检查该会话所属 bot 是否在 `bot_ids` 授权范围内。用户和 webhook 管理仅限 owner。API grants 是访问范围,不是共享文件系统隔离。 + +### 标识、分页与时间 + +| 标识 | 来源与用途 | +| --- | --- | +| `agent_id` | 原 GrokNode bot ID,来自 bot 列表 | +| `session_id` | API 附件 ID,通常为 `nsess_*`,用于 REST 路径 | +| `thread_id` | Mac Harness 对话 ID,用于 CLI resume;不能用 session ID 替代 | +| `turn_id` | Harness 回合 ID;取消必须指定当前活动回合 | +| `application_id` | 当前 bot 桌面的 X11 窗口 ID,如 `0x…`;关闭应用后可能变化 | +| `viewer_id` / ticket | 服务签发的临时观看标识;不是 bot ID、窗口 ID 或固定桌面号 | + +分页列表返回 `object: "list"`、`data`、`has_more`、`first_id`、`last_id`。支持 `limit=1..100`(默认 20)、`order=asc|desc`(默认 asc)、`after=<上一页 last_id>`。未知游标返回 `400 invalid_cursor`。`traces` 是单次列表,不使用这些分页参数。 + +session/event 的 `created_at`、密钥和 ticket 的 `expires_at` 为 Unix 秒。实时 turn 的 `started_at`/`ended_at`、webhook 的 `createdAt`/`nextAttemptAt` 使用 Unix 毫秒。原生 Harness item 保留其原始字段。 + +普通 JSON 请求上限为 64 KiB;项目导入上限为 16 MiB。消息上限为 16,000 个 JavaScript UTF-16 单元。metadata 最多 16 个字符串键值,键长最多 64,值长最多 512;`PATCH` 中 `metadata: null` 清空 metadata。 + +### 接受状态与任务结果 + +`202` 表示输入或取消已被接受,**不表示测试通过或任务完成**。session 的 `status` 描述输入接受状态;Codex 后端通过 `task_status`、`active_turn_id` 和 `/turns` 提供回合结果:`running`、`completed`、`failed`、`interrupted`、`cancelled` 或 `unknown`。 + +消息可使用 `Idempotency-Key` 请求头或 `idempotency_key` 字段。重试须保留同一会话、键和逻辑文本;两处同时提供时必须相同。相同输入返回保存结果,不重复执行;键相同但文本不同返回 `409 idempotency_conflict`。服务崩溃时未观察到结果的输入/回合记为 `unknown`,不会自动重发。 + +## 完整接口索引 + +以下为实际路径;`{session_id}`、`{agent_id}` 等占位符替换为接口返回的标识。所有 `/v1` 接口除表中权限外还需要 Bearer。只有声明支持相应能力的后端才能执行 Codex 专属操作。 + +### 服务与 bot + +| 方法 | 路径 | 成功状态 | 权限 / 说明 | +| --- | --- | --- | --- | +| GET | `/health` | 200 | 无认证;服务版本与后端身份,不证明上游就绪 | +| GET | `/v1/capabilities` | 200 | 已认证;当前能力与限制 | +| GET | `/v1/usage` | 200 | 已认证;调用用户的配额计数 | +| GET | `/v1/agents` | 200 | `agents.read`;可分页,仅返回授权 bot | +| POST | `/v1/agents` | 201 | `agents.write`、所有 bot 范围、可写后端;`{name, description?}` | +| GET | `/v1/agents/{agent_id}` | 200 | `agents.read`;原 bot 资料 | + +### 会话与任务 + +| 方法 | 路径 | 成功状态 | 权限 / 说明 | +| --- | --- | --- | --- | +| GET | `/v1/agents/sessions` | 200 | `sessions.read`;可按 `agent_id` 过滤并分页 | +| POST | `/v1/agents/sessions` | 201 / 200 | `sessions.write`;Codex 新建独立 thread;legacy 可复用已有附件 | +| GET | `/v1/agents/sessions/{session_id}` | 200 | `sessions.read`;thread、输入和任务状态 | +| PATCH | `/v1/agents/sessions/{session_id}` | 200 | `sessions.write`;替换 metadata | +| POST | `/v1/agents/sessions/{session_id}/close` | 200 | `sessions.write`;保全式关闭,活动任务须先结束或取消 | +| POST | `/v1/agents/sessions/{session_id}/resume` | 200 | `sessions.write`;重新附加 Mac Harness thread | +| POST | `/v1/agents/sessions/{session_id}/handoff` | 200 | `sessions.write`;空闲时转交同一 thread 给 CLI | +| GET | `/v1/agents/sessions/{session_id}/items` | 200 | `sessions.read`;Harness items 或 legacy transcript,可分页 | +| GET | `/v1/agents/sessions/{session_id}/turns` | 200 | `sessions.read`;Codex 回合和结果,可分页 | +| GET | `/v1/agents/sessions/{session_id}/traces` | 200 | `sessions.read`;回合、spans 与原生 token 用量,未知成本为 null | +| GET | `/v1/agents/sessions/{session_id}/events` | 200 | `sessions.read`;分页 JSON 或持久游标 SSE | +| POST | `/v1/agents/sessions/{session_id}/events` | 202 | `sessions.write`;一个消息事件或一个取消事件 | +| GET | `/v1/agents/sessions/{session_id}/actions` | 200 | `sessions.read`;待答审批,可分页 | +| POST | `/v1/agents/sessions/{session_id}/actions/{action_id}` | 200 | `sessions.write`;`{decision: "accept"\|"decline"}` | + +### 桌面与应用 + +| 方法 | 路径 | 成功状态 | 权限 / 说明 | +| --- | --- | --- | --- | +| GET | `/v1/agents/sessions/{session_id}/environment` | 200 | `sessions.read`;当前容器和 display 映射,隐藏原 WebSocket URL | +| POST | `/v1/agents/sessions/{session_id}/desktop` | 201 | `sessions.read` + `desktop.view` 或 `desktop.control`;签发整桌面/单应用 ticket | +| POST | `/v1/agents/sessions/{session_id}/handback` | 200 | `sessions.read` + `desktop.control`;交还当前控制租约 | +| GET | `/v1/agents/sessions/{session_id}/applications` | 200 | `sessions.read` + `desktop.view`;枚举当前实际窗口 | +| POST | `/v1/agents/sessions/{session_id}/applications` | 202 | `sessions.read` + `desktop.control` + 已兑换的控制租约;`{kind: "terminal"\|"browser"}` | +| GET | `/v1/agents/sessions/{session_id}/clipboard` | 200 | `sessions.read` + `desktop.view`;手动读取 `{text}` | +| POST | `/v1/agents/sessions/{session_id}/clipboard` | 200 | `sessions.write` + `desktop.control` + 控制租约;手动写入 `{text}` | + +### 项目与保全 + +| 方法 | 路径 | 成功状态 | 权限 / 说明 | +| --- | --- | --- | --- | +| GET | `/v1/agents/sessions/{session_id}/project/diff` | 200 | `sessions.read`;暂存、未暂存和未跟踪文件差异 | +| POST | `/v1/agents/sessions/{session_id}/project/import` | 200 | `sessions.write`;导入文本文件,拒绝覆盖已有文件 | +| POST | `/v1/agents/sessions/{session_id}/project/export` | 200 | `sessions.write`;生成 `project.tgz` artifact 描述 | +| GET | `/v1/agents/sessions/{session_id}/artifacts/{artifact_id}/content` | 200 | `sessions.read`;下载 gzip 二进制 | +| POST | `/v1/agents/sessions/{session_id}/environment/backup` | 201 | `sessions.write`;项目、Git/未提交修改、Mac 会话和日志备份 | +| POST | `/v1/agents/sessions/{session_id}/environment/restore` | 200 | `sessions.write`;`{backup_id}`,校验后恢复项目归档 | +| POST | `/v1/agents/sessions/{session_id}/environment/recreate` | 无共享后端成功响应 | `sessions.write`;共享实现返回 `409 managed_by_grok_node` | + +导入、导出、备份、恢复和 CLI handoff 要求共享项目没有 API 管理的运行回合。原 GrokNode 的并发写入仍需由用户协调;API 不暂停原 bot。 + +### 用户、密钥与 webhook + +| 方法 | 路径 | 成功状态 | 权限 / 说明 | +| --- | --- | --- | --- | +| GET | `/v1/users` | 200 | owner;用户列表,可分页 | +| POST | `/v1/users` | 201 | owner;`{name}` 新建 API 用户 | +| POST | `/v1/users/{user_id}/disable` | 200 | owner;停用用户并使其授权失效 | +| POST | `/v1/keys` | 201 | `keys.manage`;`{scopes, bot_ids, ttl_seconds?, user_id?}` | +| POST | `/v1/keys/{key_id}/rotate` | 201 | `keys.manage`;替代密钥,撤销旧密钥 | +| DELETE | `/v1/keys/{key_id}` | 200 | `keys.manage`;撤销该派生密钥及后代权限 | +| POST | `/v1/webhooks` | 201 | owner;`{destination}` 配置并启用本机投递 | +| POST | `/v1/webhooks/rotate` | 200 | owner;轮换签名密钥 | +| POST | `/v1/webhooks/dispatch` | 200 | owner;立即投递到期 outbox 项 | +| GET | `/v1/webhooks/{delivery_id}` | 200 | owner;查询属于 owner 的一次投递 | + +### 查看器内部接口 + +客户端应打开 `/desktop` 授权响应中的 `url`,然后使用服务返回的设置,不自行拼接 viewer ID、display、token 或 WebSocket 路径。下列路由用于 noVNC 查看器,普通 REST 客户端无需逐个调用。 + +| 方法 | 路径 | 成功状态 | 授权 / 内容 | +| --- | --- | --- | --- | +| GET | `/desktop/open` | 303 | 单次 `ticket` 查询参数,换取 cookie 并重定向 | +| GET | `/desktop/mandatory.json` | 200 | 整桌面 viewer cookie;服务端强制设置 | +| GET | `/desktop/defaults.json` | 200 | 整桌面 viewer cookie;空默认设置 | +| GET | `/desktop/vnc.html` | 200 | 整桌面 viewer cookie;隐藏 noVNC 左侧控制条 | +| GET | `/desktop/{asset}` | 200 | 整桌面 viewer cookie;允许的静态资源 | +| GET | `/desktop/socket` | 101 | 整桌面 viewer cookie + 精确 Origin;WebSocket/RFB | +| GET | `/desktop/lane/{viewer_id}/mandatory.json` | 200 | 单应用路径 cookie;独立 socket 设置 | +| GET | `/desktop/lane/{viewer_id}/defaults.json` | 200 | 单应用路径 cookie;空默认设置 | +| GET | `/desktop/lane/{viewer_id}/vnc.html` | 200 | 单应用路径 cookie;单窗口画面 | +| GET | `/desktop/lane/{viewer_id}/{asset}` | 200 | 单应用路径 cookie;允许的静态资源 | +| GET | `/desktop/lane/{viewer_id}/socket` | 101 | 单应用路径 cookie + 精确 Origin;该窗口的 WebSocket/RFB | + +## 对话与项目示例 + +以下命令在 **Mac 的仓库目录**中执行。示例 ID 需要替换为响应中的实际值;示例 JSON 只展示字段形状,不代表你的实时任务结果。 + +### 准备认证并查询 bot + +```sh +NODE_AGENT_BASE='http://127.0.0.1:18770' +read -r NODE_AGENT_KEY < .lab/node-agent-shared/owner.key +node_api() { + curl --fail-with-body --silent --show-error \ + -H "Authorization: Bearer $NODE_AGENT_KEY" \ + -H 'Content-Type: application/json' "$@" +} +node_api "$NODE_AGENT_BASE/v1/capabilities" +node_api "$NODE_AGENT_BASE/v1/agents?limit=20" +``` + +使用自定义 `--state` 时,从启动输出的 `owner_key_file` 读取密钥。不要把它提交到仓库、放进 URL 或复制进执行容器。 + +### 创建会话与提交任务 + +```sh +NODE_AGENT_BOT='BOT_ID_FROM_AGENTS' +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions" \ + --data "{\"agent_id\":\"$NODE_AGENT_BOT\",\"metadata\":{\"project\":\"demo\"}}" +``` + +Codex 后端返回 `201`,包含 `id`、`agent_id`、`context: "codex_harness"`、`thread_id`、`task_status`。同一 bot 可有多个独立 Harness 会话;每次 POST 创建新 thread。legacy 后端可返回 `200`,复用 bot 的现有 transcript 附件。 + +```sh +NODE_AGENT_SESSION='SESSION_ID_FROM_RESPONSE' +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/events" \ + -H 'Idempotency-Key: run-tests-001' \ + --data '{"events":[{"type":"message","text":"检查当前项目,修复问题并运行测试,然后说明修改。"}]}' +node_api "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/turns" +``` + +接受响应形状为: + +```json +{"request_id":"run-tests-001","status":"accepted","completed":false,"turn_id":"turn_example"} +``` + +也支持 `agent.session.input.message` 的单条 user/input_text 子集: + +```json +{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"运行测试"}]}]}],"idempotency_key":"run-tests-002"} +``` + +消息接口不接受客户端伪造的 tool result。需要审批的操作通过 `/actions` 回答,由 Harness 执行;不要通过重新发送消息重复执行同一请求。 + +### SSE、审批与取消 + +```sh +node_api -N -H 'Accept: text/event-stream' \ + "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/events" +``` + +持久事件具有 `id: nevt_*`、`event`、JSON `data`。断线后使用最后收到的 ID: + +```sh +NODE_AGENT_EVENT='LAST_PERSISTED_EVENT_ID' +node_api -N -H 'Accept: text/event-stream' -H "Last-Event-ID: $NODE_AGENT_EVENT" \ + "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/events" +``` + +`Last-Event-ID` 优先于 `after` 查询参数,重连只补事件,不重放任务。`node.harness.event` 包含原生 method/params;心跳和临时上游不可用通知不代表任务结果,也不能代替持久游标。成本未知时返回 null。 + +```sh +node_api "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/actions" +NODE_AGENT_ACTION='PENDING_ACTION_ID' +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/actions/$NODE_AGENT_ACTION" \ + --data '{"decision":"decline"}' +``` + +`decision` 只接受 `accept` 或 `decline`。查看审批详情后再选择;回答已失效或已经处理的审批会返回冲突。 + +```sh +NODE_AGENT_TURN='ACTIVE_TURN_ID' +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/events" \ + --data "{\"events\":[{\"type\":\"agent.session.input.cancel\",\"turn_id\":\"$NODE_AGENT_TURN\"}]}" +``` + +取消请求不带 `idempotency_key`,返回 `{accepted: true, turn_id, completed: false}`。它中断该 thread 的原生 turn,并定向终止该回合记录的后台进程;不会停止共享盒子或其他 thread。最终结果仍查询 `/turns`。 + +### 导入、审查与导出 + +```sh +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/project/import" \ + --data '{"files":[{"path":"api-example/hello.txt","content":"Hello from Node Agent API\n"}]}' +node_api "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/project/diff" +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/project/export" +``` + +导入最多 200 个 UTF-8 文本文件,路径必须相对项目根目录;拒绝绝对路径、`..`、`.git`、符号链接穿越和覆盖已有文件。差异返回 `{diff}`,包含暂存、未暂存及未跟踪文件。导出包括项目文件和完整 Git 目录,返回 `artifact.id/name/size/sha256/download_url`;预检上限为 200 MiB、10,000 个文件。 + +`download_url` 是同源相对路径。复制响应里的路径并带认证下载: + +```sh +NODE_AGENT_DOWNLOAD='/v1/agents/sessions/SESSION_ID/artifacts/ARTIFACT_ID/content' +node_api "$NODE_AGENT_BASE$NODE_AGENT_DOWNLOAD" --output project.tgz +shasum -a 256 project.tgz +``` + +将本地大小与 SHA-256 和 artifact 描述比对,再把归档保存到自己的成果目录。下载不会自动覆盖 Mac 项目。 + +## 桌面与单应用授权 + +### 整桌面观看或接管 + +```sh +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/desktop" \ + --data '{"mode":"view","ttl_seconds":60,"target":{"type":"desktop"}}' +``` + +响应含 `url`、`display`、`expires_at`、`single_use: true`、`server_enforced: true`、`pauses_agent: false`。在本机浏览器打开 `url` 即兑换授权。`mode` 默认 `view`;接管显式指定 `control`。 + +ticket 默认 60 秒、最多 300 秒,且只能兑换一次。兑换后 viewer cookie 为 HttpOnly、SameSite=Strict,有效期 15 分钟。服务端过滤观看模式的键鼠输入;修改 URL 中的 `view_only` 无法获得接管权限。链接失效、服务重启或桌面映射变化后重新签发。 + +每 bot 同时只有一个有效控制租约。申请 ticket 尚未建立租约,必须打开链接兑换后才能启动应用或写剪贴板。接管不会暂停原 bot;用户操作和机器人动作需要自己协调。 + +### 终端和浏览器独立窗口 + +```sh +node_api "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/applications" +``` + +列表返回实际窗口的 `id`、`name`、`running`。选定窗口后: + +```sh +NODE_AGENT_APPLICATION='WINDOW_ID_FROM_APPLICATIONS' +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/desktop" \ + --data "{\"mode\":\"view\",\"target\":{\"type\":\"application\",\"application_id\":\"$NODE_AGENT_APPLICATION\"}}" +``` + +每个单应用 viewer 使用 `/desktop/lane/{viewer_id}/…` 和独立 cookie 路径,因此可以同时打开终端与浏览器的观看窗口。要操作其中一个窗口,先交还旧控制租约,再签发该窗口的 `control` 链接。窗口 ID 会变化,不能固定旧的 `token=3` 或 `codexvm-app-3-terminal`。 + +没有所需应用时,先兑换该 bot 的控制链接,再明确启动: + +```sh +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/applications" \ + --data '{"kind":"terminal"}' +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/applications" \ + --data '{"kind":"browser"}' +``` + +`202` 只代表启动请求接受,随后刷新应用列表取得实际窗口 ID。终端工作目录为配置的 Linux 项目,浏览器打开空白页。 + +### 手动剪贴板与交还 + +```sh +node_api "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/clipboard" +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/clipboard" \ + --data '{"text":"手动传入 Linux 的文本"}' +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/handback" +``` + +写剪贴板要求有效控制租约,文本最多 64 KiB。网页只在用户点击按钮时传递文本,默认关闭自动同步。交还返回 `{released: true}` 并断开对应控制 viewer;关闭会话会撤销该 bot 当前的 API 桌面 ticket/viewer,撤销密钥则使相应授权链失效。 + +API 仅保护自身 `/v1` 和 `/desktop` 入口。原 GrokNode 的 `6080/6081` 端口保留原访问策略;访问原始 VNC 链接不会自动受到本 API 的 Bearer 权限约束。 + +## CLI 与恢复 + +### 网页与 CLI 共用 thread + +在仓库根目录使用包装命令: + +```sh +npm run node-agent -- status "$NODE_AGENT_BOT" +npm run node-agent -- codex "$NODE_AGENT_BOT" +``` + +从网页转交给 CLI,先确认共享项目空闲: + +```sh +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/handoff" +NODE_AGENT_THREAD='THREAD_ID_FROM_HANDOFF' +npm run node-agent -- resume "$NODE_AGENT_BOT" --thread "$NODE_AGENT_THREAD" +``` + +`handoff` 返回 `thread_id`、`bot_id`、`target: "codex_cli"`、`replayed: false`。CLI 使用与 API 相同的 namespace/runtime-state/workspace;改过这些配置时,CLI 也需传入对应的 `--namespace`、`--state-root`、`--workspace`。CLI 退出后,网页或 REST 显式恢复: + +```sh +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/resume" +node_api "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/turns" +``` + +CLI 也能请求桌面和单应用的短期链接: + +```sh +npm run node-agent -- desktop "$NODE_AGENT_BOT" --session "$NODE_AGENT_SESSION" --mode view +npm run node-agent -- desktop "$NODE_AGENT_BOT" --session "$NODE_AGENT_SESSION" --application "$NODE_AGENT_APPLICATION" --mode view +``` + +### 文件、修改、会话和日志保全 + +```sh +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/environment/backup" +NODE_AGENT_BACKUP='BACKUP_ID_FROM_RESPONSE' +node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/environment/restore" \ + --data "{\"backup_id\":\"$NODE_AGENT_BACKUP\"}" +``` + +备份包含项目归档、Git 目录和未提交修改、Mac Codex profile/会话及 `session.json` 日志资料,返回 `backup_id`、`preserved`、`processes_restored: false`。恢复检查备份来源、项目路径和 SHA-256,并先备份当前项目,再覆盖归档内的项目条目;归档之外的新文件会保留。 + +**自动 restore 的范围是项目归档**。它不自动覆盖 API 的 `sessions.json` 或还原被删除的 Mac Codex profile;续接对话依赖保留的 API state、runtime-state 和原 thread。Mac 会话归档供保全与人工恢复使用,不能把项目 restore 当作整台 Mac/容器快照恢复。 + +服务断线或重启后,使用相同 state/runtime-state 启动并调用 `/resume`。盒子损坏时先由原 GrokNode 修复,再由 API 重新查询容器/桌面映射、恢复文件和附加 thread。执行端不可用时工具报错,不回落到 Mac shell。运行中的进程、浏览器内存和应用内存按中断处理,是否重跑由用户决定。 + +## 多用户与 webhook + +### 用户与派生密钥 + +owner 可先创建 `{name}` 用户,再为该用户签发限定范围的密钥: + +```json +{"user_id":"nuser_example","scopes":["agents.read","sessions.read","desktop.view"],"bot_ids":["bot_example"],"ttl_seconds":3600} +``` + +此请求发送到 `POST /v1/keys`,示例 ID 须替换为实际用户和 bot ID。省略 `user_id` 时属于调用者当前 API 用户。密钥默认有效 1 小时,最多 24 小时;子密钥不能扩大父权限或有效期,明文只在签发/轮换响应返回。普通派生密钥只能管理自己的后代;owner 不能通过派生密钥接口轮换或撤销 owner 身份本身。 + +撤销父密钥使其后代失效,轮换会撤销旧密钥及旧授权链。停用用户使其密钥、SSE 和桌面授权失效。 + +默认每用户限额:120 请求/分钟、4 条并发流、64 MiB 会话与 artifact 逻辑预留存储;多个属于同一用户的密钥共用计数。SSE 与桌面 WebSocket 共用流配额。审计保留有限请求元数据,不复制密钥、正文或完整对话;用 `/v1/usage` 查看计数。持久化失败时相关操作关闭,不能把 HTTP 重试视为后台已成功完成。 + +### webhook 签名与投递 + +```sh +node_api -X POST "$NODE_AGENT_BASE/v1/webhooks" \ + --data '{"destination":"http://127.0.0.1:19000/events"}' +``` + +接收服务须先由用户准备。webhook 默认关闭,每次 API 服务重新启动后需显式配置启用;保留的 outbox 不会自行发送。destination 仅支持数值 loopback HTTP(S) 地址,不解析 DNS、不跟随重定向。配置响应返回 `destination` 和 `signingKey: {id, secret}`,应通过本机可信方式交给接收端。 + +投递请求使用下列请求头: + +| 请求头 | 含义 | +| --- | --- | +| `x-node-webhook-id` | delivery ID,重试保持不变 | +| `x-node-webhook-timestamp` | 数值 Unix 秒 | +| `x-node-webhook-key-id` | 当前签名 key ID | +| `x-node-webhook-signature` | `v1=` | + +签名输入是 `timestamp + "." + deliveryId + "." + exactBody`,密钥先从 base64url 解码。接收端须用原始 UTF-8 正文验签,不能重新 JSON 序列化;同时校验时间窗并持久去重 delivery ID。仓库的 `verifyWebhookSignature` 默认时间容差 300 秒,但去重仍由接收端执行。 + +投递语义为至少一次。默认最多 5 次尝试,指数退避从 1 秒开始、上限 60 秒,单次超时 5 秒。成功为 2xx;网络错误、408、429 和 5xx 可重试,其余状态作为失败。轮换影响下一次投递(包括重试),接收端应在交接期保留旧 key。 + +载荷只包含选定事件的 ID、类型、session ID、时间等元数据。通过 `/v1/webhooks/{delivery_id}` 查询 `pending`、`delivering`、`retry`、`delivered` 或 `failed`,不把 webhook 是否送达等同于任务是否成功。 + +## 错误与排障 + +REST 错误结构为: + +```json +{"error":{"type":"origin_rejected","message":"Host or Origin rejected"}} +``` + +响应带 `X-Request-ID` 用于关联审计。WebSocket 拒绝升级时用 HTTP 状态说明认证、授权、映射或配额等错误,不使用上述 JSON 正文。 + +| 状态 / 常见类型 | 检查方式 | +| --- | --- | +| 400 `invalid_request` / `invalid_cursor` | 核对字段、标识、分页或 SSE 游标;不要用另一会话的游标 | +| 401 `unauthorized` | Bearer 缺失、过期、已撤销或用户已停用 | +| 401 `ticket_invalid` / `viewer_expired` | 重新申请桌面链接;ticket 已使用不能再次兑换 | +| 403 `origin_rejected` / `permission_denied` | 使用启动输出的精确 origin;检查 scope、bot grants 和控制租约 | +| 404 `not_found` / `application_not_found` | 检查 session/artifact ID,刷新实际窗口列表 | +| 409 `turn_active` / `session_busy` / `input_pending` | 等活动回合结束,或显式取消后再写项目、关闭/转交会话 | +| 409 `idempotency_conflict` | 同一请求键必须对应相同逻辑文本 | +| 409 `desktop_busy` / `desktop_changed` | 交还已有租约;重新查询桌面并签发链接 | +| 409 `managed_by_grok_node` | 原 GrokNode 管理盒子生命周期,API 不执行 recreate | +| 409 `codex_version` | 核对 Mac/Linux Codex 0.160.0,不自动升级 | +| 413 / 415 | 核对请求大小与 `application/json` | +| 429 | 查看 `/v1/usage`,关闭多余 SSE/viewer,检查配额或 outbox/ticket 容量 | +| 5xx 或后端不可用 | 检查原盒子、Mac 代理、state 权限与服务诊断;查询状态后再决定恢复或重试 | + +`/health` 成功只证明 API 进程在响应,不保证模型、容器或桌面就绪。验证与边界记录见 [VERIFICATION.md](../tools/node-agent-api/VERIFICATION.md);离线 API 回归命令为 `npm run test:node-agent-api`。 diff --git a/package-lock.json b/package-lock.json index 0d4844c..3fa1ad6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,6 +103,7 @@ "@vitejs/plugin-react": "6.0.5", "acorn": "8.18.0", "acorn-walk": "8.3.5", + "ajv": "8.20.0", "electron": "42.1.0", "esbuild": "0.28.2", "node-addon-api": "8.5.0", diff --git a/package.json b/package.json index ce1f9d7..eaa994a 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,10 @@ "marketplace:parity": "node scripts/verify-marketplace-detail-parity.mjs", "marketplace:css": "node scripts/verify-marketplace-css-cascade.mjs", "marketplace:affinity:audit": "node scripts/audit-deployed-affinity-key.mjs", - "marketplace:classnames": "node scripts/verify-d17-classnames.mjs" + "marketplace:classnames": "node scripts/verify-d17-classnames.mjs", + "node-agent-api": "node tools/node-agent-api/cli.mjs", + "node-agent": "node tools/node-agent-api/client-cli.mjs", + "test:node-agent-api": "node --test tests/node-agent-*.test.mjs" }, "devDependencies": { "@electron/asar": "4.2.1", @@ -42,6 +45,7 @@ "@vitejs/plugin-react": "6.0.5", "acorn": "8.18.0", "acorn-walk": "8.3.5", + "ajv": "8.20.0", "electron": "42.1.0", "esbuild": "0.28.2", "node-addon-api": "8.5.0", diff --git a/tests/fixtures/node-agent-api-crash.mjs b/tests/fixtures/node-agent-api-crash.mjs new file mode 100644 index 0000000..c872a21 --- /dev/null +++ b/tests/fixtures/node-agent-api-crash.mjs @@ -0,0 +1,12 @@ +import { startNodeAgentApi } from '../../tools/node-agent-api/server.mjs'; + +const adapter = { + writesEnabled: true, + async requireAgent(id) { return { id }; }, + async input() { + console.log(JSON.stringify({ backend_called: true })); + await new Promise(() => {}); + }, +}; +const service = await startNodeAgentApi({ adapter, stateDirectory: process.argv[2] }); +console.log(JSON.stringify({ origin: service.origin, keyFile: service.keyFile })); diff --git a/tests/node-agent-api-web-ui-codex-contract.test.mjs b/tests/node-agent-api-web-ui-codex-contract.test.mjs new file mode 100644 index 0000000..d790520 --- /dev/null +++ b/tests/node-agent-api-web-ui-codex-contract.test.mjs @@ -0,0 +1,298 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createHash } from 'node:crypto'; +import { gzipSync } from 'node:zlib'; +import { capabilities, createClient, desktopURL, eventView, projectDownload, readSSE } from '../tools/node-agent-api/web/client.js'; + +const web = new URL('../tools/node-agent-api/web/', import.meta.url); +const archive = gzipSync(Buffer.alloc(1024)); +const exportedArtifact = { id: 'artifact_1', name: 'project.tar.gz', size: archive.length, sha256: createHash('sha256').update(archive).digest('hex'), download_url: '/v1/agents/sessions/session_1/artifacts/artifact_1/content' }; + +test('artifact binary download uses one authenticated GET without putting key in URL; legacy JSON stays local', async () => { + const calls = [], signal = new AbortController().signal; + const client = createClient('private-test-key', 'http://localhost', async (url, options) => { + calls.push({ url, options }); + return new Response(archive, { headers: { 'Content-Type': 'application/gzip' } }); + }); + const downloaded = await projectDownload(client, { artifact: exportedArtifact }, 'session_1', signal); + assert.deepEqual(Buffer.from(await downloaded.blob.arrayBuffer()), archive); + assert.equal(downloaded.name, 'project.tar.gz'); assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'http://localhost' + exportedArtifact.download_url); + assert.equal(calls[0].options.headers.Authorization, 'Bearer private-test-key'); + assert.equal(calls[0].options.method, 'GET'); assert.equal(calls[0].options.signal, signal); + assert.equal(calls[0].options.redirect, 'error'); + const legacy = { files: [{ path: 'old.txt', content: '兼容导出' }] }; + const json = await projectDownload(client, legacy, 'session_1'); + assert.deepEqual(JSON.parse(await json.blob.text()), legacy); + assert.equal(json.name, 'session_1-project.json'); assert.equal(calls.length, 1); +}); + +test('artifact URL rejects cross-origin/non-API and failed content fetch is never retried', async () => { + let calls = 0; + const client = createClient('private-test-key', 'http://localhost', async () => { + calls++; return Response.json({ error: { type: 'download_failed', message: '下载失败' } }, { status: 502 }); + }); + for (const url of ['https://other.invalid/v1/archive', '/desktop/archive']) { + await assert.rejects(projectDownload(client, { artifact: { ...exportedArtifact, download_url: url } }, 'session_1'), /其他来源/); + } + assert.equal(calls, 0); + await assert.rejects(projectDownload(client, { artifact: exportedArtifact }, 'session_1'), /HTTP 502.*下载失败/); + assert.equal(calls, 1); + await assert.rejects(projectDownload(client, { artifact: {} }, 'session_1'), /未生成下载/); + assert.equal(calls, 1); +}); + +test('UI capability gates require explicit true, including unknown and old backend', () => { + assert.ok(Object.values(capabilities()).every(value => value === false)); + const old = capabilities({ writes_enabled: false, events: { sse: true, cancellation: false }, security: { desktop_server_enforced: true } }); + assert.equal(old.sse, true); assert.equal(old.cancel, false); assert.equal(old.view, false); + assert.equal(capabilities({ project: { diff: 'true' } }).diff, false); + assert.ok(Object.values(capabilities({ writes_enabled: true, events: { sse: true, cancellation: true, required_actions: true }, project: { diff: true, import: true, export: true }, desktop: { view: true, control: true, applications: true }, environment: { isolation: 'shared_container_separate_display' }, clipboard: { read: true, write: true } })).every(Boolean)); + assert.equal(capabilities({ desktop: { applications: 'true' } }).applications, false); +}); + +test('Bearer requests stay same-origin, block redirects, and consume complete pagination', async () => { + const calls = []; + const client = createClient('test-only-key', 'http://127.0.0.1:9876', async (url, options) => { + calls.push({ url, options }); + return Response.json(calls.length === 1 ? { data: [{ id: 'a' }], has_more: true, last_id: 'a' } : { data: [{ id: 'b' }], has_more: false }); + }); + assert.deepEqual(await client.list('/v1/agents'), [{ id: 'a' }, { id: 'b' }]); + assert.equal(new URL(calls[1].url).searchParams.get('after'), 'a'); + assert.equal(calls[0].options.headers.Authorization, 'Bearer test-only-key'); + assert.equal(calls[0].options.redirect, 'error'); + assert.equal(calls[0].options.cache, 'no-store'); + await assert.rejects(client.json('https://elsewhere.invalid/v1/agents'), /其他来源/); + await assert.rejects(client.json('/desktop/not-api'), /其他来源/); + assert.equal(calls.length, 2); +}); + +test('HTTP unknown/error and malformed list never become success', async () => { + const client = createClient('test', 'http://localhost', async () => Response.json({ error: { type: 'upstream_unavailable', message: 'unknown' } }, { status: 502 })); + await assert.rejects(client.json('/v1/agents'), /HTTP 502.*unknown/); + const malformed = createClient('test', 'http://localhost', async () => Response.json({ data: [], has_more: true })); + await assert.rejects(malformed.list('/v1/agents'), /分页游标无效/); +}); + +test('fetch SSE decodes fragmented UTF-8, CRLF, multiline data and heartbeats', async () => { + const source = ': heartbeat\r\nid: nevt_1\r\nevent: node.session.turn.started\r\ndata: {"type":"node.session.turn.started",\r\ndata: "data":{"turn_id":"回合一"}}\r\n\r\ndata: {"type":"node.upstream.unavailable"}\n\n'; + const bytes = new TextEncoder().encode(source), events = []; + const body = new ReadableStream({ start(controller) { for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); controller.close(); } }); + await readSSE(new Response(body, { headers: { 'content-type': 'text/event-stream' } }), event => events.push(event)); + assert.equal(events.length, 2); assert.equal(events[0].id, 'nevt_1'); + assert.equal(eventView(events[0]).turnId, '回合一'); assert.equal(eventView(events[0]).turnStatus, 'running'); + assert.equal(events[1].id, ''); + await assert.rejects(readSSE(Response.json({}), () => {}), /SSE/); +}); + +test('turn outcomes are authoritative and tool deltas retain their association', () => { + assert.equal(eventView({ type: 'node.session.input.accepted', data: { data: { status: 'accepted' } } }).turnStatus, undefined); + assert.equal(eventView({ type: 'node.harness.event', data: { data: { method: 'turn/completed', params: { turn: { id: 't', status: 'failed' } } } } }).turnStatus, 'failed'); + const tool = eventView({ type: 'node.harness.event', data: { data: { method: 'item/commandExecution/outputDelta', params: { itemId: 'tool', turnId: 't', delta: '测试失败' } } } }); + assert.equal(tool.itemId, 'tool'); assert.equal(tool.delta, '测试失败'); assert.equal(tool.turnId, 't'); + assert.equal(eventView({ type: 'custom.unknown', data: {} }).turnStatus, undefined); +}); + +test('desktop iframe cannot navigate to arbitrary or cross-origin URLs', () => { + assert.equal(desktopURL('/desktop/ticket/t', 'http://localhost'), 'http://localhost/desktop/ticket/t'); + for (const value of ['https://other.invalid/desktop/t', 'javascript:alert(1)', '/v1/agents', '', 'http://user:pass@localhost/desktop/t']) assert.throws(() => desktopURL(value, 'http://localhost')); +}); + +test('HTML uses Chinese, external scripts, and no persistent key or clipboard automation', async () => { + const html = await readFile(new URL('index.html', web), 'utf8'); + const app = await readFile(new URL('app.js', web), 'utf8'); + assert.match(html, /lang="zh-CN"/); assert.match(html, /type="password"/); + assert.doesNotMatch(html, /\son\w+=/i); + for (const match of html.matchAll(/]*)>([\s\S]*?)<\/script>/g)) { assert.match(match[1], /src=/); assert.equal(match[2].trim(), ''); } + assert.doesNotMatch(app, /localStorage|sessionStorage|indexedDB|document\.cookie|navigator\.clipboard|new EventSource/); + assert.match(app, /agent\.session\.input\.cancel/); + assert.match(app, /Last-Event-ID/); +}); + +// This opt-in browser scenario uses existing local QA tooling, never a project dependency. +if (process.env.NODE_AGENT_UI_BROWSER === '1') test('real Chromium UI contract: session, stream, actions, project, desktop, clipboard and responsive states', { timeout: 90000 }, async () => { + const evidence = process.env.NODE_AGENT_UI_EVIDENCE; + assert.ok(evidence, 'NODE_AGENT_UI_EVIDENCE must name an artifact directory'); + await mkdir(evidence, { recursive: true }); + const { loadOmowright } = await import(pathToFileURL(process.env.NODE_AGENT_UI_BROWSER_LOADER).href); + const { omowright } = await loadOmowright(); + const requests = [], streams = new Set(); + let approvalPending = true, failDiff = false, capabilityMode = 'full', exportMode = 'files', failDownload = false; + const caps = { writes_enabled: true, sessions: { independent_conversations: false }, events: { sse: true, cancellation: true, required_actions: true }, desktop: { view: true, control: true }, project: { diff: true, import: true, export: true }, clipboard: { read: true, write: true } }; + const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + const json = (value, status = 200) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(value)); }; + if (url.pathname === '/' || /^\/(?:app\.js|client\.js|style\.css)$/.test(url.pathname)) { + const name = url.pathname === '/' ? 'index.html' : url.pathname.slice(1); + res.writeHead(200, { 'Content-Type': name.endsWith('.js') ? 'text/javascript' : name.endsWith('.css') ? 'text/css' : 'text/html', 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src 'self'; object-src 'none'" }); + res.end(await readFile(new URL(name, web))); return; + } + if (url.pathname === '/desktop/ticket/test') { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('测试桌面契约测试专用桌面路由'); return; } + if (url.pathname === '/favicon.ico') { res.writeHead(204); res.end(); return; } + let raw = ''; for await (const chunk of req) raw += chunk; + const body = raw ? JSON.parse(raw) : undefined; + requests.push({ method: req.method, path: url.pathname, query: url.search, body, bearer: req.headers.authorization === 'Bearer contract-test-key', cursor: req.headers['last-event-id'] }); + if (req.headers.authorization !== 'Bearer contract-test-key') return json({ error: { type: 'unauthorized', message: '测试认证失败' } }, 401); + if (url.pathname === '/v1/capabilities') return json(capabilityMode === 'full' ? caps : { events: { sse: false }, writes_enabled: false }); + if (url.pathname === '/v1/agents') return json({ data: [{ id: 'bot_1', name: '契约测试机器人' }, { id: 'bot_2', name: '第二个测试机器人' }], has_more: false }); + if (url.pathname === '/v1/agents/sessions') return json(req.method === 'POST' ? { id: 'session_1', agent_id: body.agent_id, status: 'idle' } : { data: [{ id: 'session_1', agent_id: 'bot_1', status: 'idle' }], has_more: false }); + if (url.pathname === '/v1/agents/sessions/session_1') return json({ id: 'session_1', agent_id: 'bot_1', status: 'idle' }); + if (url.pathname.endsWith('/items')) return json({ data: [{ id: 'item_1', role: 'assistant', text: '来自测试 API 的既有记录 ' }], has_more: false }); + if (url.pathname.endsWith('/events') && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.write(': connected\n\n'); streams.add(res); req.on('close', () => streams.delete(res)); return; + } + if (url.pathname.endsWith('/events')) return json({ status: 'accepted', completed: false, request_id: 'req_1' }, 202); + if (url.pathname.endsWith('/actions')) return json({ data: approvalPending ? [{ id: 'action_1', type: 'command', command: '测试审批操作' }] : [], has_more: false }); + if (url.pathname.endsWith('/actions/action_1')) { approvalPending = false; return json({ decision: body.decision }); } + if (url.pathname.endsWith('/project/diff')) return failDiff ? json({ error: { type: 'upstream_unavailable', message: '差异未知' } }, 502) : json({ diff: '+测试项目修改' }); + if (url.pathname.endsWith('/project/import')) return json({ imported: body.files.map(file => file.path) }); + if (url.pathname.endsWith('/project/export')) return json(exportMode === 'artifact' ? { artifact: exportedArtifact } : { files: [{ path: 'hello.txt', content: '导出内容' }] }); + if (url.pathname === exportedArtifact.download_url) { + if (failDownload) return json({ error: { type: 'download_failed', message: '下载失败' } }, 502); + res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': archive.length }); res.end(archive); return; + } + if (url.pathname.endsWith('/desktop')) return json({ url: '/desktop/ticket/test', mode: body.mode, pauses_agent: false }); + if (url.pathname.endsWith('/handback')) return json({ released: true }); + if (url.pathname.endsWith('/clipboard')) return json(req.method === 'GET' ? { text: '测试剪贴板内容' } : { written: true }); + return json({ error: { type: 'not_found', message: '未实现测试路由' } }, 404); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + const profile = await mkdtemp(join(tmpdir(), 'node-agent-ui-')); + let browser; + const observations = []; + try { + browser = await omowright.connectPipe({ browserPath: process.env.NODE_AGENT_UI_CHROME || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', browserArgs: ['--headless=new', '--no-first-run', `--user-data-dir=${profile}`, '--window-size=1280,900'], storageRoot: profile }); + const page = await browser.newTab(origin); + await page.cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: evidence }); + const evaluate = expression => page.evaluate(expression); + const waitFor = async expression => { + for (let attempt = 0; attempt < 100; attempt++) { if (await evaluate(expression)) return; await new Promise(resolve => setTimeout(resolve, 30)); } + assert.fail(`Browser condition not reached: ${expression}`); + }; + const click = selector => page.locator(selector).click(); + const fill = (selector, value) => page.locator(selector).fill(value); + const capture = async name => { await writeFile(join(evidence, name + '.png'), await page.screenshot({ fullPage: true })); }; + const emit = (id, type, data) => { for (const stream of streams) stream.write(`id: ${id}\nevent: ${type}\ndata: ${JSON.stringify({ id, type, session_id: 'session_1', data })}\n\n`); }; + await waitFor("document.getElementById('send').disabled"); await capture('disconnected-desktop'); + await fill('#key', 'contract-test-key'); await click('#connect'); + await waitFor("document.getElementById('bots').options.length === 3"); + assert.equal(await evaluate("document.getElementById('key').value"), ''); + await page.locator('#bots').selectOption('bot_1'); + await waitFor("document.querySelectorAll('#sessions button').length === 1"); + await click('#sessions button'); + await waitFor("document.getElementById('stream-status').textContent === '事件流已连接'"); + assert.equal(await evaluate("document.querySelector('#transcript img') === null"), true); + assert.equal(requests.some(row => row.path.endsWith('/clipboard')), false); + observations.push({ scenario: 'manual authentication, bot/session resume, safe transcript', passed: true }); + await fill('#prompt', '契约测试消息'); await click('#send'); + await waitFor("document.getElementById('status').textContent.includes('accepted')"); + assert.equal(await evaluate("document.getElementById('turn-status').textContent.includes('已完成')"), false); + const message = requests.find(row => row.method === 'POST' && row.body?.events?.[0]?.type === 'message'); + assert.deepEqual(message.body.events, [{ type: 'message', text: '契约测试消息' }]); + assert.match(message.body.idempotency_key, /^nui_/); + emit('nevt_1', 'node.harness.event', { method: 'turn/started', params: { turn: { id: 'turn_1' } } }); + emit('nevt_2', 'node.harness.event', { method: 'item/commandExecution/outputDelta', params: { itemId: 'tool_1', turnId: 'turn_1', delta: '实时工具输出' } }); + await waitFor("!document.getElementById('cancel').disabled && document.getElementById('transcript').textContent.includes('实时工具输出')"); + await capture('running-tools-approval'); + await click('#cancel'); + await waitFor("document.getElementById('status').textContent.includes('取消请求响应')"); + assert.deepEqual(requests.find(row => row.body?.events?.[0]?.type === 'agent.session.input.cancel').body, { events: [{ type: 'agent.session.input.cancel', turn_id: 'turn_1' }] }); + emit('nevt_3', 'node.session.turn.cancelled', { turn_id: 'turn_1' }); + await waitFor("document.getElementById('turn-status').textContent.includes('已取消')"); + assert.equal(await evaluate("document.getElementById('cancel').disabled"), true); + await click('#approvals button'); await waitFor("document.getElementById('approvals').textContent.includes('没有待审批')"); + assert.deepEqual(requests.find(row => row.path.endsWith('/actions/action_1')).body, { decision: 'accept' }); + approvalPending = true; await click('#actions-refresh'); + await waitFor("document.querySelectorAll('#approvals button').length === 2"); + await click('#approvals button:last-child'); + await waitFor("document.getElementById('approvals').textContent.includes('没有待审批')"); + assert.deepEqual(requests.filter(row => row.path.endsWith('/actions/action_1')).at(-1).body, { decision: 'decline' }); + await click('#diff-refresh'); await waitFor("document.getElementById('diff').textContent === '+测试项目修改'"); + const importPath = join(evidence, 'import-fixture.json'); await writeFile(importPath, JSON.stringify({ files: [{ path: 'hello.txt', content: '导入内容' }] })); + await page.locator('#import-file').setInputFiles(importPath); await click('#import'); + await waitFor("document.getElementById('workspace-status').textContent.includes('导入响应')"); + assert.deepEqual(requests.find(row => row.path.endsWith('/project/import')).body, { files: [{ path: 'hello.txt', content: '导入内容' }] }); + await click('#export'); await waitFor("document.getElementById('workspace-status').textContent.includes('已收到 1 个文件')"); + assert.equal(requests.find(row => row.path.endsWith('/project/export')).method, 'POST'); + const exportPath = join(evidence, 'session_1-project.json'); + for (let attempt = 0; attempt < 100; attempt++) { + try { await readFile(exportPath); break; } + catch (error) { if (error.code !== 'ENOENT') throw error; await new Promise(resolve => setTimeout(resolve, 30)); } + } + assert.deepEqual(JSON.parse(await readFile(exportPath, 'utf8')), { files: [{ path: 'hello.txt', content: '导出内容' }] }); + exportMode = 'artifact'; + await click('#export'); await waitFor("document.getElementById('workspace-status').textContent.includes('project.tar.gz')"); + const archivePath = join(evidence, 'project.tar.gz'); + for (let attempt = 0; attempt < 100; attempt++) { + try { await readFile(archivePath); break; } + catch (error) { if (error.code !== 'ENOENT') throw error; await new Promise(resolve => setTimeout(resolve, 30)); } + } + assert.deepEqual(await readFile(archivePath), archive); + assert.equal(requests.filter(row => row.path.endsWith('/project/export')).length, 2); + assert.deepEqual(requests.filter(row => row.path === exportedArtifact.download_url).map(row => [row.method, row.query, row.bearer]), [['GET', '', true]]); + await capture('artifact-downloaded'); + failDownload = true; + await click('#export'); await waitFor("document.getElementById('status').textContent.includes('download_failed')"); + assert.equal(requests.filter(row => row.path.endsWith('/project/export')).length, 3); + assert.equal(requests.filter(row => row.path === exportedArtifact.download_url).length, 2); + await capture('artifact-download-failed'); + observations.push({ scenario: 'legacy JSON and authenticated artifact tar.gz download bytes match; failed GET shows 502 without automatically repeating export', passed: true, archive: 'project.tar.gz', sha256: exportedArtifact.sha256 }); + await click('#view'); await waitFor("document.querySelector('#desktop iframe') !== null"); + assert.equal(requests.filter(row => row.path.endsWith('/desktop')).at(-1).body.mode, 'view'); + await click('#control'); await waitFor("document.getElementById('desktop-status').textContent.includes('接管授权')"); + assert.equal(requests.filter(row => row.path.endsWith('/desktop')).at(-1).body.mode, 'control'); + assert.equal(await evaluate("document.querySelector('#desktop iframe').src.startsWith(location.origin + '/desktop/')"), true); + assert.equal(requests.some(row => row.path.endsWith('/clipboard')), false); + await click('#clipboard-read'); await waitFor("document.getElementById('clipboard').value === '测试剪贴板内容'"); + await fill('#clipboard', '手动发送'); await click('#clipboard-write'); + await waitFor("document.getElementById('status').textContent.includes('剪贴板写入响应')"); + assert.deepEqual(requests.filter(row => row.path.endsWith('/clipboard')).map(row => [row.method, row.body]), [['GET', undefined], ['POST', { text: '手动发送' }]]); + await capture('desktop-project-clipboard'); + await click('#handback'); await waitFor("document.querySelector('#desktop iframe') === null"); + assert.equal(requests.find(row => row.path.endsWith('/handback')).method, 'POST'); + for (const stream of streams) stream.end(); + await waitFor("document.getElementById('stream-status').textContent.includes('已断开')"); + await click('#resume'); await waitFor("document.getElementById('stream-status').textContent === '事件流已连接'"); + assert.equal(requests.filter(row => row.path.endsWith('/events') && row.method === 'GET').at(-1).cursor, 'nevt_3'); + assert.equal(requests.filter(row => row.body?.events?.[0]?.type === 'message').length, 1); + failDiff = true; await click('#diff-refresh'); await waitFor("document.getElementById('status').textContent.includes('HTTP 502')"); + await capture('error-honesty'); + observations.push({ scenario: 'chat, streamed tools, cancel terminal event, both approval decisions, diff/import/export, desktop/handback, explicit clipboard, cursor resume and 502', passed: true }); + for (const width of [375, 768, 1280]) { + await page.evaluate(`window.scrollTo(0,0)`); + await omowright.emulate(page, { width, height: 900, deviceScaleFactor: 1, mobile: false, hasTouch: false }); + assert.equal(await evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true, `horizontal overflow at ${width}`); + await capture(`responsive-${width}`); + } + await page.locator('#bots').selectOption('bot_2'); + assert.equal(await evaluate("document.getElementById('clipboard').value === '' && document.querySelector('#desktop iframe') === null && document.getElementById('send').disabled"), true); + await click('#disconnect'); assert.equal(await evaluate("document.getElementById('bots').disabled"), true); + capabilityMode = 'unknown'; await fill('#key', 'contract-test-key'); await click('#connect'); + await waitFor("document.getElementById('bots').options.length === 3"); + await page.locator('#bots').selectOption('bot_1'); await click('#attach'); + await waitFor("document.getElementById('thread').textContent === 'session_1'"); + assert.equal(await evaluate("['send','cancel','view','control','clipboard-read','clipboard-write','actions-refresh','diff-refresh','import','export'].every(id=>document.getElementById(id).disabled)"), true); + await capture('unsupported-capabilities'); + assert.equal(await evaluate('localStorage.length === 0 && sessionStorage.length === 0'), true); + await page.goto(origin); await waitFor("document.getElementById('bots').disabled"); + assert.equal(await evaluate("document.getElementById('key').value"), ''); + assert.ok(requests.every(row => row.bearer)); + assert.equal(requests.filter(row => row.path.endsWith('/project/export')).length, 3); + assert.equal(requests.filter(row => row.path === exportedArtifact.download_url).length, 2); + observations.push({ scenario: 'responsive widths, clear on bot switch/disconnect, unsupported gates, reload forgets key', passed: true }); + await writeFile(join(evidence, 'browser-observations.json'), JSON.stringify(observations, null, 2)); + await writeFile(join(evidence, 'requests.json'), JSON.stringify(requests, null, 2)); + } finally { + await writeFile(join(evidence, 'requests.json'), JSON.stringify(requests, null, 2)); + for (const stream of streams) stream.end(); + if (browser) await browser.close(); + server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); + await rm(profile, { recursive: true, force: true }); + } +}); diff --git a/tests/node-agent-api.test.mjs b/tests/node-agent-api.test.mjs new file mode 100644 index 0000000..2bfb59c --- /dev/null +++ b/tests/node-agent-api.test.mjs @@ -0,0 +1,590 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtemp, readFile, realpath, stat, unlink, symlink } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import Ajv from 'ajv'; +import { WebSocket, WebSocketServer } from 'ws'; +import { startNodeAgentApi } from '../tools/node-agent-api/server.mjs'; +import { createRfbFilter } from '../tools/node-agent-api/rfb-filter.mjs'; +import { createAuth } from '../tools/node-agent-api/auth.mjs'; +import { createStore } from '../tools/node-agent-api/store.mjs'; +import { createGrokNodeAdapter } from '../tools/node-agent-api/grok-node.mjs'; +import { recoverServiceLocks } from '../tools/node-agent-api/persistence.mjs'; + +const api = JSON.parse(await readFile(new URL('../tools/node-agent-api/openapi.json', import.meta.url), 'utf8')); +const schemaId = 'urn:node-agent-api:openapi'; +const ajv = new Ajv({ strict: false, allErrors: true }); +ajv.addSchema(api, schemaId); +const validators = new Map(); +function dereference(value) { + if (!value.$ref) return value; + return value.$ref.slice(2).split('/').reduce((current, part) => current[part], api); +} +function validateResponse(method, url, status, data) { + const pathname = new URL(url, 'http://127.0.0.1').pathname; + const route = Object.keys(api.paths).find(template => template === pathname) + ?? Object.keys(api.paths).find(template => new RegExp('^' + template.replace(/\{[^}]+\}/g, '[^/]+') + '$').test(pathname)); + assert.ok(route, 'OpenAPI route missing: ' + method + ' ' + pathname); + const operation = api.paths[route][method.toLowerCase()]; + assert.ok(operation, 'OpenAPI method missing: ' + method + ' ' + route); + const response = dereference(operation.responses[status] ?? operation.responses.default); + const schema = response.content?.['application/json']?.schema; + assert.ok(schema, 'OpenAPI JSON response schema missing: ' + route); + const cacheKey = method + ' ' + route + ' ' + status; + if (!validators.has(cacheKey)) validators.set(cacheKey, ajv.compile({ ...schema, $id: schemaId + ':response:' + validators.size, ...(schema.$ref ? { $ref: schemaId + schema.$ref } : {}) })); + const validate = validators.get(cacheKey); + assert.ok(validate(data), 'Response disagrees with OpenAPI: ' + cacheKey + ' ' + JSON.stringify(validate.errors)); +} + +// /var is a symlink to /private/var on macOS; the credential store rejects +// symlinked ancestors, so start from the resolved path. +const stateDirectory = await realpath(await mkdtemp(path.join(tmpdir(), 'node-agent-api-'))); +const upstreamHttp = createServer(); +const upstream = new WebSocketServer({ noServer: true }); +upstreamHttp.on('upgrade', (req, socket, head) => upstream.handleUpgrade(req, socket, head, client => upstream.emit('connection', client, req))); +await new Promise(resolve => upstreamHttp.listen(0, '127.0.0.1', resolve)); +const upstreamPort = upstreamHttp.address().port; +const received = []; +const drained = Buffer.from('fixture-drained'); +upstream.on('connection', socket => { + let stage = 0; + socket.send(Buffer.from('RFB 003.008\n', 'ascii')); + socket.on('message', data => { + if (stage === 0) { socket.send(Buffer.from([1, 1])); stage = 1; return; } + if (stage === 1) { socket.send(Buffer.from([0, 0, 0, 0])); stage = 2; return; } + if (stage === 2) { stage = 3; return; } + received.push({ type: data[0], size: data.length }); + if (data[0] === 3) socket.send(drained); + }); +}); + +const bots = [ + { object: 'node.agent', id: 'bot-a', name: 'Alpha', description: '', backend: 'grok_node' }, + { object: 'node.agent', id: 'bot-b', name: 'Beta', description: '', backend: 'grok_node' }, +]; +let inputLog = []; +const adapter = { + writesEnabled: true, + async agents() { return bots; }, + async requireAgent(id) { const bot = bots.find(b => b.id === id); if (!bot) { const error = new Error('missing'); error.status = 404; error.code = 'not_found'; throw error; } return bot; }, + async items() { return [{ kind: 'send-message', id: 'e1', text: 'hello' }]; }, + async input(id, text, requestId) { inputLog.push({ id, text, requestId }); return { accepted: true }; }, + async createAgent(input) { return { object: 'node.agent', id: 'bot-new', name: input.name }; }, + async desktop(id) { + return { object: 'node.environment', id: 'nenv_' + id, agent_id: id, type: 'grok_node', isolation: 'shared_container_separate_display', container: 'grok-node-local-vm', display: 3, websocketUrl: 'ws://127.0.0.1:' + upstreamPort }; + }, + async viewerAsset(asset) { + if (asset === 'vnc.html') return Buffer.from('viewer'); + if (asset === 'core/rfb.js') return Buffer.from('export class RFB {}'); + throw Object.assign(new Error('missing'), { status: 404, code: 'not_found' }); + }, +}; + +const service = await startNodeAgentApi({ adapter, stateDirectory }); +const ownerKey = (await readFile(service.keyFile, 'utf8')).trim(); +const authorization = 'Bearer ' + ownerKey; +let sessions = {}; +let issued = []; + +async function call(method, url, { body, headers = {}, raw = false } = {}) { + const response = await fetch(service.origin + url, { + method, + headers: { ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + redirect: 'manual', + }); + if (raw) return response; + const data = await response.json(); + validateResponse(method, url, response.status, data); + return { status: response.status, data }; +} + +test('health and unauthenticated access', async () => { + const health = await call('GET', '/health'); + assert.equal(health.status, 200); + assert.equal(health.data.backend, 'grok_node'); + assert.equal(health.data.compatible_with_openai_sdk, false); + assert.equal((await call('GET', '/v1/agents')).status, 401); + assert.equal((await call('GET', '/v1/agents', { headers: { authorization: 'Bearer wrong' } })).status, 401); +}); + +test('host and origin guard reject foreign requests', async () => { + const response = await fetch(service.origin + '/v1/agents', { headers: { authorization, origin: 'https://untrusted.invalid' } }); + assert.equal(response.status, 403); +}); + +test('agent roster is scoped to the caller', async () => { + const all = await call('GET', '/v1/agents', { headers: { authorization } }); + assert.equal(all.status, 200); + assert.deepEqual(all.data.data.map(a => a.id), ['bot-a', 'bot-b']); + const created = await call('POST', '/v1/keys', { headers: { authorization }, body: { scopes: ['agents.read', 'sessions.read', 'sessions.write', 'desktop.view'], bot_ids: ['bot-a'], ttl_seconds: 120 } }); + assert.equal(created.status, 201); + assert.ok(created.data.key.startsWith('node_') || created.data.key.length > 30); + issued.push(created.data); + const scoped = await call('GET', '/v1/agents', { headers: { authorization: 'Bearer ' + created.data.key } }); + assert.deepEqual(scoped.data.data.map(a => a.id), ['bot-a']); + assert.equal((await call('GET', '/v1/agents/bot-b', { headers: { authorization: 'Bearer ' + created.data.key } })).status, 403); + assert.equal((await call('GET', '/v1/agents/bot-b', { headers: { authorization } })).status, 200); +}); + +test('sessions attach to an existing bot and reuse its transcript', async () => { + const created = await call('POST', '/v1/agents/sessions', { headers: { authorization }, body: { agent_id: 'bot-a' } }); + assert.equal(created.status, 201); + sessions.a = created.data; + const again = await call('POST', '/v1/agents/sessions', { headers: { authorization }, body: { agent_id: 'bot-a' } }); + assert.equal(again.status, 200); + assert.equal(again.data.id, created.data.id); + const other = await call('POST', '/v1/agents/sessions', { headers: { authorization }, body: { agent: { id: 'bot-b' } } }); + sessions.b = other.data; + assert.notEqual(other.data.id, created.data.id); + const list = await call('GET', '/v1/agents/sessions', { headers: { authorization } }); + assert.equal(list.data.data.length >= 2, true); + const items = await call('GET', '/v1/agents/sessions/' + created.data.id + '/items', { headers: { authorization } }); + assert.equal(items.data.context, 'grok_node_bot_transcript'); + assert.equal(items.data.data[0].text, 'hello'); + const environment = await call('GET', '/v1/agents/sessions/' + created.data.id + '/environment', { headers: { authorization } }); + assert.equal(environment.status, 200); + assert.equal(environment.data.display, 3); + assert.equal(Object.hasOwn(environment.data, 'websocketUrl'), false); + assert.equal((await call('GET', '/v1/agents/sessions/nsess_missing/items', { headers: { authorization } })).status, 404); +}); + +test('message input is idempotent and streamed as events', async () => { + const id = sessions.a.id; + const events = await call('GET', '/v1/agents/sessions/' + id + '/events', { headers: { authorization } }); + assert.equal(events.data.data.some(e => e.type === 'node.session.created'), true); + const key = 'nreq_fixture_1'; + const first = await call('POST', '/v1/agents/sessions/' + id + '/events', { headers: { authorization }, body: { events: [{ type: 'message', text: 'run the suite' }], idempotency_key: key } }); + assert.equal(first.status, 202); + assert.equal(first.data.status, 'accepted'); + assert.equal(first.data.completed, false); + assert.deepEqual(inputLog.at(-1), { id: 'bot-a', text: 'run the suite', requestId: key }); + const replay = await call('POST', '/v1/agents/sessions/' + id + '/events', { headers: { authorization }, body: { events: [{ type: 'message', text: 'run the suite' }], idempotency_key: key } }); + assert.equal(replay.status, 202); + assert.equal(replay.data.replayed, true); + assert.equal(replay.data.completed, false, 'acceptance does not prove model completion'); + assert.equal(replay.data.request_id, key); + assert.equal(inputLog.filter(entry => entry.requestId === key).length, 1); + const conflict = await call('POST', '/v1/agents/sessions/' + id + '/events', { headers: { authorization }, body: { events: [{ type: 'message', text: 'different' }], idempotency_key: key } }); + assert.equal(conflict.status, 409); + assert.equal(conflict.data.error.type, 'idempotency_conflict'); + const unsupported = await call('POST', '/v1/agents/sessions/' + id + '/events', { headers: { authorization }, body: { events: [{ type: 'turn.cancel' }] } }); + assert.equal(unsupported.status, 400); + const after = await call('GET', '/v1/agents/sessions/' + id + '/events', { headers: { authorization } }); + assert.equal(after.data.data.some(e => e.type === 'node.session.input.accepted' && e.data.request_id === key), true); +}); + +test('SSE replays stored events and forwards new ones', async () => { + const id = sessions.a.id; + const response = await fetch(service.origin + '/v1/agents/sessions/' + id + '/events', { headers: { authorization, accept: 'text/event-stream' } }); + assert.equal(response.status, 200); + const reader = response.body.getReader(); + const firstChunk = await reader.read(); + const text = new TextDecoder().decode(firstChunk.value); + assert.match(text, /event: node\.session\.created/); + const posted = call('POST', '/v1/agents/sessions/' + id + '/events', { headers: { authorization }, body: { events: [{ type: 'message', text: 'stream probe' }] } }); + let live = ''; + const deadline = Date.now() + 4000; + while (Date.now() < deadline && !live.includes('node.session.input.accepted')) { + const chunk = await reader.read(); + if (chunk.done) break; + live += new TextDecoder().decode(chunk.value); + } + await posted; + assert.match(live, /node\.session\.input\.accepted/); + await reader.cancel(); +}); + +test('desktop authorization is single-use, bot-bound and server enforced', async () => { + const view = await call('POST', '/v1/agents/sessions/' + sessions.a.id + '/desktop', { headers: { authorization }, body: { mode: 'view', ttl_seconds: 60 } }); + assert.equal(view.status, 201); + assert.equal(view.data.single_use, true); + assert.equal(view.data.server_enforced, true); + assert.equal(view.data.pauses_agent, false); + assert.equal(view.data.display, 3); + const ticket = new URL(view.data.url).searchParams.get('ticket'); + const opened = await call('GET', '/desktop/open?ticket=' + ticket, { raw: true, headers: { authorization } }); + assert.equal(opened.status, 303); + const cookie = /nodeviewer=([A-Za-z0-9_-]+)/.exec(opened.headers.get('set-cookie'))[1]; + assert.equal((await call('GET', '/desktop/open?ticket=' + ticket, { raw: true, headers: { authorization } })).status, 401); + const mandatory = await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + cookie } }); + const settings = await mandatory.json(); + const validateSettings = ajv.compile({ $ref: schemaId + '#/components/schemas/ViewerSettings' }); + assert.ok(validateSettings(settings), JSON.stringify(validateSettings.errors)); + assert.equal(settings.view_only, true); + assert.equal(settings.path, 'desktop/socket'); + const html = await call('GET', '/desktop/vnc.html', { raw: true, headers: { cookie: 'nodeviewer=' + cookie } }); + assert.match(await html.text(), /#noVNC_control_bar/); + const scoped = issued[0]; + const crossBot = await call('POST', '/v1/agents/sessions/' + sessions.b.id + '/desktop', { headers: { authorization: 'Bearer ' + scoped.key }, body: { mode: 'view' } }); + assert.equal(crossBot.status, 403, 'a bot-a credential must not open bot-b desktop'); + sessions.viewCookie = cookie; +}); + +async function driveViewer(cookie, messages) { + const socket = new WebSocket(service.origin.replace('http:', 'ws:') + '/desktop/socket', { headers: { cookie: 'nodeviewer=' + cookie, origin: service.origin } }); + const closed = new Promise(resolve => { socket.once('close', resolve); socket.once('error', resolve); }); + await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); }); + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Upstream did not receive the framebuffer barrier')), 4000); + socket.on('message', data => { if (data.equals(drained)) { clearTimeout(timeout); resolve(); } }); + socket.once('close', () => { clearTimeout(timeout); reject(new Error('Viewer closed before the framebuffer barrier')); }); + socket.once('error', error => { clearTimeout(timeout); reject(error); }); + socket.send(Buffer.from('RFB 003.008\n', 'ascii')); + socket.send(Buffer.from([1])); + socket.send(Buffer.from([0])); + for (const message of messages) socket.send(Buffer.from(message)); + // The upstream acknowledgement proves every preceding message was processed. + socket.send(Buffer.from([3, 0, 0, 0, 0, 0, 0, 1, 0, 1])); + }); + } finally { socket.terminate(); await closed; } +} + +test('view-only viewers cannot deliver input even when the client tries', async () => { + received.length = 0; + await driveViewer(sessions.viewCookie, [ + [4, 1, 0, 0, 0, 0, 0, 65], [5, 0, 0, 0, 0, 0], + [2, 0, 0, 1, 0, 0, 0, 0], [6, 0, 0, 0, 0, 0, 0, 0], + [250, 0, 1, 2], [251, 0, 0, 1, 0, 1, 0, 0], + [255, 0, 0, 1, 0, 0, 0, 65, 0, 0, 0, 30], + ]); + const types = received.map(entry => entry.type); + assert.equal(types.includes(2), true, 'non-mutating SetEncodings must pass'); + for (const type of [4, 5, 6, 250, 251, 255]) assert.equal(types.includes(type), false, 'mutating RFB message ' + type + ' must be dropped in view mode'); +}); + +test('control viewers can deliver input and hold a single exclusive lease', async () => { + const control = await call('POST', '/v1/agents/sessions/' + sessions.a.id + '/desktop', { headers: { authorization }, body: { mode: 'control', ttl_seconds: 60 } }); + assert.equal(control.status, 201); + const ticket = new URL(control.data.url).searchParams.get('ticket'); + const opened = await call('GET', '/desktop/open?ticket=' + ticket, { raw: true, headers: { authorization } }); + const cookie = /nodeviewer=([A-Za-z0-9_-]+)/.exec(opened.headers.get('set-cookie'))[1]; + const settings = await (await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + cookie } })).json(); + assert.equal(settings.view_only, false); + const busy = await call('POST', '/v1/agents/sessions/' + sessions.a.id + '/desktop', { headers: { authorization }, body: { mode: 'control' } }); + assert.equal(busy.status, 409); + assert.equal(busy.data.error.type, 'desktop_busy'); + received.length = 0; + await driveViewer(cookie, [[5, 0, 0, 0, 0, 0]]); + assert.equal(received.some(entry => entry.type === 5), true, 'pointer event must pass while the lease is held'); + const released = await call('POST', '/v1/agents/sessions/' + sessions.a.id + '/handback', { headers: { authorization }, body: {} }); + assert.equal(released.data.released, true); +}); + +test('revoking a credential closes its access and live desktop lease', async () => { + // Authorize a viewer with the scoped credential, then revoke that same key. + const scoped = issued[0]; + const scopedSession = await call('POST', '/v1/agents/sessions', { headers: { authorization: 'Bearer ' + scoped.key }, body: { agent_id: 'bot-a' } }); + const scopedView = await call('POST', '/v1/agents/sessions/' + scopedSession.data.id + '/desktop', { headers: { authorization: 'Bearer ' + scoped.key }, body: { mode: 'view' } }); + assert.equal(scopedView.status, 201); + const scopedTicket = new URL(scopedView.data.url).searchParams.get('ticket'); + const scopedOpened = await call('GET', '/desktop/open?ticket=' + scopedTicket, { raw: true, headers: { authorization: 'Bearer ' + scoped.key } }); + const scopedCookie = /nodeviewer=([A-Za-z0-9_-]+)/.exec(scopedOpened.headers.get('set-cookie'))[1]; + assert.equal((await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + scopedCookie } })).status, 200); + const live = new WebSocket(service.origin.replace('http:', 'ws:') + '/desktop/socket', { headers: { cookie: 'nodeviewer=' + scopedCookie, origin: service.origin } }); + const liveClosed = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Revoked viewer stayed connected')), 4000); + live.once('close', () => { clearTimeout(timeout); resolve(); }); + }); + await new Promise((resolve, reject) => { live.once('open', resolve); live.once('error', reject); }); + const revoked = await call('DELETE', '/v1/keys/' + scoped.id, { headers: { authorization } }); + assert.equal(revoked.data.revoked, true); + await liveClosed; + assert.equal((await call('GET', '/v1/agents', { headers: { authorization: 'Bearer ' + scoped.key } })).status, 401); + assert.equal((await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + scopedCookie } })).status, 401); + assert.equal((await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + sessions.viewCookie } })).status, 200, 'an owner viewer stays valid'); +}); + +test('credential store and owner key stay private', async () => { + assert.equal((await stat(service.keyFile)).mode & 0o777, 0o600); + assert.equal((await stat(path.join(stateDirectory, 'keys.json'))).mode & 0o777, 0o600); + const stored = await readFile(path.join(stateDirectory, 'keys.json'), 'utf8'); + assert.equal(stored.includes(ownerKey), false); + assert.equal(stored.includes(issued[0].key), false); +}); + +test('RFB filter rejects unknown protocols and fragments input correctly', () => { + const invalid = createRfbFilter(() => true); + assert.throws(() => invalid(Buffer.from('RFB 003.003\n')), /RFB 3\.8/); + const init = createRfbFilter(() => true); + assert.equal(init(Buffer.from('RFB 003.')).length, 0); + assert.equal(init(Buffer.from('008\n')).length, 1); + assert.equal(init(Buffer.from([1])).length, 1); + assert.equal(init(Buffer.from([0])).length, 1); + // SetPixelFormat is a 20-byte message; a 1-byte fragment must wait. + assert.equal(init(Buffer.from([0])).length, 0); + assert.equal(init(Buffer.alloc(19)).length, 1); + // In the normal phase an unknown message type is rejected outright. + assert.throws(() => init(Buffer.from([99, 0, 0])), /Unsupported RFB message/); +}); + +test('pagination follows authorized cursors and rejects invalid limits', async () => { + const first = await call('GET', '/v1/agents?limit=1', { headers: { authorization } }); + assert.equal(first.data.data[0].id, 'bot-a'); + assert.equal(first.data.has_more, true); + const next = await call('GET', '/v1/agents?limit=1&after=' + first.data.last_id, { headers: { authorization } }); + assert.equal(next.data.data[0].id, 'bot-b'); + assert.equal(next.data.has_more, false); + const descending = await call('GET', '/v1/agents?limit=1&order=desc', { headers: { authorization } }); + assert.equal(descending.data.first_id, 'bot-b'); + const filtered = await call('GET', '/v1/agents/sessions?agent_id=bot-b&limit=1', { headers: { authorization } }); + assert.equal(filtered.data.data.length, 1); + assert.equal(filtered.data.data[0].agent_id, 'bot-b'); + for (const query of ['limit=0', 'limit=101', 'limit=1.5', 'order=sideways', 'after=missing']) assert.equal((await call('GET', '/v1/agents?' + query, { headers: { authorization } })).status, 400); +}); + +test('capability discovery explicitly reports unsupported runtime features', async () => { + const response = await call('GET', '/v1/capabilities', { headers: { authorization } }); + assert.equal(response.status, 200); + assert.equal(response.data.version, '0.3.0'); + assert.equal(response.data.events.turn_outcomes, false); + assert.equal(response.data.events.cancellation, false); + assert.equal(response.data.sessions.independent_conversations, false); + assert.equal(response.data.security.legacy_vnc_protected, false); +}); + +test('official text input and concurrent retries submit exactly once', async () => { + const url = '/v1/agents/sessions/' + sessions.a.id + '/events'; + const body = { events: [{ type: 'agent.session.input.message', input: [{ role: 'user', content: [{ type: 'input_text', text: 'concurrent probe' }] }] }] }; + const key = 'nreq_concurrent'; + const replies = await Promise.all(Array.from({ length: 6 }, () => call('POST', url, { headers: { authorization, 'Idempotency-Key': key }, body }))); + assert.equal(replies.every(r => r.status === 202 && r.data.completed === false), true); + assert.equal(replies.filter(r => r.data.replayed).length, 5); + assert.equal(inputLog.filter(entry => entry.requestId === key).length, 1); + const legacy = await call('POST', url, { headers: { authorization }, body: { events: [{ type: 'message', text: 'concurrent probe' }], idempotency_key: key } }); + assert.equal(legacy.data.replayed, true); + assert.equal((await call('POST', url, { headers: { authorization, 'Idempotency-Key': 'other' }, body: { ...body, idempotency_key: key } })).status, 400); + const constructor = await call('POST', url, { headers: { authorization, 'Idempotency-Key': 'constructor' }, body }); + assert.equal(constructor.status, 202, 'valid identifiers must not collide with object prototype fields'); +}); + +test('clean restart preserves metadata and SSE replay from a persisted cursor', async () => { + const directory = await realpath(await mkdtemp(path.join(tmpdir(), 'node-agent-resume-'))); + let instance = await startNodeAgentApi({ adapter, stateDirectory: directory }); + const key = (await readFile(instance.keyFile, 'utf8')).trim(); + const headers = { authorization: 'Bearer ' + key, 'content-type': 'application/json' }; + try { + const created = await (await fetch(instance.origin + '/v1/agents/sessions', { method: 'POST', headers, body: JSON.stringify({ agent_id: 'bot-a', metadata: { project: 'persisted' } }) })).json(); + const base = '/v1/agents/sessions/' + created.id; + await fetch(instance.origin + base + '/items', { headers }); + const before = await (await fetch(instance.origin + base + '/events', { headers })).json(); + assert.equal(before.data.length, 2); + await instance.close(); + instance = await startNodeAgentApi({ adapter, stateDirectory: directory }); + assert.equal((await readFile(instance.keyFile, 'utf8')).trim(), key); + const restored = await (await fetch(instance.origin + base, { headers })).json(); + assert.equal(restored.metadata.project, 'persisted'); + const response = await fetch(instance.origin + base + '/events', { headers: { ...headers, accept: 'text/event-stream', 'Last-Event-ID': before.data[0].id }, signal: AbortSignal.timeout(4000) }); + const reader = response.body.getReader(); + const first = new TextDecoder().decode((await reader.read()).value); + assert.match(first, /event: node\.session\.items\.changed/); + assert.equal(first.includes(before.data[1].id), true); + assert.equal(first.includes('event: node.session.created'), false); + await reader.cancel(); + } finally { await instance.close(); } +}); + +test('SSE polling persists one change notification shared by two viewers', async () => { + const directory = await realpath(await mkdtemp(path.join(tmpdir(), 'node-agent-poll-'))); + let items = [{ id: 'original', text: 'before' }]; + const instance = await startNodeAgentApi({ adapter: { ...adapter, async items() { return items; } }, stateDirectory: directory, pollIntervalMs: 25 }); + const key = (await readFile(instance.keyFile, 'utf8')).trim(); + const headers = { authorization: 'Bearer ' + key, 'content-type': 'application/json' }; + const readers = []; + try { + const created = await (await fetch(instance.origin + '/v1/agents/sessions', { method: 'POST', headers, body: JSON.stringify({ agent_id: 'bot-a' }) })).json(); + const base = '/v1/agents/sessions/' + created.id; + await fetch(instance.origin + base + '/items', { headers }); + const before = await (await fetch(instance.origin + base + '/events', { headers })).json(); + for (let i = 0; i < 2; i++) { + const response = await fetch(instance.origin + base + '/events?after=' + before.last_id, { headers: { ...headers, accept: 'text/event-stream' }, signal: AbortSignal.timeout(4000) }); + readers.push(response.body.getReader()); + } + items = [...items, { id: 'new', text: 'after' }]; + const chunks = await Promise.all(readers.map(async reader => new TextDecoder().decode((await reader.read()).value))); + assert.equal(chunks.every(chunk => chunk.includes('node.session.items.changed')), true); + const ids = chunks.map(chunk => /^id: ([^\n]+)/.exec(chunk)[1]); + assert.equal(ids[0], ids[1]); + const after = await (await fetch(instance.origin + base + '/events?after=' + before.last_id, { headers })).json(); + assert.equal(after.data.length, 1); + assert.equal(after.data[0].data.item_count, 2); + } finally { await Promise.all(readers.map(reader => reader.cancel())); await instance.close(); } +}); + +test('session metadata, persistent item notifications and explicit close', async () => { + const url = '/v1/agents/sessions/' + sessions.b.id; + const updated = await call('PATCH', url, { headers: { authorization }, body: { metadata: { project: 'beta' } } }); + assert.equal(updated.data.metadata.project, 'beta'); + assert.equal((await call('PATCH', url, { headers: { authorization }, body: { metadata: { wrong: 1 } } })).status, 400); + const cleared = await call('PATCH', url, { headers: { authorization }, body: { metadata: null } }); + assert.deepEqual(cleared.data.metadata, {}); + await call('GET', url + '/items', { headers: { authorization } }); + await call('GET', url + '/items', { headers: { authorization } }); + const events = await call('GET', url + '/events', { headers: { authorization } }); + assert.equal(events.data.data.filter(e => e.type === 'node.session.items.changed').length, 1); + const unknown = await call('GET', url + '/events?after=missing', { raw: true, headers: { authorization, accept: 'text/event-stream' } }); + assert.equal(unknown.status, 400); + const changed = events.data.data.find(e => e.type === 'node.session.items.changed'); + const resumed = await call('GET', url + '/events?after=' + changed.id, { raw: true, headers: { authorization, accept: 'text/event-stream' } }); + assert.equal(resumed.status, 200, 'an empty replay still establishes the SSE connection'); + await resumed.body.cancel(); + const closed = await call('POST', url + '/close', { headers: { authorization } }); + assert.equal(closed.data.status, 'closed'); + assert.equal((await call('POST', url + '/events', { headers: { authorization }, body: { events: [{ type: 'message', text: 'blocked' }] } })).status, 409); + const next = await call('POST', '/v1/agents/sessions', { headers: { authorization }, body: { agent_id: 'bot-b', metadata: { project: 'new' } } }); + assert.notEqual(next.data.id, sessions.b.id); + assert.equal((await call('GET', url, { headers: { authorization } })).data.status, 'closed', 'closed records remain readable'); +}); + +test('delegated credentials cannot outlive or revoke unrelated parents', async () => { + const directory = await realpath(await mkdtemp(path.join(tmpdir(), 'node-agent-auth-'))); + let now = 1000000; + const auth = await createAuth(directory, () => now); + const owner = auth.authenticate('Bearer ' + (await readFile(auth.keyFile, 'utf8')).trim()); + const parent = await auth.issue(owner, { scopes: ['keys.manage', 'agents.read'], bot_ids: ['bot-a'], ttl_seconds: 10 }); + const sibling = await auth.issue(owner, { scopes: ['agents.read'], bot_ids: ['bot-a'], ttl_seconds: 100 }); + const principal = auth.authenticate('Bearer ' + parent.key); + const child = await auth.issue(principal, { scopes: ['agents.read'], bot_ids: ['bot-a'], ttl_seconds: 100 }); + assert.equal(child.expires_at, parent.expires_at); + await assert.rejects(auth.revoke(principal, sibling.id), /does not allow/); + await auth.revoke(owner, parent.id); + assert.throws(() => auth.authenticate('Bearer ' + child.key), /invalid or expired/); + assert.equal(auth.authenticate('Bearer ' + sibling.key).id, sibling.id); + now += 101000; + assert.throws(() => auth.authenticate('Bearer ' + sibling.key), /invalid or expired/); +}); + +test('state lock prevents concurrent owners and state symlinks fail closed', async () => { + await assert.rejects(startNodeAgentApi({ adapter, stateDirectory }), /locked/); + const directory = await realpath(await mkdtemp(path.join(tmpdir(), 'node-agent-state-'))); + await symlink(path.join(stateDirectory, 'sessions.json'), path.join(directory, 'sessions.json')); + await assert.rejects(createStore(directory), /ELOOP/); +}); + +test('real process crash preserves input state without resubmitting work', async () => { + const directory = await realpath(await mkdtemp(path.join(tmpdir(), 'node-agent-crash-'))); + const child = spawn(process.execPath, [new URL('./fixtures/node-agent-api-crash.mjs', import.meta.url).pathname, directory], { stdio: ['ignore', 'pipe', 'pipe'] }); + const exited = new Promise(resolve => child.once('exit', resolve)); + const lines = createInterface({ input: child.stdout }); + const notifications = []; + const listeners = new Set(); + lines.on('line', line => { const value = JSON.parse(line); notifications.push(value); for (const listener of listeners) listener(); }); + function waitFor(predicate) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { listeners.delete(check); reject(new Error('Crash fixture did not reach checkpoint')); }, 5000); + function check() { const value = notifications.find(predicate); if (value) { clearTimeout(timeout); listeners.delete(check); resolve(value); } } + listeners.add(check); check(); + }); + } + let recovered; + try { + const started = await waitFor(value => value.origin); + const key = (await readFile(started.keyFile, 'utf8')).trim(); + const headers = { authorization: 'Bearer ' + key, 'content-type': 'application/json' }; + const created = await (await fetch(started.origin + '/v1/agents/sessions', { method: 'POST', headers, body: JSON.stringify({ agent_id: 'bot-a' }) })).json(); + const payload = { events: [{ type: 'message', text: 'crash probe' }], idempotency_key: 'nreq_crash' }; + const pending = fetch(started.origin + '/v1/agents/sessions/' + created.id + '/events', { method: 'POST', headers, body: JSON.stringify(payload) }).catch(() => null); + await waitFor(value => value.backend_called); + child.kill('SIGKILL'); await exited; await pending; + // Remove only this test child's lock after authoritative process termination. + await recoverServiceLocks(directory); + let calls = 0; + recovered = await startNodeAgentApi({ adapter: { ...adapter, async input() { calls++; return { accepted: true }; } }, stateDirectory: directory }); + assert.equal((await readFile(recovered.keyFile, 'utf8')).trim(), key); + const state = await (await fetch(recovered.origin + '/v1/agents/sessions/' + created.id, { headers })).json(); + assert.equal(state.status, 'unknown'); + const replay = await (await fetch(recovered.origin + '/v1/agents/sessions/' + created.id + '/events', { method: 'POST', headers, body: JSON.stringify(payload) })).json(); + assert.equal(replay.status, 'unknown'); assert.equal(replay.replayed, true); assert.equal(calls, 0); + const events = await (await fetch(recovered.origin + '/v1/agents/sessions/' + created.id + '/events', { headers })).json(); + assert.equal(events.data.some(e => e.type === 'node.session.input.unknown' && e.data.reason === 'service_restart'), true); + } finally { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); await exited; } lines.close(); await recovered?.close(); } +}); + +test('write-enabled GrokNode backends require ownership and safe mounts', async () => { + const container = 'grok-node-lab-api-test'; + let info = { Name: '/' + container, Config: { Labels: { 'com.groknode.lab.namespace': 'grok-node-lab-api' } }, Mounts: [] }; + const methods = []; + const guarded = createGrokNodeAdapter({ container, allowWrites: true, async execute(_binary, args) { + if (args[0] === 'inspect') return { stdout: JSON.stringify([info]) }; + const method = args.at(-2); methods.push(method); + return { stdout: JSON.stringify(method === 'listAgents' ? bots : { accepted: true }) }; + } }); + await guarded.input('bot-a', 'allowed', 'request'); + assert.equal(methods.includes('sendPrompt'), true); + methods.length = 0; + info.Mounts = [{ Type: 'bind', Destination: '/host' }]; + await assert.rejects(guarded.input('bot-a', 'blocked', 'request'), /isolation check/); + assert.deepEqual(methods, []); + info.Mounts = []; info.Config.Labels['com.groknode.lab.bot-id'] = 'bot-b'; + await assert.rejects(guarded.input('bot-a', 'blocked', 'request'), /isolation check/); + info.Config.Labels = {}; + await assert.rejects(guarded.createAgent({ name: 'blocked' }), /namespace label/); + info.Config.Labels = { 'com.groknode.lab.namespace': 'grok-node-lab-api' }; delete info.Mounts; + await assert.rejects(guarded.createAgent({ name: 'blocked' }), /isolation check/); +}); + +test('OpenAPI request schemas accept documented approval, cancel and user-key payloads', () => { + const approval = ajv.compile({ $ref: schemaId + '#/components/schemas/AnswerAction' }); + assert.equal(approval({ decision: 'accept' }), true); + assert.equal(approval({ decision: 'decline' }), true); + assert.equal(approval({ decision: 'approve' }), false); + assert.equal(approval({}), false); + assert.equal(approval({ decision: 'accept', command: 'untrusted' }), false); + + const eventInput = api.paths['/v1/agents/sessions/{session_id}/events'].post.requestBody.content['application/json'].schema; + const submit = ajv.compile({ $ref: schemaId + eventInput.$ref }); + assert.equal(submit({ events: [{ type: 'message', text: 'Run project tests' }], idempotency_key: 'tests-001' }), true); + assert.equal(submit({ events: [{ type: 'agent.session.input.cancel', turn_id: 'turn_example' }] }), true); + assert.equal(submit({ events: [{ type: 'agent.session.input.cancel' }] }), false); + assert.equal(submit({ events: [{ type: 'agent.session.input.cancel', turn_id: 'turn_example' }], idempotency_key: 'cancel-001' }), false); + + const result = ajv.compile({ $ref: schemaId + '#/components/schemas/SubmitResult' }); + assert.equal(result({ request_id: 'tests-001', status: 'accepted', completed: false, turn_id: 'turn_example' }), true); + assert.equal(result({ accepted: true, turn_id: 'turn_example', completed: false }), true); + assert.equal(result({ accepted: true, completed: false }), false); + assert.equal(result({ accepted: true, turn_id: 'turn_example', completed: true }), false); + + const issueKey = ajv.compile({ $ref: schemaId + '#/components/schemas/IssueKey' }); + assert.equal(issueKey({ scopes: ['agents.read', 'sessions.read', 'desktop.view'], bot_ids: ['bot-example'], user_id: 'nuser_example', ttl_seconds: 3600 }), true); + assert.equal(issueKey({ scopes: ['agents.read'], bot_ids: ['bot-example'], user_id: 42 }), false); + + const settings = ajv.compile({ $ref: schemaId + '#/components/schemas/ViewerSettings' }); + const base = { autoconnect: true, resize: 'scale', reconnect: false, view_only: true }; + assert.equal(settings({ ...base, path: 'desktop/socket' }), true); + assert.equal(settings({ ...base, path: 'desktop/lane/example_viewer/socket' }), true); + assert.equal(settings({ ...base, path: 'unrelated/socket' }), false); +}); + +test('OpenAPI references, operations and response schemas are well formed', () => { + assert.equal(api.openapi, '3.0.3'); + const operationIds = new Set(); + function visit(value) { + if (!value || typeof value !== 'object') return; + if (value.$ref) assert.ok(dereference(value), 'Unresolved reference: ' + value.$ref); + for (const child of Object.values(value)) visit(child); + } + visit(api); + for (const name of Object.keys(api.components.schemas)) ajv.compile({ $ref: schemaId + '#/components/schemas/' + name }); + for (const [route, pathItem] of Object.entries(api.paths)) { + for (const [method, operation] of Object.entries(pathItem)) { + if (!['get', 'post', 'delete', 'patch'].includes(method)) continue; + assert.ok(operation.operationId); + assert.equal(operationIds.has(operation.operationId), false, 'Duplicate operation ID'); + operationIds.add(operation.operationId); + const parameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])].map(dereference); + for (const match of route.matchAll(/\{([^}]+)\}/g)) assert.ok(parameters.some(p => p.in === 'path' && p.name === match[1] && p.required)); + for (const response of Object.values(operation.responses)) assert.equal(typeof dereference(response).description, 'string'); + } + } +}); + +test.after(async () => { + await service.close(); + upstream.close(); + await new Promise(resolve => upstreamHttp.close(resolve)); +}); diff --git a/tests/node-agent-applications.test.mjs b/tests/node-agent-applications.test.mjs new file mode 100644 index 0000000..0da2ecb --- /dev/null +++ b/tests/node-agent-applications.test.mjs @@ -0,0 +1,337 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import net from 'node:net'; +import WebSocket from 'ws'; +import { createApplicationPool, parseWindowTree } from '../tools/node-agent-api/runtime/applications.mjs'; + +test('window discovery handles native displays without a window-manager list', () => { + const tree = ' 0x1a00004 "about:blank - Google Chrome": ("google-chrome" "box-chrome") 1050x780+10+10 +10+10\n 0x200003 "Node Agent Linux project": ("xfce4-terminal" "Xfce4-terminal") 817x485+0+0 +0+0\n 0xe00003 "plank": ("plank" "Plank") 1280x137+0+663 +0+663\n 0x200001 "Xfce Terminal": ("xfce4-terminal" "Xfce4-terminal") 10x10+10+10 +10+10\n 0x200004 "child": () 817x485+0+0'; + assert.deepEqual([...parseWindowTree(tree)], [['0x1a00004', 'about:blank - Google Chrome'], ['0x200003', 'Node Agent Linux project']]); +}); +import { startDesktopProxy } from '../tools/node-agent-api/runtime/desktop-proxy.mjs'; + +function execFileWith(ids) { + return (file, args, options, callback) => { + if (file === 'xprop' && args[0] === '-root') { + queueMicrotask(() => callback(null, `_NET_CLIENT_LIST(WINDOW): window id # ${ids.join(' ')}\n`, '')); + return; + } + queueMicrotask(() => callback(new Error(file + ' ' + args.join(' ')), '', '')); + }; +} + +function fakeCreateConnection() { + const socket = new EventEmitter(); + socket.setTimeout = () => {}; + socket.destroy = () => {}; + queueMicrotask(() => socket.emit('connect')); + return socket; +} + +function unavailableCreateConnection() { + const socket = new EventEmitter(); + socket.setTimeout = () => {}; + socket.destroy = () => {}; + queueMicrotask(() => socket.emit('error', new Error('refused'))); + return socket; +} + +let fakePort = 30000; +function nextPort() { return Promise.resolve(fakePort++); } + +function fakeChild() { + const child = new EventEmitter(); + child.exitCode = null; + child.signalCode = null; + child.kill = signal => queueMicrotask(() => { + child.exitCode = 0; + child.signalCode = signal; + child.emit('exit', 0, signal); + }); + return child; +} + +test('application pool size is a numeric map property and rejects invalid input', async () => { + let spawns = 0; + const pool = createApplicationPool({ + maxProcesses: 2, + execFileImpl: execFileWith(['0x0a000001']), + createConnectionImpl: fakeCreateConnection, + portImpl: nextPort, + spawnImpl: () => { spawns += 1; return fakeChild(); }, + }); + assert.equal(pool.size, 0); + await assert.rejects(() => pool.connect('1'), error => error.status === 400); + await assert.rejects(() => pool.connect('0x0a000001', 'inject'), error => error.status === 400); + assert.equal(pool.size, 0); + assert.equal(spawns, 0); +}); + +test('concurrent connects for the same mode and application spawn one x11vnc process', async () => { + let spawns = 0; + const argsSeen = []; + const pool = createApplicationPool({ + maxProcesses: 4, + execFileImpl: execFileWith(['0x0a000001']), + createConnectionImpl: fakeCreateConnection, + portImpl: nextPort, + spawnImpl: (binary, args) => { spawns += 1; argsSeen.push(args); return fakeChild(); }, + }); + const lanes = await Promise.all([ + pool.connect('0x0A000001', 'view'), + pool.connect('0x0a000001', 'view'), + ]); + assert.equal(spawns, 1); + assert.equal(pool.size, 1); + assert.equal(argsSeen[0].includes('-viewonly'), true); + lanes.forEach(lane => lane.release()); + await pool.close(); +}); + +test('view and control lanes for one application remain separate', async () => { + let spawns = 0; + const argsSeen = []; + const pool = createApplicationPool({ + maxProcesses: 4, + execFileImpl: execFileWith(['0x0a000001']), + createConnectionImpl: fakeCreateConnection, + portImpl: nextPort, + spawnImpl: (binary, args) => { spawns += 1; argsSeen.push(args); return fakeChild(); }, + }); + const view = await pool.connect('0x0a000001', 'view'); + const control = await pool.connect('0x0a000001', 'control'); + assert.equal(spawns, 2); + assert.equal(pool.size, 2); + assert.equal(argsSeen[0].includes('-viewonly'), true); + assert.equal(argsSeen[1].includes('-viewonly'), false); + assert.deepEqual([...new Set(argsSeen.flat().filter(arg => arg === '-localhost' || arg === '-nopw'))], ['-localhost', '-nopw']); + view.release(); control.release(); + await pool.close(); +}); + +test('capacity eviction synchronously removes an idle lane and allows reuse', async () => { + const pool = createApplicationPool({ + maxProcesses: 1, + execFileImpl: execFileWith(['0x0a000001', '0x0a000002']), + createConnectionImpl: fakeCreateConnection, + portImpl: nextPort, + spawnImpl: fakeChild, + }); + const first = await pool.connect('0x0a000001', 'view'); + first.release(); + const second = await pool.connect('0x0a000002', 'control'); + assert.equal(pool.size, 1); + assert.equal(second.mode, 'control'); + second.release(); + await pool.close(); +}); + +test('capacity fails with 503 when every lane has an active client', async () => { + const pool = createApplicationPool({ + maxProcesses: 1, + execFileImpl: execFileWith(['0x0a000001', '0x0a000002']), + createConnectionImpl: fakeCreateConnection, + portImpl: nextPort, + spawnImpl: fakeChild, + }); + const active = await pool.connect('0x0a000001', 'view'); + await assert.rejects(() => pool.connect('0x0a000002', 'control'), error => error.status === 503 && error.code === 'application_capacity'); + active.release(); + await pool.close(); +}); + +test('capacity counts pending different applications and fails before spawning another listener', async () => { + let rootCalls = 0, spawns = 0, releaseGate; + const gate = new Promise(resolve => { releaseGate = resolve; }); + const execFileImpl = (file, args, options, callback) => { + if (file === 'xprop' && args[0] === '-root') { + rootCalls += 1; + queueMicrotask(() => callback(null, '_NET_CLIENT_LIST(WINDOW): window id # 0x0a000001\n', '')); + } else if (file === 'xprop' && args[0] === '-id') { + gate.then(() => callback(null, '_NET_WM_NAME(UTF8_STRING) = "Terminal"\n', '')); + } else { + queueMicrotask(() => callback(new Error('missing'), '', '')); + } + }; + const pool = createApplicationPool({ + maxProcesses: 1, + execFileImpl, + createConnectionImpl: fakeCreateConnection, + portImpl: nextPort, + spawnImpl: () => { spawns += 1; return fakeChild(); }, + }); + const firstPromise = pool.connect('0x0a000001', 'view'); + await assert.rejects(pool.connect('0x0a000002', 'control'), error => + error.status === 503 && error.code === 'application_capacity'); + assert.equal(spawns, 0); + assert.equal(rootCalls, 1); + assert.equal(pool.pendingSize, 1); + releaseGate(); + const first = await firstPromise; + assert.equal(spawns, 1); + first.release(); + await pool.close(); +}); + +test('capacity does not evict a lane that is still starting', async () => { + let spawns = 0, releaseReadiness; + let capacityCheck; + const readinessGate = new Promise(resolve => { releaseReadiness = resolve; }); + const gatedConnection = () => { + const socket = new EventEmitter(); + socket.setTimeout = () => {}; + socket.destroy = () => {}; + readinessGate.then(() => socket.emit('connect')); + return socket; + }; + const pool = createApplicationPool({ + maxProcesses: 1, + execFileImpl: execFileWith(['0x0a000001', '0x0a000002']), + createConnectionImpl: gatedConnection, + portImpl: nextPort, + spawnImpl: () => { + spawns += 1; + if (spawns === 1) { + queueMicrotask(() => { capacityCheck = assert.rejects(pool.connect('0x0a000002', 'control'), error => error.status === 503 && error.code === 'application_capacity'); }); + } + return fakeChild(); + }, + }); + const firstPromise = pool.connect('0x0a000001', 'view'); + while (!capacityCheck) await new Promise(resolve => setImmediate(resolve)); + await capacityCheck; + releaseReadiness(); + const first = await firstPromise; + assert.equal(spawns, 1); + assert.equal(pool.size, 1); + first.release(); + await pool.close(); +}); + +test('a lane that never becomes ready is killed and removed from the pool', async () => { + const kills = []; + const pool = createApplicationPool({ + maxProcesses: 2, + execFileImpl: execFileWith(['0x0a000001']), + createConnectionImpl: unavailableCreateConnection, + portImpl: nextPort, + spawnImpl: () => { + const child = fakeChild(); + const kill = child.kill.bind(child); + child.kill = signal => { kills.push(signal); return kill(signal); }; + return child; + }, + }); + await assert.rejects(() => pool.connect('0x0a000001', 'view'), /readiness timed out/); + assert.equal(pool.size, 0); + assert.equal(kills.includes('SIGTERM'), true); + await pool.close(); +}); + +test('desktop proxy authenticates HTTP and bridges legacy desktop and scoped application RFB', async () => { + const sockets = new Set(); + async function rfbServer() { + const server = net.createServer(socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + socket.write(Buffer.from('RFB 003.008\n', 'ascii')); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + return { + port: server.address().port, + async close() { + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(resolve)); + }, + }; + } + const desktopRfb = await rfbServer(), applicationRfb = await rfbServer(); + const applicationId = '0x0a000001'; + let connectCount = 0, released = 0; + const applicationPool = { + async list() { + return [{ object: 'desktop.application', application_id: applicationId, name: 'Terminal' }]; + }, + async connect(id, laneMode) { + connectCount += 1; + if (id !== applicationId) throw Object.assign(new Error('not found'), { status: 404, code: 'application_not_found' }); + return { port: applicationRfb.port, mode: laneMode, release() { released += 1; } }; + }, + }; + const proxy = await startDesktopProxy({ + token: 'proxy-test-token', + port: 0, + host: '127.0.0.1', + desktopTarget: desktopRfb.port, + applicationPool, + mode: 'view', + }); + const origin = `http://127.0.0.1:${proxy.port}`; + const wsHeaders = { authorization: 'Bearer proxy-test-token' }; + try { + const unauthenticated = await fetch(origin + '/applications'); + assert.equal(unauthenticated.status, 401); + const applications = await fetch(origin + '/applications', { + headers: { authorization: 'Bearer proxy-test-token' }, + }); + assert.equal(applications.status, 200); + assert.deepEqual((await applications.json()).data, [ + { object: 'desktop.application', application_id: applicationId, name: 'Terminal' }, + ]); + + async function expectUpgradeRejected(url, options = {}) { + const socket = new WebSocket(url, options.authenticate === false ? undefined : { headers: wsHeaders }); + await new Promise((resolve, reject) => { + socket.once('open', () => reject(new Error('unexpected accepted upgrade'))); + socket.once('error', resolve); + }); + socket.terminate(); + } + async function readRfb(url) { + const socket = new WebSocket(url, { headers: wsHeaders }); + const closed = new Promise(resolve => socket.once('close', resolve)); + await new Promise((resolve, reject) => { + socket.once('open', resolve); + socket.once('error', reject); + }); + const message = await new Promise((resolve, reject) => { + socket.once('message', resolve); + socket.once('error', reject); + }); + assert.equal(Buffer.from(message).toString(), 'RFB 003.008\n'); + socket.terminate(); + await closed; + } + + await expectUpgradeRejected(origin.replace('http:', 'ws:') + '/desktop', { authenticate: false }); + await readRfb(origin.replace('http:', 'ws:') + '/desktop'); + await readRfb(origin.replace('http:', 'ws:') + '/application/' + applicationId); + assert.equal(connectCount, 1); + for (let i = 0; released < 1 && i < 50; i++) await new Promise(resolve => setTimeout(resolve, 10)); + assert.equal(released, 1); + await expectUpgradeRejected(origin.replace('http:', 'ws:') + '/application/0x0a000099'); + await expectUpgradeRejected(origin.replace('http:', 'ws:') + '/application/not-a-window'); + } finally { + await proxy.close(); + await desktopRfb.close(); + await applicationRfb.close(); + } +}); + +test('an existing lane is rejected when its X11 window disappears', async () => { + let ids = ['0x0a000001']; + const pool = createApplicationPool({ + maxProcesses: 2, + execFileImpl: (...args) => execFileWith(ids)(...args), + createConnectionImpl: fakeCreateConnection, + portImpl: nextPort, + spawnImpl: fakeChild, + }); + const first = await pool.connect('0x0a000001', 'view'); + first.release(); + ids = ['0x0a000002']; + await assert.rejects(() => pool.connect('0x0a000001', 'view'), error => error.status === 404); + assert.equal(pool.size, 0); + await pool.close(); +}); diff --git a/tests/node-agent-codex-adapter.test.mjs b/tests/node-agent-codex-adapter.test.mjs new file mode 100644 index 0000000..346fe01 --- /dev/null +++ b/tests/node-agent-codex-adapter.test.mjs @@ -0,0 +1,393 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { EventEmitter } from 'node:events'; +import { startNodeAgentApi } from '../tools/node-agent-api/server.mjs'; +import { createCodexAdapter } from '../tools/node-agent-api/codex.mjs'; +import { rpcParams } from '../tools/node-agent-api/runtime/harness.mjs'; + +// Fake harness speaking the real RPC seam: requests are validated by rpcParams, +// the same gate the production harness applies. Scripted replies stand in for +// the app-server child process, so no model ever runs in these tests. +const bots = [ + { object: 'node.agent', id: 'bot-a', name: 'Alpha', description: '', backend: 'codex_harness' }, + { object: 'node.agent', id: 'bot-b', name: 'Beta', description: '', backend: 'codex_harness' }, +]; + +function createFakeHarness() { + const events = new EventEmitter(); + const calls = []; + const answers = []; + let threadSeq = 0; + let turnSeq = 0; + return { + calls, + answers, + redact: value => value, + subscribe(listener) { events.on('event', listener); return () => events.off('event', listener); }, + async request(botId, method, params) { + // Validate through the production seam before recording the call. + const safe = rpcParams(method, params); + calls.push({ botId, method, params: safe }); + if (method === 'thread/start') { threadSeq += 1; return { thread: { id: 'thr_fake_' + threadSeq } }; } + if (method === 'turn/start') { turnSeq += 1; return { turn: { id: 'turn_fake_' + turnSeq, status: 'inProgress' } }; } + if (method === 'thread/read') return { thread: { turns: [] } }; + return {}; + }, + async answer(botId, id, result) { answers.push({ botId, id, result }); return { answered: true }; }, + async pending() { return []; }, + async home() { return join(tmpdir(), 'codex-adapter-fake-home'); }, + async disconnect() {}, + async close() {}, + emit(botId, message) { events.emit('event', { botId, message }); }, + }; +} + +function createFakeRuntime() { + const ensured = []; + const shells = []; + const projects = []; + return { + ensured, + shells, + projects, + async ensure(botId) { ensured.push(botId); return { botId, running: true }; }, + async status(botId) { return { botId, exists: true, running: false }; }, + async descriptor() { return { version: 1, namespace: 'grok-node-lab-codex', environments: [] }; }, + async approvedShell(botId, command) { shells.push({ botId, command }); return { exit_code: 0, stdout: 'fake-ok', stderr: '', timed_out: false }; }, + async project(botId, action) { + projects.push({ botId, action }); + if (action === 'diff') return { diff: '+fake' }; + return { imported: [] }; + }, + }; +} + +const roster = { + agents: async () => bots, + async requireAgent(id) { + const row = bots.find(bot => bot.id === id); + if (!row) throw Object.assign(new Error('missing'), { status: 404, code: 'not_found' }); + return row; + }, +}; + +async function fixture() { + const dir = await realpath(await mkdtemp(join(tmpdir(), 'codex-adapter-'))); + const harness = createFakeHarness(); + const runtime = createFakeRuntime(); + const adapter = createCodexAdapter({ + runtime, + harness, + roster, + namespace: 'grok-node-lab-codex', + stateRoot: join(dir, 'lab'), + registryFile: join(dir, 'registry.json'), + artifactRoot: join(dir, 'artifacts'), + }); + const service = await startNodeAgentApi({ adapter, stateDirectory: join(dir, 'api') }); + const key = (await readFile(service.keyFile, 'utf8')).trim(); + async function call(method, route, data) { + const response = await fetch(service.origin + route, { + method, + headers: { authorization: 'Bearer ' + key, ...(data ? { 'content-type': 'application/json' } : {}) }, + ...(data ? { body: JSON.stringify(data) } : {}), + }); + return { status: response.status, data: await response.json() }; + } + return { service, adapter, harness, runtime, call }; +} + +async function attach(f, botId = 'bot-a') { + const created = await f.call('POST', '/v1/agents/sessions', { agent_id: botId }); + assert.equal(created.status, 201); + return created.data; +} + +function submit(f, sessionId, text, requestId) { + return f.call('POST', '/v1/agents/sessions/' + sessionId + '/events', { + events: [{ type: 'message', text }], + idempotency_key: requestId, + }); +} + +test('attaching a session starts exactly one harness thread with the approved-shell tool', async () => { + const f = await fixture(); + try { + const session = await attach(f); + assert.equal(session.context, 'codex_harness'); + assert.equal(session.task_status, 'idle'); + assert.match(session.thread_id, /^thr_fake_/); + const starts = f.harness.calls.filter(call => call.method === 'thread/start'); + assert.equal(starts.length, 1); + assert.equal(starts[0].botId, 'bot-a'); + assert.equal(starts[0].params.dynamicTools[0].name, 'node_approved_shell'); + assert.deepEqual(f.runtime.ensured, ['bot-a']); + } finally { + await f.service.close(); + } +}); + +test('submitting text starts one turn and records its idempotency key on the running turn', async () => { + const f = await fixture(); + try { + const session = await attach(f); + const accepted = await submit(f, session.id, 'probe the seam', 'nreq_adapter_input'); + assert.equal(accepted.status, 202); + assert.equal(accepted.data.status, 'accepted'); + assert.equal(accepted.data.completed, false); + assert.match(accepted.data.turn_id, /^turn_fake_/); + const starts = f.harness.calls.filter(call => call.method === 'turn/start'); + assert.equal(starts.length, 1); + assert.equal(starts[0].params.threadId, session.thread_id); + assert.equal(starts[0].params.input[0].text, 'probe the seam'); + const turns = await f.call('GET', '/v1/agents/sessions/' + session.id + '/turns'); + assert.equal(turns.data.data.length, 1); + assert.equal(turns.data.data[0].status, 'running'); + assert.equal(turns.data.data[0].request_id, 'nreq_adapter_input'); + } finally { + await f.service.close(); + } +}); + +test('replaying the same input key does not start a second harness turn', async () => { + const f = await fixture(); + try { + const session = await attach(f); + const payload = { events: [{ type: 'message', text: 'only once' }], idempotency_key: 'nreq_adapter_dedupe' }; + const url = '/v1/agents/sessions/' + session.id + '/events'; + const replies = await Promise.all(Array.from({ length: 4 }, () => f.call('POST', url, payload))); + assert.ok(replies.every(reply => reply.status === 202)); + assert.equal(replies.filter(reply => reply.data.replayed === true).length, 3); + assert.equal(f.harness.calls.filter(call => call.method === 'turn/start').length, 1); + const conflict = await f.call('POST', url, { ...payload, events: [{ type: 'message', text: 'different' }] }); + assert.equal(conflict.status, 409); + assert.equal(conflict.data.error.type, 'idempotency_conflict'); + } finally { + await f.service.close(); + } +}); + +test('turn completion stores native token usage and lets the next turn start', async () => { + const f = await fixture(); + try { + const session = await attach(f); + const first = await submit(f, session.id, 'first job', 'nreq_adapter_first'); + const turnId = first.data.turn_id; + f.harness.emit('bot-a', { method: 'thread/tokenUsage/updated', params: { threadId: session.thread_id, turnId, tokenUsage: { inputTokens: 3, outputTokens: 7 } } }); + f.harness.emit('bot-a', { method: 'turn/completed', params: { threadId: session.thread_id, turn: { id: turnId, status: 'completed' } } }); + await f.adapter.flush(); + const turns = await f.call('GET', '/v1/agents/sessions/' + session.id + '/turns'); + const turn = turns.data.data.find(row => row.id === turnId); + assert.equal(turn.status, 'completed'); + assert.ok(Number.isFinite(turn.ended_at)); + assert.equal(turn.usage.source, 'thread/tokenUsage/updated'); + assert.equal(turn.usage.native.outputTokens, 7); + const second = await submit(f, session.id, 'second job', 'nreq_adapter_second'); + assert.equal(second.status, 202); + assert.notEqual(second.data.turn_id, turnId); + } finally { + await f.service.close(); + } +}); + +test('cancel interrupts the running turn and is refused once the bot is idle', async () => { + const f = await fixture(); + try { + const session = await attach(f); + const started = await submit(f, session.id, 'long job', 'nreq_adapter_cancel'); + const turnId = started.data.turn_id; + const url = '/v1/agents/sessions/' + session.id + '/events'; + const cancelled = await f.call('POST', url, { events: [{ type: 'agent.session.input.cancel', turn_id: turnId }] }); + assert.equal(cancelled.status, 202); + const interrupts = f.harness.calls.filter(call => call.method === 'turn/interrupt'); + assert.equal(interrupts.length, 1); + assert.equal(interrupts[0].params.turnId, turnId); + f.harness.emit('bot-a', { method: 'turn/completed', params: { threadId: session.thread_id, turn: { id: turnId, status: 'interrupted' } } }); + await f.adapter.flush(); + const turns = await f.call('GET', '/v1/agents/sessions/' + session.id + '/turns'); + assert.equal(turns.data.data[0].status, 'cancelled'); + const idle = await f.call('POST', url, { events: [{ type: 'agent.session.input.cancel', turn_id: turnId }] }); + assert.equal(idle.status, 409); + assert.equal(idle.data.error.type, 'turn_not_active'); + } finally { + await f.service.close(); + } +}); + +test('a second turn is refused while the bot is still working', async () => { + const f = await fixture(); + try { + const session = await attach(f); + await submit(f, session.id, 'first', 'nreq_adapter_busy_1'); + const second = await submit(f, session.id, 'second', 'nreq_adapter_busy_2'); + assert.equal(second.status, 409); + assert.equal(second.data.error.type, 'turn_active'); + assert.equal(f.harness.calls.filter(call => call.method === 'turn/start').length, 1); + } finally { + await f.service.close(); + } +}); + +test('harness disconnect marks the running turn unknown and appends a no-replay event', async () => { + const f = await fixture(); + try { + const session = await attach(f); + await submit(f, session.id, 'doomed job', 'nreq_adapter_doomed'); + f.harness.emit('bot-a', { method: 'lab/status', params: { status: 'unknown', replayed: false } }); + await f.adapter.flush(); + const current = await f.call('GET', '/v1/agents/sessions/' + session.id); + assert.equal(current.data.task_status, 'unknown'); + assert.equal(current.data.active_turn_id, null); + const events = await f.call('GET', '/v1/agents/sessions/' + session.id + '/events'); + const marker = events.data.data.find(event => event.type === 'node.session.turn.unknown'); + assert.ok(marker); + assert.equal(marker.data.reason, 'harness_disconnected'); + assert.equal(marker.data.replayed, false); + } finally { + await f.service.close(); + } +}); + +test('cancellation terminates only processes recorded on the active turn in its own thread', async () => { + const f = await fixture(); + try { + const session = await attach(f); + const old = await submit(f, session.id, 'earlier job', 'nreq_old_process'); + f.harness.emit('bot-a', { method: 'item/started', params: { threadId: session.thread_id, turnId: old.data.turn_id, item: { type: 'commandExecution', id: 'old_process', processId: '100', status: 'inProgress' } } }); + f.harness.emit('bot-a', { method: 'turn/completed', params: { threadId: session.thread_id, turn: { id: old.data.turn_id, status: 'completed' } } }); + await f.adapter.flush(); + const active = await submit(f, session.id, 'current job', 'nreq_current_process'); + f.harness.emit('bot-a', { method: 'item/started', params: { threadId: session.thread_id, turnId: active.data.turn_id, item: { type: 'commandExecution', id: 'current_process', processId: '200', status: 'inProgress' } } }); + await f.adapter.flush(); + assert.equal((await f.call('POST', '/v1/agents/sessions/' + session.id + '/events', { events: [{ type: 'agent.session.input.cancel', turn_id: active.data.turn_id }] })).status, 202); + const terminations = f.harness.calls.filter(call => call.method === 'thread/backgroundTerminals/terminate'); + assert.deepEqual(terminations.map(call => ({ bot: call.botId, ...call.params })), [{ bot: 'bot-a', threadId: session.thread_id, processId: '200' }]); + } finally { await f.service.close(); } +}); + +test('accepting a shell approval runs the command once and answers the harness', async () => { + const f = await fixture(); + try { + const session = await attach(f); + const started = await submit(f, session.id, 'needs approval', 'nreq_adapter_approve'); + f.harness.emit('bot-a', { + method: 'item/tool/call', + id: 'rpc_approve_1', + params: { threadId: session.thread_id, turnId: started.data.turn_id, itemId: 'item_1', tool: 'node_approved_shell', arguments: { command: 'echo approved' } }, + }); + await f.adapter.flush(); + const pending = await f.call('GET', '/v1/agents/sessions/' + session.id + '/actions'); + assert.equal(pending.data.data.length, 1); + assert.equal(pending.data.data[0].type, 'item/tool/call'); + const answered = await f.call('POST', '/v1/agents/sessions/' + session.id + '/actions/' + pending.data.data[0].id, { decision: 'accept' }); + assert.equal(answered.status, 200); + assert.deepEqual(f.runtime.shells.map(shell => shell.command), ['echo approved']); + assert.equal(f.harness.answers.length, 1); + assert.equal(f.harness.answers[0].id, 'rpc_approve_1'); + assert.equal(f.harness.answers[0].result.success, true); + } finally { + await f.service.close(); + } +}); + +test('declining a shell approval answers the harness without executing anything', async () => { + const f = await fixture(); + try { + const session = await attach(f); + const started = await submit(f, session.id, 'needs approval', 'nreq_adapter_decline'); + f.harness.emit('bot-a', { + method: 'item/tool/call', + id: 'rpc_approve_2', + params: { threadId: session.thread_id, turnId: started.data.turn_id, itemId: 'item_2', tool: 'node_approved_shell', arguments: { command: 'echo never-runs' } }, + }); + await f.adapter.flush(); + const pending = await f.call('GET', '/v1/agents/sessions/' + session.id + '/actions'); + const answered = await f.call('POST', '/v1/agents/sessions/' + session.id + '/actions/' + pending.data.data[0].id, { decision: 'decline' }); + assert.equal(answered.status, 200); + assert.equal(f.runtime.shells.length, 0); + assert.equal(f.harness.answers.length, 1); + assert.equal(f.harness.answers[0].result.success, false); + assert.match(f.harness.answers[0].result.contentItems[0].text, /"executed":false/); + } finally { + await f.service.close(); + } +}); + +test('approving a tool the adapter does not implement is refused without side effects', async () => { + const f = await fixture(); + try { + const session = await attach(f); + const started = await submit(f, session.id, 'risky tool', 'nreq_adapter_other_tool'); + f.harness.emit('bot-a', { + method: 'item/tool/call', + id: 'rpc_other_1', + params: { threadId: session.thread_id, turnId: started.data.turn_id, itemId: 'item_9', tool: 'other_tool', arguments: {} }, + }); + await f.adapter.flush(); + const pending = await f.call('GET', '/v1/agents/sessions/' + session.id + '/actions'); + assert.equal(pending.data.data.length, 1); + const rejected = await f.call('POST', '/v1/agents/sessions/' + session.id + '/actions/' + pending.data.data[0].id, { decision: 'accept' }); + assert.equal(rejected.status, 409); + assert.equal(rejected.data.error.type, 'tool_unavailable'); + assert.equal(f.harness.answers.length, 0); + assert.equal(f.runtime.shells.length, 0); + } finally { + await f.service.close(); + } +}); + +test('project writes are refused while a turn runs and reads stay available after it', async () => { + const f = await fixture(); + try { + const session = await attach(f); + const started = await submit(f, session.id, 'busy job', 'nreq_adapter_project'); + const busy = await f.call('POST', '/v1/agents/sessions/' + session.id + '/project/import', { files: [] }); + assert.equal(busy.status, 409); + // The HTTP route rejects a busy session before the adapter is reached; + // the adapter's own guard raises turn_active for direct adapter callers. + assert.equal(busy.data.error.type, 'session_busy'); + assert.equal(f.runtime.projects.length, 0); + f.harness.emit('bot-a', { method: 'turn/completed', params: { threadId: session.thread_id, turn: { id: started.data.turn_id, status: 'completed' } } }); + await f.adapter.flush(); + const diff = await f.call('GET', '/v1/agents/sessions/' + session.id + '/project/diff'); + assert.equal(diff.status, 200); + assert.equal(diff.data.diff, '+fake'); + } finally { + await f.service.close(); + } +}); + +test('resume keeps the live harness thread instead of starting another', async () => { + const f = await fixture(); + try { + const session = await attach(f); + const resumed = await f.call('POST', '/v1/agents/sessions/' + session.id + '/resume'); + assert.equal(resumed.status, 200); + assert.equal(resumed.data.thread_id, session.thread_id); + assert.equal(f.harness.calls.filter(call => call.method === 'thread/start').length, 1); + assert.equal(f.harness.calls.filter(call => call.method === 'thread/resume').length, 0); + } finally { + await f.service.close(); + } +}); + +test('handoff unsubscribes the thread and reports it without replay', async () => { + const f = await fixture(); + try { + const session = await attach(f); + const handoff = await f.call('POST', '/v1/agents/sessions/' + session.id + '/handoff'); + assert.equal(handoff.status, 200); + assert.equal(handoff.data.thread_id, session.thread_id); + assert.equal(handoff.data.bot_id, 'bot-a'); + assert.equal(handoff.data.target, 'codex_cli'); + assert.equal(handoff.data.replayed, false); + const unsubscribes = f.harness.calls.filter(call => call.method === 'thread/unsubscribe'); + assert.equal(unsubscribes.length, 1); + assert.equal(unsubscribes[0].params.threadId, session.thread_id); + } finally { + await f.service.close(); + } +}); diff --git a/tests/node-agent-governance.test.mjs b/tests/node-agent-governance.test.mjs new file mode 100644 index 0000000..9eb6606 --- /dev/null +++ b/tests/node-agent-governance.test.mjs @@ -0,0 +1,145 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, readFile, stat, writeFile, symlink, unlink, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { createGovernance, sanitizeAuditRecord } from '../tools/node-agent-api/governance.mjs'; + +const alice = { id: 'nkey_alice', digest: 'CREDENTIAL_SENTINEL', authorization: 'Bearer CREDENTIAL_SENTINEL' }; +const bob = { id: 'nkey_bob' }; +const moduleUrl = new URL('../tools/node-agent-api/governance.mjs', import.meta.url).href; +async function directory(name) { + const base = process.env.NODE_AGENT_P2_EVIDENCE_DIR ?? tmpdir(); + await mkdir(base, { recursive: true }); + return realpath(await mkdtemp(join(base, 'governance-' + name + '-'))); +} +const code = (value, status) => error => error.code === value && error.status === status; + +test('governance: concurrent request admissions are principal scoped and fixed-window bounded', async t => { + let now = 1000; + const dir = await directory('rate'), g = await createGovernance(dir, { clock: () => now, limits: { requests: 3, windowMs: 1000 }, principals: { nkey_bob: { requests: 1 } } }); + t.after(() => g.close()); + const attempts = await Promise.allSettled(Array.from({ length: 20 }, () => g.consumeRequest(alice))); + assert.equal(attempts.filter(r => r.status === 'fulfilled').length, 3); + for (const r of attempts.filter(r => r.status === 'rejected')) { assert.equal(r.reason.code, 'request_rate_limit'); assert.equal(r.reason.status, 429); assert.equal(r.reason.retryAfterMs, 1000); } + await g.consumeRequest(bob); + await assert.rejects(g.consumeRequest(bob), code('request_rate_limit', 429)); + now = 1999; + await assert.rejects(g.consumeRequest(alice), e => e.retryAfterMs === 1); + now = 2000; + assert.equal((await g.consumeRequest(alice)).remaining, 2); + assert.equal((await g.usage(bob)).requests, 0); +}); + +test('governance: concurrent stream leases cap admissions and release exactly once', async t => { + const g = await createGovernance(await directory('streams'), { limits: { streams: 2 } }); + t.after(() => g.close()); + const attempts = await Promise.allSettled(Array.from({ length: 12 }, () => g.acquireStream(alice))); + const releases = attempts.filter(r => r.status === 'fulfilled').map(r => r.value); + assert.equal(releases.length, 2); + assert.equal(attempts.filter(r => r.status === 'rejected' && r.reason.code === 'stream_limit').length, 10); + const bobRelease = await g.acquireStream(bob); + assert.equal((await g.usage(alice)).streams, 2); + releases[0](); releases[0](); + const release = await g.acquireStream(alice); + await assert.rejects(g.acquireStream(alice), code('stream_limit', 429)); + try { throw new Error('consumer failed'); } catch {} finally { release(); releases[1](); bobRelease(); } + assert.equal((await g.usage(alice)).streams, 0); +}); + +test('governance: storage reservations are atomic, idempotent, isolated and reject invalid bytes', async t => { + const g = await createGovernance(await directory('storage'), { limits: { storageBytes: 10 } }); + t.after(() => g.close()); + const writes = await Promise.allSettled(Array.from({ length: 10 }, (_, i) => g.reserveStorage(alice, 'resource_' + i, 3))); + assert.equal(writes.filter(r => r.status === 'fulfilled').length, 3); + assert.equal(writes.filter(r => r.status === 'rejected' && r.reason.code === 'storage_quota').length, 7); + assert.equal((await g.usage(alice)).storageBytes, 9); + await g.reserveStorage(alice, 'resource_0', 3); + await g.reserveStorage(bob, 'resource_0', 10); + await assert.rejects(g.reserveStorage(alice, 'resource_0', 5), code('storage_quota', 413)); + for (const value of [-1, NaN, Infinity, 1.5, '2', Number.MAX_SAFE_INTEGER + 1]) assert.throws(() => g.reserveStorage(alice, 'invalid', value), code('invalid_request', 400)); + await g.reserveStorage(alice, 'resource_0', 0); + assert.equal((await g.reserveStorage(alice, 'new', 4)).storageBytes, 10); + assert.equal((await g.usage(bob)).storageBytes, 10); +}); + +test('governance: audit journal is private, bounded and never copies credentials or prompt bodies', async t => { + const dir = await directory('audit'), g = await createGovernance(dir, { journalLimit: 2 }); + t.after(() => g.close()); + for (let i = 0; i < 3; i++) await g.recordAudit({ principal: alice, action: 'authorization', outcome: 'denied', status: 403, resourceId: 'bot_1', requestId: 'req_' + i, headers: { authorization: 'CREDENTIAL_SENTINEL' }, prompt: 'PROMPT_SENTINEL', error: new Error('CREDENTIAL_SENTINEL'), body: { prompt: 'PROMPT_SENTINEL' } }); + const raw = await readFile(g.journalFile, 'utf8'), saved = JSON.parse(raw); + assert.equal(saved.journal.length, 2); + assert.ok(!/CREDENTIAL_SENTINEL|PROMPT_SENTINEL|nkey_alice|bot_1|req_/.test(raw)); + assert.match(saved.journal[0].principal, /^[a-f0-9]{64}$/); + assert.equal((await stat(g.journalFile)).mode & 0o777, 0o600); + assert.equal((await stat(join(dir, 'governance'))).mode & 0o777, 0o700); + assert.throws(() => sanitizeAuditRecord({ principal: alice, action: 'PROMPT_SENTINEL', outcome: 'failed' }), code('invalid_request', 400)); + assert.throws(() => sanitizeAuditRecord({ principal: alice, action: 'request', outcome: 'failed', status: 'SECRET' }), code('invalid_request', 400)); +}); + +test('governance: lower limits after restart allow storage reclamation and zero quotas deny admissions', async t => { + const dir = await directory('lower-limits'); + let g = await createGovernance(dir, { limits: { storageBytes: 10 } }); + await g.reserveStorage(alice, 'file', 10); await g.close(); + g = await createGovernance(dir, { limits: { storageBytes: 2, requests: 0, streams: 0 }, maxPrincipals: 1 }); + t.after(() => g.close()); + assert.deepEqual(await g.reserveStorage(alice, 'file', 8), { storageBytes: 8, remaining: 0 }); + await assert.rejects(g.reserveStorage(alice, 'file', 9), code('storage_quota', 413)); + await g.reserveStorage(alice, 'file', 0); + assert.equal((await g.reserveStorage(alice, 'file', 2)).remaining, 0); + await assert.rejects(g.consumeRequest(alice), code('request_rate_limit', 429)); + await assert.rejects(g.acquireStream(alice), code('stream_limit', 429)); + await assert.rejects(g.reserveStorage(bob, 'file', 1), code('principal_capacity', 503)); +}); + +test('governance: fresh process after SIGKILL preserves quotas and audit but releases dead streams', async t => { + const dir = await directory('restart'); + const child = spawn(process.execPath, ['--input-type=module', '-e', ` + import { createGovernance } from ${JSON.stringify(moduleUrl)}; + const g = await createGovernance(${JSON.stringify(dir)}, { clock: () => 1000, limits: { requests: 1, streams: 1, storageBytes: 8 } }); + await g.consumeRequest({id:'nkey_alice'}); + await g.reserveStorage({id:'nkey_alice'}, 'retained', 8); + await g.acquireStream({id:'nkey_alice'}); + process.stdout.write('durable\\n'); setInterval(() => {}, 1000); + `], { stdio: ['ignore', 'pipe', 'pipe'] }); + t.after(() => { if (child.exitCode === null) child.kill('SIGKILL'); }); + let errors = ''; child.stderr.on('data', bytes => { errors += bytes; }); + const ready = await Promise.race([once(child.stdout, 'data').then(([bytes]) => bytes.toString()), once(child, 'exit').then(() => { throw new Error(errors); })]); + assert.equal(ready, 'durable\n'); + const exit = once(child, 'exit'); child.kill('SIGKILL'); assert.equal((await exit)[1], 'SIGKILL'); + await assert.rejects(createGovernance(dir), /locked/); + // Deliberate stale-lock recovery only after observing the writer process exit. + await unlink(join(dir, 'governance', 'service.lock')); + const g = await createGovernance(dir, { clock: () => 1000, limits: { requests: 1, streams: 1, storageBytes: 8 } }); + t.after(() => g.close()); + await assert.rejects(g.consumeRequest(alice), code('request_rate_limit', 429)); + await assert.rejects(g.reserveStorage(alice, 'overflow', 1), code('storage_quota', 413)); + const release = await g.acquireStream(alice); release(); + assert.equal((await g.usage(alice)).storageBytes, 8); + assert.equal(JSON.parse(await readFile(g.journalFile, 'utf8')).journal.filter(r => r.action === 'request' && r.outcome === 'allowed').length, 1); + await writeFile(join(dir, 'restart-observation.json'), JSON.stringify({ childSignal: 'SIGKILL', requestDenied: true, storageDenied: true, streamReacquired: true })); +}); + +test('governance: corrupt state, symlinks, second writers, closed operations and persistence faults fail closed', async () => { + const dir = await directory('negative'), g = await createGovernance(dir); + await assert.rejects(createGovernance(dir), /locked/); + await g.consumeRequest(alice); + await g.close(); await g.close(); + await assert.rejects(g.acquireStream(alice), code('governance_closed', 503)); + await writeFile(g.journalFile, '{bad json'); + await assert.rejects(createGovernance(dir), SyntaxError); + await unlink(g.journalFile); + const target = join(dir, 'target.json'); await writeFile(target, '{}'); + await symlink(target, g.journalFile); + await assert.rejects(createGovernance(dir)); + await unlink(g.journalFile); + const next = await createGovernance(dir); + await symlink(target, next.journalFile); + await assert.rejects(next.acquireStream(alice), code('governance_persistence_failed', 503)); + await assert.rejects(next.consumeRequest(alice), code('governance_persistence_failed', 503)); + assert.equal(await readFile(target, 'utf8'), '{}'); + await next.close(); + for (const limits of [{ requests: -1 }, { windowMs: 0 }, { streams: Infinity }, { storageBytes: 1.1 }]) await assert.rejects(createGovernance(dir, { limits }), code('invalid_request', 400)); +}); diff --git a/tests/node-agent-http-service.test.mjs b/tests/node-agent-http-service.test.mjs new file mode 100644 index 0000000..a53995c --- /dev/null +++ b/tests/node-agent-http-service.test.mjs @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createServer } from 'node:http'; +import { startNodeAgentApi } from '../tools/node-agent-api/server.mjs'; +import { verifyWebhookSignature } from '../tools/node-agent-api/webhooks.mjs'; + +const bots = [{ object: 'node.agent', id: 'a', name: 'A', description: '', backend: 'grok_node' }, { object: 'node.agent', id: 'b', name: 'B', description: '', backend: 'grok_node' }]; +const adapter = { writesEnabled: false, agents: async () => bots, requireAgent: async id => { const row = bots.find(b => b.id === id); if (!row) throw Error('missing'); return row; }, items: async () => [] }; +async function fixture(limits = {}) { + const dir = await realpath(await mkdtemp(path.join(tmpdir(), 'node-http-'))); + const service = await startNodeAgentApi({ adapter, stateDirectory: dir, governanceOptions: { limits: { requests: 1000, ...limits } } }); + const key = (await readFile(service.keyFile, 'utf8')).trim(); + async function call(method, route, data, credential = key) { const r = await fetch(service.origin + route, { method, headers: { authorization: 'Bearer ' + credential, ...(data ? { 'content-type': 'application/json' } : {}) }, ...(data ? { body: JSON.stringify(data) } : {}) }); return { status: r.status, data: await r.json() }; } + return { dir, service, key, call }; +} + +test('HTTP users, bot grants and rotation enforce boundaries across real requests', async () => { + const f = await fixture(); + try { + const alice = (await f.call('POST', '/v1/users', { name: 'Alice' })).data; + const bob = (await f.call('POST', '/v1/users', { name: 'Bob' })).data; + const grants = ['agents.read', 'sessions.read', 'sessions.write']; + const a = (await f.call('POST', '/v1/keys', { user_id: alice.id, scopes: grants, bot_ids: ['a'] })).data; + const b = (await f.call('POST', '/v1/keys', { user_id: bob.id, scopes: grants, bot_ids: ['b'] })).data; + assert.deepEqual((await f.call('GET', '/v1/agents', undefined, a.key)).data.data.map(row => row.id), ['a']); + assert.equal((await f.call('GET', '/v1/agents/b', undefined, a.key)).status, 403); + const sb = (await f.call('POST', '/v1/agents/sessions', { agent_id: 'b' }, b.key)).data; + assert.equal((await f.call('GET', '/v1/agents/sessions/' + sb.id, undefined, a.key)).status, 403); + const replacement = (await f.call('POST', '/v1/keys/' + a.id + '/rotate', {})).data; + assert.equal((await f.call('GET', '/v1/agents', undefined, a.key)).status, 401); + assert.equal((await f.call('GET', '/v1/agents', undefined, replacement.key)).status, 200); + await f.call('POST', '/v1/users/' + bob.id + '/disable', {}); + assert.equal((await f.call('GET', '/v1/agents', undefined, b.key)).status, 401); + assert.equal((await f.call('POST', '/v1/users', { name: 'Forbidden' }, replacement.key)).status, 403); + } finally { await f.service.close(); } +}); + +test('HTTP concurrent requests enforce rate, stream and physical state quotas', async () => { + const f = await fixture({ requests: 5, streams: 1, storageBytes: 4096 }); + try { + const replies = await Promise.all(Array.from({ length: 12 }, () => f.call('GET', '/v1/agents'))); + assert.equal(replies.filter(r => r.status === 200).length, 5); + assert.equal(replies.filter(r => r.status === 429).length, 7); + } finally { await f.service.close(); } + const g = await fixture({ streams: 1, storageBytes: 4096 }); + try { + const session = (await g.call('POST', '/v1/agents/sessions', { agent_id: 'a' })).data; + const url = g.service.origin + '/v1/agents/sessions/' + session.id + '/events'; + const first = await fetch(url, { headers: { authorization: 'Bearer ' + g.key, accept: 'text/event-stream' } }); + const second = await fetch(url, { headers: { authorization: 'Bearer ' + g.key, accept: 'text/event-stream' } }); + assert.equal(second.status, 429); await first.body.cancel(); + const large = await g.call('PATCH', '/v1/agents/sessions/' + session.id, { metadata: Object.fromEntries(Array.from({ length: 16 }, (_, i) => ['k' + i, 'x'.repeat(500)])) }); + assert.equal(large.status, 413); + assert.deepEqual((await g.call('GET', '/v1/agents/sessions/' + session.id)).data.metadata, {}, 'rejected data must not alter the live session'); + assert.equal((await g.call('PATCH', '/v1/agents/sessions/' + session.id, { metadata: { accepted: 'small' } })).status, 200, 'quota rejection must not poison other writes'); + } finally { await g.service.close().catch(error => { assert.equal(error.code, 'storage_quota'); }); } +}); + +test('HTTP webhook configuration signs a persisted event and delivery status is verifiable', async () => { + const f = await fixture(); let signingKey, received; + const receiver = createServer(async (req, res) => { + const parts = []; for await (const part of req) parts.push(part); + const body = Buffer.concat(parts).toString(); + // HTTP header values arrive as strings; verifyWebhookSignature takes a parsed + // epoch-seconds value, so convert before verifying (same as the webhook suite). + assert.equal(verifyWebhookSignature({ keys: [signingKey], keyId: req.headers['x-node-webhook-key-id'], timestamp: Number(req.headers['x-node-webhook-timestamp']), deliveryId: req.headers['x-node-webhook-id'], body, signature: req.headers['x-node-webhook-signature'] }), true); + received = JSON.parse(body); res.writeHead(204); res.end(); + }); + await new Promise(resolve => receiver.listen(0, '127.0.0.1', resolve)); + try { + const configured = await f.call('POST', '/v1/webhooks', { destination: 'http://127.0.0.1:' + receiver.address().port + '/hook' }); + assert.equal(configured.status, 201); signingKey = configured.data.signingKey; + const session = await f.call('POST', '/v1/agents/sessions', { agent_id: 'a' }); assert.equal(session.status, 201); + const deadline = Date.now() + 5000; + while (!received && Date.now() < deadline) { await f.call('POST', '/v1/webhooks/dispatch', {}); if (!received) await new Promise(resolve => setTimeout(resolve, 20)); } + assert.ok(received); assert.equal(received.event.type, 'node.session.created'); + const status = await f.call('GET', '/v1/webhooks/' + received.delivery_id); assert.equal(status.data.status, 'delivered'); + assert.equal((await f.call('POST', '/v1/webhooks', { destination: 'http://169.254.169.254/latest' })).status, 400); + } finally { await f.service.close(); await new Promise(resolve => receiver.close(resolve)); } +}); diff --git a/tests/node-agent-users.test.mjs b/tests/node-agent-users.test.mjs new file mode 100644 index 0000000..b894fde --- /dev/null +++ b/tests/node-agent-users.test.mjs @@ -0,0 +1,225 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, mkdir, readFile, writeFile, stat, symlink, unlink, chmod } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { createAuth, SCOPES } from '../tools/node-agent-api/auth.mjs'; + +const execute = promisify(execFile); +const moduleUrl = new URL('../tools/node-agent-api/auth.mjs', import.meta.url).href; +const denied = error => error.code === 'permission_denied' && error.status === 403; +const invalid = error => error.code === 'invalid_request' && error.status === 400; +const unauthorized = error => error.code === 'unauthorized' && error.status === 401; +const principal = (auth, issued) => auth.authenticate('Bearer ' + issued.key); +async function fixture(name, clock) { + const base = process.env.NODE_AGENT_USERS_EVIDENCE_DIR ?? tmpdir(); + await mkdir(base, { recursive: true }); + const directory = await realpath(await mkdtemp(join(base, 'users-' + name + '-'))); + const auth = await createAuth(directory, clock); + const owner = auth.authenticate('Bearer ' + (await readFile(auth.keyFile, 'utf8')).trim()); + return { directory, auth, owner }; +} +const grant = (user_id, bot = 'bot-a') => ({ user_id, scopes: ['keys.manage', 'agents.read', 'sessions.read'], bot_ids: [bot], ttl_seconds: 10 }); + +test('users: private persistent identities give two users distinct bot scopes and owner-only management', async () => { + const { directory, auth, owner } = await fixture('isolation'); + const alice = await auth.createUser(owner, { name: 'Alice' }); + const bob = await auth.createUser(owner, { name: 'Bob' }); + assert.notEqual(alice.id, bob.id); + const a = await auth.issue(owner, grant(alice.id)); + const b = await auth.issue(owner, grant(bob.id, 'bot-b')); + const pa = principal(auth, a), pb = principal(auth, b); + assert.equal(a.user_id, alice.id); assert.equal(pa.user_id, alice.id); + assert.equal(b.user_id, bob.id); assert.equal(pb.user_id, bob.id); + auth.requireScope(pa, 'agents.read', 'bot-a'); auth.requireScope(pb, 'agents.read', 'bot-b'); + assert.throws(() => auth.requireScope(pa, 'agents.read', 'bot-b'), denied); + assert.throws(() => auth.requireScope(pb, 'agents.read', 'bot-a'), denied); + await assert.rejects(auth.createUser(pa, { name: 'Intruder' }), denied); + assert.throws(() => auth.listUsers(pa), denied); + await assert.rejects(auth.disableUser(pa, bob.id), denied); + const listed = auth.listUsers(owner); + assert.deepEqual(listed.map(u => u.id), ['owner', alice.id, bob.id]); + listed[1].name = 'mutated'; assert.equal(auth.listUsers(owner)[1].name, 'Alice'); + const raw = await readFile(auth.usersFile, 'utf8'); + assert.equal(JSON.parse(raw).length, 2); + for (const token of [a.key, b.key, owner.digest]) assert.equal(raw.includes(token), false); + assert.equal((await stat(auth.usersFile)).mode & 0o777, 0o600); + assert.equal((await stat(directory)).mode & 0o777, 0o700); +}); + +test('users: user switching, forged principal fields and scope expansion cannot escalate delegation', async () => { + const { auth, owner } = await fixture('switching'); + const alice = await auth.createUser(owner, { name: 'Alice' }), bob = await auth.createUser(owner, { name: 'Bob' }); + const parent = await auth.issue(owner, grant(alice.id)); + const pa = principal(auth, parent); + for (const user_id of [bob.id, 'owner']) await assert.rejects(auth.issue(pa, grant(user_id)), denied); + const child = await auth.issue(pa, { scopes: ['agents.read'], bot_ids: ['bot-a'] }); + assert.equal(child.user_id, alice.id); + const explicit = await auth.issue(pa, { ...grant(alice.id), scopes: ['agents.read'] }); + assert.equal(explicit.user_id, alice.id); + pa.user_id = bob.id; pa.scopes.push('agents.write'); pa.botIds.push('bot-b'); + assert.equal((await auth.issue(pa, { scopes: ['agents.read'], bot_ids: ['bot-a'] })).user_id, alice.id); + await assert.rejects(auth.issue(pa, { scopes: ['agents.write'], bot_ids: ['bot-a'] }), invalid); + await assert.rejects(auth.issue(pa, { scopes: ['agents.read'], bot_ids: ['bot-b'] }), denied); + assert.throws(() => auth.requireScope(pa, 'agents.write', 'bot-b'), denied); + await assert.rejects(auth.createUser({ ...pa, id: 'owner' }, { name: 'Forged' }), denied); + await assert.rejects(auth.issue(owner, grant('missing_user')), invalid); +}); + +test('users: disabling a user invalidates existing descendants and stale principals without affecting peers', async () => { + const { auth, owner, directory } = await fixture('disabled'); + const alice = await auth.createUser(owner, { name: 'Alice' }), bob = await auth.createUser(owner, { name: 'Bob' }); + const parent = await auth.issue(owner, grant(alice.id)), peer = await auth.issue(owner, grant(bob.id, 'bot-b')); + const pa = principal(auth, parent), child = await auth.issue(pa, grant(alice.id)); + const pc = principal(auth, child), grandchild = await auth.issue(pc, { scopes: ['agents.read'], bot_ids: ['bot-a'] }); + const disabled = await auth.disableUser(owner, alice.id); + assert.equal(disabled.disabled, true); + assert.deepEqual(await auth.disableUser(owner, alice.id), disabled); + for (const key of [parent, child, grandchild]) assert.throws(() => principal(auth, key), unauthorized); + assert.equal(auth.active(pa), false); assert.equal(auth.active(pc), false); + assert.throws(() => auth.requireScope(pa, 'agents.read', 'bot-a'), denied); + await assert.rejects(auth.issue(pa, grant(alice.id)), denied); + await assert.rejects(auth.issue(owner, grant(alice.id)), invalid); + await assert.rejects(auth.rotateKey(owner, parent.id), e => e.code === 'key_inactive'); + assert.equal(principal(auth, peer).user_id, bob.id); + const reopened = await createAuth(directory); + assert.throws(() => principal(reopened, parent), unauthorized); + assert.equal(principal(reopened, peer).user_id, bob.id); +}); + +test('users: owner defaults and missing user_id records migrate without changing SCOPES or privileges', async () => { + let now = 1000000; + const { auth, owner, directory } = await fixture('migration', () => now); + assert.deepEqual(SCOPES, ['agents.read', 'agents.write', 'sessions.read', 'sessions.write', 'desktop.view', 'desktop.control', 'keys.manage']); + const parent = await auth.issue(owner, { scopes: ['keys.manage', 'agents.read'], bot_ids: ['bot-a'] }); + assert.equal(parent.user_id, 'owner'); assert.equal(parent.expires_at, 4600); + const file = join(directory, 'keys.json'), saved = JSON.parse(await readFile(file, 'utf8')); + for (const key of saved) { delete key.user_id; delete key.parentId; } + await writeFile(file, JSON.stringify(saved)); await unlink(auth.usersFile); + const reopened = await createAuth(directory, () => now), legacy = principal(reopened, parent); + assert.equal(legacy.user_id, 'owner'); assert.equal(legacy.id, parent.id); + const child = await reopened.issue(legacy, { scopes: ['agents.read'], bot_ids: ['bot-a'], ttl_seconds: 86400 }); + assert.equal(child.user_id, 'owner'); assert.equal(child.expires_at, parent.expires_at); + await assert.rejects(reopened.createUser(legacy, { name: 'NotOwner' }), denied); + const next = await reopened.rotateKey(owner, parent.id); + assert.equal(next.user_id, 'owner'); assert.equal(next.expires_at, parent.expires_at); + assert.throws(() => principal(reopened, child), unauthorized); + now += 3600000; assert.throws(() => principal(reopened, next), unauthorized); +}); + +test('users: rotation durably revokes old secret and descendants without extending scope, bots, user or expiry', async () => { + let now = 1000000; + const { auth, owner, directory } = await fixture('rotation', () => now); + const alice = await auth.createUser(owner, { name: 'Alice' }); + const parent = await auth.issue(owner, grant(alice.id)), oldPrincipal = principal(auth, parent); + const child = await auth.issue(oldPrincipal, grant(alice.id)); + now += 3000; + const rotated = await auth.rotateKey(owner, parent.id); + assert.notEqual(rotated.id, parent.id); assert.notEqual(rotated.key, parent.key); + for (const field of ['user_id', 'scopes', 'bot_ids', 'expires_at']) assert.deepEqual(rotated[field], parent[field]); + assert.throws(() => principal(auth, parent), unauthorized); assert.throws(() => principal(auth, child), unauthorized); + assert.equal(auth.active(oldPrincipal), false); + const nextPrincipal = principal(auth, rotated); auth.requireScope(nextPrincipal, 'agents.read', 'bot-a'); + assert.throws(() => auth.requireScope(nextPrincipal, 'agents.read', 'bot-b'), denied); + const persisted = JSON.parse(await readFile(join(directory, 'keys.json'), 'utf8')); + assert.equal(persisted.find(k => k.id === parent.id).revokedAt, now); + assert.equal(persisted.find(k => k.id === rotated.id).parentId, 'owner'); + assert.equal(persisted.find(k => k.id === rotated.id).expiresAt, 1010000); + for (const key of [parent, child, rotated]) assert.equal(JSON.stringify(persisted).includes(key.key), false); + const { stdout } = await execute(process.execPath, ['--input-type=module', '-e', ` + import assert from 'node:assert/strict'; + import { createAuth } from ${JSON.stringify(moduleUrl)}; + const auth = await createAuth(${JSON.stringify(directory)}, () => 1003000); + assert.throws(() => auth.authenticate('Bearer ' + process.env.TEST_OLD_KEY), e => e.status === 401); + assert.throws(() => auth.authenticate('Bearer ' + process.env.TEST_CHILD_KEY), e => e.status === 401); + const p = auth.authenticate('Bearer ' + process.env.TEST_NEW_KEY); + assert.equal(p.user_id, ${JSON.stringify(alice.id)}); assert.equal(p.expiresAt, 1010000); + process.stdout.write(JSON.stringify({oldRejected:true,childRejected:true,newAccepted:true,user:p.user_id,expiresAt:p.expiresAt})); + `], { env: { ...process.env, TEST_OLD_KEY: parent.key, TEST_NEW_KEY: rotated.key, TEST_CHILD_KEY: child.key } }); + assert.equal(JSON.parse(stdout).newAccepted, true); + await writeFile(join(directory, 'restart-observation.json'), stdout); + now = 1010000; assert.throws(() => principal(auth, rotated), unauthorized); +}); + +test('users: authorized managers rotate only active descendants and parent revocation still cascades', async () => { + const { auth, owner } = await fixture('delegated-rotation'); + const alice = await auth.createUser(owner, { name: 'Alice' }), bob = await auth.createUser(owner, { name: 'Bob' }); + const manager = await auth.issue(owner, grant(alice.id)), peer = await auth.issue(owner, grant(bob.id, 'bot-b')); + const p = principal(auth, manager), child = await auth.issue(p, { scopes: ['agents.read'], bot_ids: ['bot-a'] }); + const sibling = await auth.issue(owner, grant(alice.id)); + for (const id of [manager.id, peer.id, sibling.id, 'owner']) await assert.rejects(auth.rotateKey(p, id), denied); + await assert.rejects(auth.rotateKey(principal(auth, child), manager.id), denied); + const replacement = await auth.rotateKey(p, child.id); + assert.equal(replacement.user_id, alice.id); assert.equal(replacement.expires_at, child.expires_at); + assert.throws(() => principal(auth, child), unauthorized); + assert.equal(principal(auth, replacement).parentId, manager.id); + await assert.rejects(auth.rotateKey(p, child.id), e => e.code === 'key_inactive'); + await auth.revoke(owner, manager.id); + assert.throws(() => principal(auth, replacement), unauthorized); + await assert.rejects(auth.rotateKey(p, replacement.id), denied); + assert.equal(principal(auth, peer).user_id, bob.id); +}); + +test('users: concurrent rotations yield one replacement and disable wins queued issuance', async () => { + const { auth, owner, directory } = await fixture('concurrency'); + const alice = await auth.createUser(owner, { name: 'Alice' }); + const key = await auth.issue(owner, grant(alice.id)); + const outcomes = await Promise.allSettled(Array.from({ length: 12 }, () => auth.rotateKey(owner, key.id))); + assert.equal(outcomes.filter(r => r.status === 'fulfilled').length, 1); + assert.equal(outcomes.filter(r => r.status === 'rejected' && r.reason.code === 'key_inactive').length, 11); + assert.equal(JSON.parse(await readFile(join(directory, 'keys.json'), 'utf8')).length, 2); + const disabling = auth.disableUser(owner, alice.id); + const issuance = auth.issue(owner, grant(alice.id)); + await disabling; await assert.rejects(issuance, invalid); + const fresh = outcomes.find(r => r.status === 'fulfilled').value; + assert.throws(() => principal(auth, fresh), unauthorized); +}); + +test('users: owner token and identity are immutable through management methods', async () => { + const { auth, owner, directory } = await fixture('owner-protection'); + const before = await readFile(auth.keyFile, 'utf8'); + await assert.rejects(auth.rotateKey(owner, 'owner'), denied); + await assert.rejects(auth.revoke(owner, 'owner'), denied); + await assert.rejects(auth.disableUser(owner, 'owner'), denied); + await assert.rejects(auth.createUser(owner, { name: 'Replacement', id: 'owner' }), invalid); + for (const name of ['', ' ', 'x'.repeat(129), 'Alice\nInjected', 123]) await assert.rejects(auth.createUser(owner, { name }), invalid); + await assert.rejects(auth.disableUser(owner, 'missing'), e => e.status === 404); + assert.equal(await readFile(auth.keyFile, 'utf8'), before); + const reopened = await createAuth(directory); + assert.equal(reopened.authenticate('Bearer ' + before.trim()).id, 'owner'); + assert.equal(reopened.authenticate('Bearer ' + before.trim()).user_id, 'owner'); + assert.equal(reopened.listUsers(owner).length, 1); +}); + +test('users: corrupt and symlink user state fail closed and existing file permissions are repaired', async () => { + const { auth, directory, owner } = await fixture('unsafe'); + await chmod(auth.usersFile, 0o644); await createAuth(directory); + assert.equal((await stat(auth.usersFile)).mode & 0o777, 0o600); + await writeFile(auth.usersFile, '{broken'); await assert.rejects(createAuth(directory), SyntaxError); + await writeFile(auth.usersFile, JSON.stringify([{ id: 'owner', name: 'owner', createdAt: 0 }])); + await assert.rejects(createAuth(directory), /Invalid API user store/); + await unlink(auth.usersFile); const target = join(directory, 'target.json'); await writeFile(target, '[]'); + await symlink(target, auth.usersFile); await assert.rejects(createAuth(directory), /Unsafe credential file/); + await assert.rejects(auth.createUser(owner, { name: 'Alice' }), e => e.code === 'auth_persistence_failed'); + assert.equal(await readFile(target, 'utf8'), '[]'); + assert.equal(auth.active(owner), false); + assert.throws(() => auth.listUsers(owner), e => e.code === 'auth_persistence_failed'); +}); + +test('users: failed rotation never publishes a replacement and reopening retains the prior durable key', async () => { + const { auth, directory, owner } = await fixture('rotation-failure'); + const old = await auth.issue(owner, { scopes: ['agents.read'], bot_ids: ['bot-a'] }); + const file = join(directory, 'keys.json'), durable = await readFile(file, 'utf8'); + const target = join(directory, 'unchanged-keys.json'); await writeFile(target, durable); + await unlink(file); await symlink(target, file); + await assert.rejects(auth.rotateKey(owner, old.id), e => e.code === 'auth_persistence_failed'); + assert.equal(await readFile(target, 'utf8'), durable); + assert.throws(() => principal(auth, old), e => e.code === 'auth_persistence_failed'); + await assert.rejects(auth.flush(), e => e.code === 'auth_persistence_failed'); + await unlink(file); await writeFile(file, durable, { mode: 0o600 }); + const reopened = await createAuth(directory); + assert.equal(principal(reopened, old).id, old.id); + assert.equal(JSON.parse(await readFile(file, 'utf8')).length, 1); +}); diff --git a/tests/node-agent-webhooks.test.mjs b/tests/node-agent-webhooks.test.mjs new file mode 100644 index 0000000..a097191 --- /dev/null +++ b/tests/node-agent-webhooks.test.mjs @@ -0,0 +1,242 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, readFile, stat, writeFile, unlink, symlink, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHmac } from 'node:crypto'; +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { createWebhookOutbox, validateWebhookDestination, verifyWebhookSignature } from '../tools/node-agent-api/webhooks.mjs'; + +const alice = { id: 'nkey_alice' }, bob = { id: 'nkey_bob' }; +const destination = 'http://127.0.0.1:9999/events'; +const moduleUrl = new URL('../tools/node-agent-api/webhooks.mjs', import.meta.url).href; +const code = (value, status) => error => error.code === value && error.status === status; +async function directory(name) { + const base = process.env.NODE_AGENT_P2_EVIDENCE_DIR ?? tmpdir(); + await mkdir(base, { recursive: true }); + return realpath(await mkdtemp(join(base, 'webhooks-' + name + '-'))); +} +async function server(t, handler) { + const http = createServer(handler); + await new Promise((resolve, reject) => { http.once('error', reject); http.listen(0, '127.0.0.1', resolve); }); + t.after(() => new Promise(resolve => { http.closeAllConnections(); http.close(resolve); })); + return 'http://127.0.0.1:' + http.address().port; +} + +test('webhooks: disabled by default and destination validation blocks SSRF inputs before transport', async t => { + let calls = 0; + const w = await createWebhookOutbox(await directory('destinations'), { transport: async () => { calls++; return { status: 200 }; } }); + t.after(() => w.close()); + assert.throws(() => w.enqueue(alice, { idempotencyKey: 'one', event: {} }), code('webhooks_disabled', 503)); + assert.throws(() => w.dispatchDue(), code('webhooks_disabled', 503)); + for (const url of ['http://example.com/', 'http://localhost/', 'http://127.1/', 'http://2130706433/', 'http://0x7f000001/', 'http://127.0.0.2/', 'http://169.254.169.254/', 'http://10.0.0.1/', 'file:///tmp/test', 'ftp://127.0.0.1/', 'http://[::ffff:127.0.0.1]/', 'http://127.0.0.1.evil.test/', 'http://user:password@127.0.0.1/', 'http://127.0.0.1:0/', 'http://127.0.0.1:99999/', 'http://127.0.0.1/?token=secret', 'http://127.0.0.1/#fragment', 'http://127.0.0.1\\@example.com/', ' http://127.0.0.1/', 'http://127.0.0.1/\n']) { + assert.throws(() => validateWebhookDestination(url), code('invalid_request', 400), url); + await assert.rejects(w.configure({ destination: url }), code('invalid_request', 400)); + } + assert.equal(validateWebhookDestination('https://[::1]:444/hook'), 'https://[::1]:444/hook'); + await w.configure({ destination }); + w.disable(); + assert.throws(() => w.enqueue(alice, { idempotencyKey: 'two', event: {} }), code('webhooks_disabled', 503)); + assert.equal(calls, 0); +}); + +test('webhooks: concurrent idempotent enqueue, conflicts, principal isolation and single-flight delivery', async t => { + const dir = await directory('concurrency'); + let sends = 0, unblock, signalStart; + const started = new Promise(resolve => { signalStart = resolve; }); + const blocked = new Promise(resolve => { unblock = resolve; }); + const w = await createWebhookOutbox(dir, { transport: async () => { sends++; signalStart(); await blocked; return { status: 204 }; } }); + t.after(() => w.close()); + await w.configure({ destination }); + const event = { type: 'session.changed', session_id: 'nsess_test' }; + const entries = await Promise.all(Array.from({ length: 20 }, () => w.enqueue(alice, { idempotencyKey: 'same', event }))); + assert.equal(new Set(entries.map(d => d.id)).size, 1); + await assert.rejects(w.enqueue(alice, { idempotencyKey: 'same', event: { type: 'different' } }), code('webhook_idempotency_conflict', 409)); + await assert.rejects(w.status(bob, entries[0].id), code('not_found', 404)); + const foreign = await w.enqueue(bob, { idempotencyKey: 'same', event }); + assert.notEqual(foreign.id, entries[0].id); + const dispatches = Array.from({ length: 10 }, () => w.dispatchDue()); + await started; + assert.equal(sends, 1); + assert.equal((await w.status(alice, entries[0].id)).status, 'delivering'); + unblock(); await Promise.all(dispatches); + assert.equal(sends, 2); + assert.equal((await w.status(alice, entries[0].id)).status, 'delivered'); + assert.deepEqual(await w.dispatchDue(), []); + assert.equal(JSON.parse(await readFile(w.stateFile, 'utf8')).deliveries.length, 2); + assert.equal((await stat(w.stateFile)).mode & 0o777, 0o600); + assert.equal((await stat(join(dir, 'webhooks'))).mode & 0o777, 0o700); +}); + +test('webhooks: real HTTP exact-body HMAC, timestamp and delivery ID verification with key rotation', async t => { + const captures = [], dir = await directory('wire'); + const url = await server(t, async (req, res) => { + const chunks = []; for await (const chunk of req) chunks.push(chunk); + captures.push({ headers: req.headers, body: Buffer.concat(chunks).toString('utf8') }); + res.writeHead(204); res.end(); + }); + let now = 1000000; + const w = await createWebhookOutbox(dir, { clock: () => now }); t.after(() => w.close()); + const first = (await w.configure({ destination: url })).signingKey; + const entry = await w.enqueue(alice, { idempotencyKey: 'signed', event: { type: 'session.changed', value: 'UTF-8: 测试' } }); + await w.dispatchDue(); + const c = captures[0], h = c.headers; + assert.equal(h['x-node-webhook-id'], entry.id); + assert.equal(h['x-node-webhook-timestamp'], '1000'); + assert.equal(h['x-node-webhook-signature'], 'v1=' + createHmac('sha256', Buffer.from(first.secret, 'base64url')).update('1000.' + entry.id + '.' + c.body).digest('hex')); + assert.equal(Number(h['content-length']), Buffer.byteLength(c.body)); + const verification = { keys: [first], keyId: first.id, timestamp: 1000, deliveryId: entry.id, body: c.body, signature: h['x-node-webhook-signature'], now }; + assert.equal(verifyWebhookSignature(verification), true); + for (const changes of [{ body: c.body + ' ' }, { deliveryId: 'whd_other' }, { timestamp: 1001 }, { keyId: 'missing' }, { now: now + 300001 }, { now: now - 300001 }, { signature: 'v1=garbage' }]) assert.equal(verifyWebhookSignature({ ...verification, ...changes }), false); + const secondEntry = await w.enqueue(alice, { idempotencyKey: 'rotated', event: { type: 'session.changed' } }); + const second = await w.rotateKey(); now += 1000; + await w.dispatchDue(); + assert.notEqual(first.id, second.id); assert.notEqual(first.secret, second.secret); + assert.equal(captures[1].headers['x-node-webhook-key-id'], second.id); + assert.equal(verifyWebhookSignature({ keys: [first, second], keyId: second.id, timestamp: 1001, deliveryId: secondEntry.id, body: captures[1].body, signature: captures[1].headers['x-node-webhook-signature'], now }), true); + assert.equal(verifyWebhookSignature({ ...verification, keys: [second] }), false); + await writeFile(join(dir, 'wire-capture.json'), JSON.stringify(captures, null, 2)); +}); + +test('webhooks: durable backoff across reopen, retry caps and sanitized transport errors', async t => { + const dir = await directory('retry'); let now = 1000, count = 0; + const attemptedKeys = []; + const policy = { baseDelayMs: 100, maxDelayMs: 150, maxAttempts: 3 }; + let w = await createWebhookOutbox(dir, { clock: () => now, policy, transport: async () => { count++; return { status: 503 }; } }); + t.after(() => w.close()); + await w.configure({ destination }); + const entry = await w.enqueue(alice, { idempotencyKey: 'retry', event: { type: 'retry' } }); + await w.dispatchDue(); + assert.deepEqual((await w.status(alice, entry.id)), { ...entry, status: 'retry', attempts: 1, nextAttemptAt: 1100, lastError: 'http_status', lastStatus: 503 }); + const key = await w.rotateKey(); + await w.close(); + w = await createWebhookOutbox(dir, { clock: () => now, policy, transport: async ({ headers }) => { count++; attemptedKeys.push(headers['x-node-webhook-key-id']); throw new Error('Bearer CREDENTIAL_SENTINEL / PROMPT_SENTINEL'); } }); + assert.throws(() => w.dispatchDue(), code('webhooks_disabled', 503)); + assert.deepEqual((await w.configure({ destination })).signingKey, key); + assert.equal((await w.enqueue(alice, { idempotencyKey: 'retry', event: { type: 'retry' } })).id, entry.id); + now = 1099; await w.dispatchDue(); assert.equal(count, 1); + now = 1100; await w.dispatchDue(); assert.equal(count, 2); + assert.equal((await w.status(alice, entry.id)).nextAttemptAt, 1250); + now = 1250; await w.dispatchDue(); assert.equal(count, 3); + const failed = await w.status(alice, entry.id); + assert.equal(failed.status, 'failed'); assert.equal(failed.lastError, 'transport'); + now = 99999; await w.dispatchDue(); assert.equal(count, 3); + assert.deepEqual(attemptedKeys, [key.id, key.id]); + assert.ok(!/CREDENTIAL_SENTINEL|PROMPT_SENTINEL/.test(await readFile(w.stateFile, 'utf8'))); +}); + +test('webhooks: real HTTP redirect is not followed, 400 is terminal and 429 retries', async t => { + let redirected = 0; + const target = await server(t, (_req, res) => { redirected++; res.end(); }); + const source = await server(t, (req, res) => { + if (req.url === '/redirect') { res.writeHead(302, { location: target }); res.end(); } + else { res.writeHead(req.url === '/rate' ? 429 : 400); res.end(); } + }); + const w = await createWebhookOutbox(await directory('http-negative')); t.after(() => w.close()); + const entries = []; + for (const [path, status] of [['/redirect', 'failed'], ['/bad', 'failed'], ['/rate', 'retry']]) { + await w.configure({ destination: source + path }); + const d = await w.enqueue(alice, { idempotencyKey: path.slice(1), event: { type: 'notice' } }); + entries.push({ id: d.id, status }); + } + await w.dispatchDue(); + for (const entry of entries) assert.equal((await w.status(alice, entry.id)).status, entry.status); + assert.equal(redirected, 0); +}); + +test('webhooks: disable and close win over pending configuration and prevent new dispatch claims', async () => { + const dir = await directory('lifecycle'); let calls = 0; + let finish, started; + const begin = new Promise(resolve => { started = resolve; }); + const blocked = new Promise(resolve => { finish = resolve; }); + const w = await createWebhookOutbox(dir, { transport: async () => { calls++; started(); await blocked; return { status: 204 }; } }); + const configuration = w.configure({ destination }); w.disable(); await configuration; + assert.throws(() => w.dispatchDue(), code('webhooks_disabled', 503)); + await w.configure({ destination }); + const one = await w.enqueue(alice, { idempotencyKey: 'one', event: {} }); + const two = await w.enqueue(alice, { idempotencyKey: 'two', event: {} }); + const dispatch = w.dispatchDue(); await begin; + const pendingConfiguration = w.configure({ destination }); + const closed = w.close(); finish(); + await Promise.all([dispatch, pendingConfiguration, closed]); + assert.equal(calls, 1); + const state = JSON.parse(await readFile(w.stateFile, 'utf8')); + assert.equal(state.deliveries.find(d => d.id === one.id).status, 'delivered'); + assert.equal(state.deliveries.find(d => d.id === two.id).status, 'pending'); + assert.throws(() => w.dispatchDue(), code('webhooks_closed', 503)); +}); + +test('webhooks: timeout bounds a stalled HTTP body and aborts injected transport', async t => { + const url = await server(t, (_req, res) => { res.writeHead(200); res.write('never finishes'); }); + const w = await createWebhookOutbox(await directory('http-timeout'), { policy: { timeoutMs: 40, maxAttempts: 1 } }); t.after(() => w.close()); + await w.configure({ destination: url }); + const d = await w.enqueue(alice, { idempotencyKey: 'timeout', event: {} }); + const start = Date.now(); await w.dispatchDue(); + assert.ok(Date.now() - start < 2000); + assert.equal((await w.status(alice, d.id)).lastError, 'timeout'); + assert.equal((await w.status(alice, d.id)).status, 'failed'); + let aborted = false; + const injected = await createWebhookOutbox(await directory('injected-timeout'), { policy: { timeoutMs: 30 }, transport: ({ signal }) => new Promise((_, reject) => signal.addEventListener('abort', () => { aborted = true; reject(new Error('aborted')); }, { once: true })) }); + t.after(() => injected.close()); await injected.configure({ destination }); + const pending = await injected.enqueue(alice, { idempotencyKey: 'hang', event: {} }); + await injected.dispatchDue(); + assert.equal(aborted, true); assert.equal((await injected.status(alice, pending.id)).status, 'retry'); +}); + +test('webhooks: SIGKILL during delivery recovers with same delivery ID and payload, never false success', async t => { + const dir = await directory('crash'), captured = [], arrivals = []; + let notify; + const received = new Promise(resolve => { notify = resolve; }); + const url = await server(t, async (req, res) => { + let body = ''; for await (const chunk of req) body += chunk; + captured.push(body); arrivals.push(req.headers['x-node-webhook-id']); + if (captured.length === 1) notify(); else { res.writeHead(204); res.end(); } + }); + const child = spawn(process.execPath, ['--input-type=module', '-e', ` + import { createWebhookOutbox } from ${JSON.stringify(moduleUrl)}; + const w = await createWebhookOutbox(${JSON.stringify(dir)}); + await w.configure({destination:${JSON.stringify(url)}}); + await w.enqueue({id:'nkey_alice'}, {idempotencyKey:'crash',event:{type:'session.changed'}}); + await w.dispatchDue(); + `], { stdio: ['ignore', 'ignore', 'pipe'] }); + t.after(() => { if (child.exitCode === null) child.kill('SIGKILL'); }); + let errors = ''; child.stderr.on('data', bytes => { errors += bytes; }); + await Promise.race([received, once(child, 'exit').then(() => { throw new Error(errors || 'child exited before delivery'); })]); + const exit = once(child, 'exit'); child.kill('SIGKILL'); assert.equal((await exit)[1], 'SIGKILL'); + assert.equal(JSON.parse(await readFile(join(dir, 'webhooks', 'outbox.json'), 'utf8')).deliveries[0].status, 'delivering'); + await assert.rejects(createWebhookOutbox(dir), /locked/); + await unlink(join(dir, 'webhooks', 'service.lock')); + const w = await createWebhookOutbox(dir); t.after(() => w.close()); + const recovered = await w.status(alice, arrivals[0]); + assert.equal(recovered.status, 'retry'); assert.equal(recovered.lastError, 'interrupted'); + assert.throws(() => w.dispatchDue(), code('webhooks_disabled', 503)); + await w.configure({ destination: url }); + await w.dispatchDue(); + assert.deepEqual(arrivals, [arrivals[0], arrivals[0]]); + assert.equal(captured[0], captured[1]); + assert.equal((await w.status(alice, arrivals[0])).status, 'delivered'); + assert.equal((await w.status(alice, arrivals[0])).attempts, 2); + await writeFile(join(dir, 'crash-replay-observation.json'), JSON.stringify({ arrivals, captured, signal: 'SIGKILL', recovered })); +}); + +test('webhooks: capacity, payload bounds, invalid policy, corruption, symlinks and persistence errors fail closed', async () => { + const dir = await directory('negative'); let calls = 0; + const w = await createWebhookOutbox(dir, { policy: { maxEntries: 1, maxBodyBytes: 200 }, transport: async () => { calls++; return { status: 204 }; } }); + await assert.rejects(createWebhookOutbox(dir), /locked/); + await w.configure({ destination }); + assert.throws(() => w.enqueue(alice, { idempotencyKey: 'large', event: { text: 'x'.repeat(201) } }), code('invalid_request', 400)); + const d = await w.enqueue(alice, { idempotencyKey: 'one', event: {} }); + await assert.rejects(w.enqueue(alice, { idempotencyKey: 'two', event: {} }), code('webhook_capacity', 429)); + await unlink(w.stateFile); const target = join(dir, 'target'); await writeFile(target, '{}'); await symlink(target, w.stateFile); + await assert.rejects(w.dispatchDue(), code('webhook_persistence_failed', 503)); + assert.equal(calls, 0); + await assert.rejects(w.status(alice, d.id), code('webhook_persistence_failed', 503)); + await w.close(); await w.close(); + await assert.rejects(createWebhookOutbox(dir)); + assert.equal(await readFile(target, 'utf8'), '{}'); + await unlink(w.stateFile); await writeFile(w.stateFile, '{broken'); + await assert.rejects(createWebhookOutbox(dir), SyntaxError); + for (const policy of [{ timeoutMs: 0 }, { maxAttempts: Infinity }, { maxEntries: -1 }]) await assert.rejects(createWebhookOutbox(dir, { policy }), code('invalid_request', 400)); +}); diff --git a/tools/node-agent-api/README.md b/tools/node-agent-api/README.md new file mode 100644 index 0000000..6a9bc6a --- /dev/null +++ b/tools/node-agent-api/README.md @@ -0,0 +1,122 @@ +# Node Agent API 0.3.0 + +Node Agent API 是独立的本机服务。原始 GrokNode 管理 bot 和桌面,所有 bot 共用它已经运行的容器与数据。Codex Harness、模型调用和对话保存在 Mac;shell、改文件和测试通过 Docker stdio 执行端进入 Linux。API 不修改或替换 GrokNode 源码、App、host、daemon、登录和启动流程,也不自动启动、重启或新建盒子。 + +这是借鉴 agents / sessions / events 概念的外部适配服务,不是 OpenAI SDK 兼容实现。完整的接口索引、请求示例和接入流程见 [Node Agent API 0.3.0 详细文档](../../docs/NODE_AGENT_API_0_3.md)。当前需求见 [requirements.md](./requirements.md),机器可读接口见 [openapi.json](./openapi.json)。 + +## 启动和网页 + +依赖 Node 26.5.x、Mac 与 Linux 两端 Codex 0.160.0、项目现有依赖和已经运行的 GrokNode 盒子。Linux Codex 使用先前安装的持久目录 /home/box/sand-data/codex-vm;版本不符时明确拒绝,不自动升级。 + +新盒子可单独安装执行包,不更改 GrokNode 的启动脚本或程序: + +```sh +docker exec --user box grok-node-local-vm npm install -g @openai/codex@0.160.0 --prefix /home/box/sand-data/codex-vm +``` + +容器内不需要复制 Mac 登录文件或模型 API key;exec-server 由连接按需附加,断线后由用户恢复会话。 + +在仓库根目录运行,先安装锁定依赖: + +```sh +npm ci +npm run node-agent-api -- --backend codex --port 18770 --recover true +``` + +打开 http://127.0.0.1:18770/ui/ 。必须通过 HTTP 服务打开;直接打开 web/index.html 不提供 API。启动输出给出 owner_key_file 路径,不打印密钥。默认是 .lab/node-agent-shared/owner.key,0600;用户手动填入网页。网页只在内存保留密钥,刷新后重新输入。 + +常用选项:--state、--runtime-state、--container、--workspace、--model、--base-url、--port。默认容器 grok-node-local-vm,共享项目 /workspace/codex-projects/node-agent-api,Mac 会话目录 .lab/shared-runtime。--workspace 可选择 /workspace 下的现有项目;对应 CLI 应使用同一设置。 + +codex 是共享盒子后端。grok 是显式 legacy gateway 后端,默认只读、共享原始 bot transcript。仓库发布版本不包含旧的每 bot 容器管理或实验 host。 + +此版本默认使用已有 opencodex 代理 http://127.0.0.1:10100/v1 和 Mac 的 ~/.codex/opencodex-catalog.json;Mac 需要已有 Codex 登录。可用 --base-url 和 --model 指定对应代理端点与模型。API 不替用户安装或修改代理、登录及原 GrokNode 软件。Node 版本遵循仓库 .node-version(26.5.x)。 + +Ctrl+C 只关闭 API 与它自己的连接、执行端和 VNC 辅助进程,不关闭 GrokNode 或共享容器。Linux 中用户启动的应用按正常应用管理。 + +## 日常流程 + +1. 选择原 GrokNode 中的 bot,打开或新建 Mac Codex 会话。 +2. 导入项目或使用配置的共享项目;发送任务,观察原生工具记录和回合结果。 +3. 查看差异、审批待处理动作、取消当前回合、导出项目。 +4. 点击观看或接管。刷新应用列表可选择实际窗口,分别打开终端、浏览器的观看或接管窗口。打开应用须先持有该 bot 的控制租约。 +5. 剪贴板仅通过文本框和按钮手动传递。没有自动同步 Mac 系统剪贴板。 +6. 用备份按钮保存项目与会话。恢复前会自动备份当前项目;进程内存不恢复。 + +网页、CLI 与 GrokNode 读取同一份实际 bot/桌面映射。桌面编号可能变化,不使用固定 token=3 或自建编号。单应用 id 是当前 X11 窗口 id,不是授权凭据。 + +## CLI + +```sh +node tools/node-agent-api/client-cli.mjs status BOT_ID +node tools/node-agent-api/client-cli.mjs codex BOT_ID +node tools/node-agent-api/client-cli.mjs resume BOT_ID --thread THREAD_ID +node tools/node-agent-api/client-cli.mjs resume BOT_ID --thread THREAD_ID --prompt "运行测试" +node tools/node-agent-api/client-cli.mjs desktop BOT_ID --session API_SESSION_ID +node tools/node-agent-api/client-cli.mjs desktop BOT_ID --session API_SESSION_ID --application 0xWINDOW --mode view +``` + +desktop 返回短期授权链接;默认观看。--mode control 明确申请接管。CLI 的 Mac profile 与 API 使用相同目录;网页转 CLI 前先 POST session/handoff,CLI 结束后 POST session/resume 读取同一 thread 的更新。API 不自动重发旧任务。 + +## 认证与桌面授权 + +除 /health、/ui/ 静态页面及已签发的 viewer 路径外,REST 使用 Authorization: Bearer。精确校验 Host 和 Origin,无跨域 CORS。派生 key 限定 scopes、bot_ids 与有效期;子 key 不超过父权限,撤销父 key 使后代失效。 + +| scope | 用途 | +| --- | --- | +| agents.read / agents.write | bot 查询 / 通过原有接口创建 bot | +| sessions.read / sessions.write | 会话与记录查询 / 消息、取消和项目操作 | +| desktop.view | 应用枚举和观看 | +| desktop.control | 接管、交还、手动写剪贴板和打开应用 | +| keys.manage | 管理授权范围内的派生 key | + +桌面 ticket 默认 60 秒、最多 300 秒,单次兑换为 HttpOnly、SameSite=Strict cookie,viewer 有效期 15 分钟。应用窗口各自使用 cookie 路径,可以同时观看多个应用;每 bot 只有一个控制租约。观看输入在服务端过滤,交还、会话关闭或撤销 key 断开对应授权。 + +API 的授权保护 API 入口。原 GrokNode 的 6080/6081 VNC 端口保持原有访问策略,API 没有关闭或改写它们。API bot grants 也不构成共享 Linux 文件系统的 bot 间隔离。 + +新的 Codex 执行进程为 UID 1000、能力边界为空、no-new-privileges,环境只包含 Linux 必需变量。Mac 模型凭据不复制进 Linux;已验证该执行用户不能读取原容器的 /root/.codex/auth.json。原 GrokNode 的 root 进程和既有挂载保持原样。 + +## 接口分组 + +所有 session 路径前缀为 /v1/agents/sessions/{session_id}。 + +| 接口 | 行为 | +| --- | --- | +| GET /v1/capabilities、/health | 当前后端能力与版本 | +| GET/POST /v1/agents | 原 bot 查询、原接口创建 | +| GET/POST /v1/agents/sessions | 查询、新建独立 Codex thread | +| GET/PATCH session、POST close | 会话资料、metadata、保全式关闭 | +| GET items、turns、traces | 原生记录、权威回合状态、工具及 token 用量;未知成本为 null | +| GET/POST events | 持久游标 SSE、消息和取消 | +| GET actions、POST actions/{id} | 持久审批与 accept/decline | +| POST resume、handoff | 恢复 thread、转交 CLI | +| POST desktop、handback | 授权观看/接管、交还 | +| GET/POST applications | 当前实际窗口、明确打开 terminal/browser | +| GET/POST clipboard | 手动读取/传递文本 | +| GET project/diff、POST project/import/export | 差异、无覆盖导入、带认证的归档导出 | +| GET artifacts/{id}/content | 认证下载 tgz,返回大小与 SHA-256 | +| POST environment/backup、restore | 项目、会话与日志保全/恢复 | +| POST environment/recreate | 共享后端返回 409 managed_by_grok_node;盒子生命周期由 GrokNode 管理 | +| /v1/users、/v1/keys | owner 用户管理、派生授权、轮换与级联撤销 | +| /v1/usage、/v1/webhooks | 配额、持久签名投递 | + +消息接受旧的 message/text,以及 agent.session.input.message 的单 input_text 子集。Idempotency-Key 或 idempotency_key 防止同一会话重复提交。202 只表示接受;任务结果看 turns,不能把 idle 当成功。崩溃中的请求记 unknown,不自动重放。 + +取消使用原生 turn/interrupt,并对该回合记录的 processId 调用该 thread 的 backgroundTerminals/terminate;不会停止共享容器或其他 thread。 + +## 备份与恢复 + +Mac 私有备份中保存共享项目归档、完整 Git 目录、未提交修改、选定 Codex profile/会话与 session.json。恢复先检查来源、项目路径和 SHA-256,再保存当前项目。当前恢复是覆盖归档已有条目的方式,不删除归档之外的新文件;不等于整个共享盒子的镜像快照。 + +项目快照不暂停原 GrokNode 的任务。API 内运行的共享项目任务会阻止恢复,但原程序中的并发写入仍需由用户协调。GrokNode 自行修复或重启盒子后,API 按当前容器/桌面映射重新连接,并从 Mac 读取原 thread。 + +## 多用户、配额与 webhook + +默认每用户 120 请求/分钟、4 条并发流、64 MiB API 会话与 artifact 预留存储。SSE 与 viewer 共用流配额。审计只保存限定元数据,不拷贝请求正文、密钥或对话内容。配额拒绝不会修改已保存会话;磁盘持久化失败会关闭相关操作。 + +webhook 默认关闭,仅支持数值 loopback HTTP(S) 地址,无 DNS 和重定向跟随。POST /v1/webhooks 配置 destination 并取得签名 key;HMAC-SHA256 的输入是 timestamp.deliveryId.exactBody,签名为 v1=hex。接收端验证 key id、数值 epoch timestamp、签名,并持久去重 delivery id。投递为至少一次,重试保留同一 id;载荷只有事件元数据。支持 key 轮换、持久 outbox、退避、状态查询。traces 是本 API 的 JSON 记录,未声称 OTLP 或 OpenAI SDK 等价。 + +## 验证与原型 + +当前共享路径的证据保存在 .lab/shared-harness-verification.json、shared-project-verification.json、shared-cli-verification.json、shared-lifecycle-verification.json 及 service-load-verification.json。未完成的验证不得由旧双容器报告替代。 + +历史 .lab/api-p0p2、.lab/node-runtime、source/ 下旧实验改动保留为历史,不部署到原程序,也不以删除它们完成迁移。需求与真实证据优先于旧文档。 diff --git a/tools/node-agent-api/VERIFICATION.md b/tools/node-agent-api/VERIFICATION.md new file mode 100644 index 0000000..4e5a5c7 --- /dev/null +++ b/tools/node-agent-api/VERIFICATION.md @@ -0,0 +1,24 @@ +# Node Agent API 0.3.0 整合验证 + +2026-10-06,基于远端 main 的 6bce415 构建干净整合分支。 + +| 检查 | 结果 | +| --- | --- | +| npm ci --ignore-scripts | 锁定依赖安装成功;没有运行 App 打包或部署 | +| npm run test:node-agent-api | 86 通过,0 失败 | +| npm run test:offline | 950 通过,0 失败,129 按既有构建产物条件跳过 | +| npm run typecheck / source:typecheck | 通过 | +| npm run frontend:build | 通过,保留既有动态导入/体积提示 | +| 新目录真实 Harness 连接 | 原共享盒子 /workspace/codex-projects/node-agent-api,Docker stdio 连接成功,无模型任务 | +| 提交边界 | source/、frontend/、原 App 打包及登录逻辑没有改动 | + +此前 0.3.0 真实验收覆盖共享容器与不同 bot 桌面、原生修复/远端删除/npm 测试/导出恢复、网页与 CLI 同一 thread、原生审批拒绝、后台进程定向取消、缺失执行端关闭本机回落、独立终端/浏览器窗口、手动剪贴板和交还控制。另有 41 项运行服务/独立配额 fixture 检查通过。 + +私有密钥、会话、机器日志、截图与真实 bot 标识未纳入仓库。在线原盒子生命周期和用户任务没有被重启或替换;旧实验改动留在原工作副本中。 + +## 0.3.0 文档补充验证 + +- API 回归 87 通过、0 失败;新增契约用例先复现错误审批 schema,再验证正确的审批、取消、用户密钥与单应用 viewer 请求/响应形状。 +- 详细子页面与 OpenAPI 的 55 个方法/路径逐项对应,无漏项或重复。 +- 首页、运行说明与详细子页面的相对链接和目录锚点通过;API 文档的 26 个 shell 示例完成语法检查,4 个 JSON 示例完成解析检查。 +- 修正 OpenAPI 的旧实验环境说明、请求关联头名称和单应用 viewer 路径;原 GrokNode 和 API 执行代码没有修改。 diff --git a/tools/node-agent-api/application-stream.mjs b/tools/node-agent-api/application-stream.mjs new file mode 100644 index 0000000..b3c181c --- /dev/null +++ b/tools/node-agent-api/application-stream.mjs @@ -0,0 +1,18 @@ +// Run only through docker exec in the existing box. No original scripts or +// token-router files are installed or replaced. stdout is binary RFB only. +export const applicationStreamProgram = String.raw` +const fs=require('fs'),net=require('net'),{spawn,spawnSync}=require('child_process'); +const [botId,seat,id,mode]=process.argv.slice(1),display=':'+seat; +if(!/^[1-9][0-9]{0,2}$/.test(seat)||!/^0x[0-9a-f]{1,16}$/.test(id)||!['view','control'].includes(mode))process.exit(2); +function owner(){const s=JSON.parse(fs.readFileSync('/home/box/.sand-window-assignments.json','utf8'));if(s.assignments[botId]!==Number(seat))throw Error('Seat changed');if(s.tokens?.[botId]&&fs.readFileSync('/tmp/sand-window-tokens.d/'+seat,'utf8').trim()!==s.tokens[botId])throw Error('Seat changed');} +function windows(){const options={env:{...process.env,DISPLAY:display},encoding:'utf8',timeout:5000},r=spawnSync('xprop',['-root','_NET_CLIENT_LIST'],options);let ids=(r.stdout||'').match(/0x[0-9a-f]+/gi)||[];if(!ids.length){const tree=spawnSync('xwininfo',['-root','-tree'],options);ids=(tree.stdout||'').split(/\r?\n/).flatMap(line=>{const m=/^\s{5}(0x[0-9a-f]+) ".*?":.*?\s(\d+)x(\d+)[+-]/i.exec(line);return m&&Number(m[2])>150&&Number(m[3])>100?[m[1]]:[]});}if(!ids.map(x=>x.toLowerCase()).includes(id))throw Error('Window unavailable');} +let child,socket,closed=false;function stop(){if(closed)return;closed=true;socket?.destroy();child?.kill('SIGTERM');setTimeout(()=>{child?.kill('SIGKILL');process.exit(0)},100).unref();} +process.stdin.on('end',stop);process.on('SIGTERM',stop);process.on('SIGINT',stop); +(async()=>{owner();windows();const probe=net.createServer();await new Promise(r=>probe.listen(0,'127.0.0.1',r));const port=probe.address().port;await new Promise(r=>probe.close(r)); +if(mode==='control'){const focus=spawnSync('xdotool',['windowraise',id,'windowfocus',id],{env:{...process.env,DISPLAY:display},timeout:3000,stdio:'ignore'});if(focus.status!==0)throw Error('Application focus unavailable');} +const args=['-display',display,'-id',id,'-rfbport',String(port),'-localhost','-nopw','-forever','-shared','-noxdamage','-noshm','-nosel','-noclipboard','-nosetclipboard','-nosetprimary','-quiet'];if(mode==='view')args.push('-viewonly'); +child=spawn('x11vnc',args,{env:{...process.env,DISPLAY:display},stdio:'ignore'});child.on('error',stop);child.on('exit',stop); +for(let i=0;i<50&&!closed;i++){const connected=await new Promise(resolve=>{const s=net.connect(port,'127.0.0.1');s.once('connect',()=>{s.destroy();resolve(true)});s.once('error',()=>resolve(false));});if(connected)break;await new Promise(r=>setTimeout(r,100));} +if(closed)return;owner();windows();socket=net.connect(port,'127.0.0.1');socket.on('error',stop);socket.on('close',stop);process.stdin.pipe(socket);socket.pipe(process.stdout); +const timer=setInterval(()=>{try{owner();windows()}catch{stop()}},3000);timer.unref();setTimeout(stop,900000).unref(); +})().catch(()=>{console.error('Application viewer unavailable');stop();process.exitCode=1});`; diff --git a/tools/node-agent-api/auth.mjs b/tools/node-agent-api/auth.mjs new file mode 100644 index 0000000..c806819 --- /dev/null +++ b/tools/node-agent-api/auth.mjs @@ -0,0 +1,143 @@ +import { randomBytes, createHash, timingSafeEqual } from 'node:crypto'; +import { readFile, writeFile, lstat, chmod } from 'node:fs/promises'; +import { join } from 'node:path'; +import { privateDirectory } from './runtime/common.mjs'; +import { ApiError, badRequest, forbidden, identifier, object } from './errors.mjs'; +import { atomicJson, readPrivateJson } from './persistence.mjs'; + +export const SCOPES = ['agents.read', 'agents.write', 'sessions.read', 'sessions.write', 'desktop.view', 'desktop.control', 'keys.manage']; +const digest = value => createHash('sha256').update(value).digest('hex'); +const secret = () => randomBytes(32).toString('base64url'); + +export async function createAuth(directory, clock = Date.now) { + await privateDirectory(directory); + const keyFile = join(directory, 'owner.key'), storeFile = join(directory, 'keys.json'), usersFile = join(directory, 'users.json'); + async function privateFile(file) { + try { const info = await lstat(file); if (!info.isFile() || info.isSymbolicLink()) throw new Error('Unsafe credential file'); await chmod(file, 0o600); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + } + await privateFile(keyFile); await privateFile(storeFile); await privateFile(usersFile); + let ownerKey; + try { ownerKey = (await readFile(keyFile, 'utf8')).trim(); } + catch (error) { if (error.code !== 'ENOENT') throw error; ownerKey = secret(); await writeFile(keyFile, ownerKey + '\n', { flag: 'wx', mode: 0o600 }); } + if (!/^[A-Za-z0-9_-]{43}$/.test(ownerKey)) throw new Error('Invalid owner credential'); + const savedUsers = await readPrivateJson(usersFile, []); + if (!Array.isArray(savedUsers) || savedUsers.some(u => !u || typeof u.id !== 'string' || !/^nuser_[a-f0-9]{24}$/.test(u.id) || typeof u.name !== 'string' || !u.name.trim() || u.name.length > 128 || /[\x00-\x1f\x7f]/.test(u.name) || !Number.isFinite(u.createdAt) || (u.disabledAt !== undefined && !Number.isFinite(u.disabledAt))) || new Set(savedUsers.map(u => u.id)).size !== savedUsers.length) throw new Error('Invalid API user store'); + let users = new Map(savedUsers.map(u => [u.id, u])); + const saved = await readPrivateJson(storeFile, []); + if (!Array.isArray(saved) || saved.some(k => !k || typeof k.id !== 'string' || !/^[a-f0-9]{64}$/.test(k.digest ?? '') || !Array.isArray(k.scopes) || k.scopes.some(s => !SCOPES.includes(s)) || !Array.isArray(k.botIds) || !Number.isFinite(k.expiresAt))) throw new Error('Invalid API credential store'); + if (new Set(saved.map(k => k.id)).size !== saved.length || saved.some(k => k.id === 'owner' || (k.user_id !== undefined && k.user_id !== 'owner' && !users.has(k.user_id)))) throw new Error('Invalid API credential subject'); + let keys = new Map(saved.map(k => [k.id, { ...k, user_id: k.user_id === undefined ? 'owner' : k.user_id }])); + const owner = { id: 'owner', user_id: 'owner', digest: digest(ownerKey), scopes: SCOPES, botIds: ['*'], expiresAt: Infinity }; + await atomicJson(usersFile, savedUsers); + let saving = Promise.resolve(), fault; + // The service owns the directory lock. Serialize all auth mutations and publish only after fsync. + function mutate(kind, operation) { + const task = saving.then(async () => { + if (fault) throw fault; + const next = structuredClone(kind === 'keys' ? keys : users), result = operation(next); + try { await atomicJson(kind === 'keys' ? storeFile : usersFile, [...next.values()]); } + catch { fault = new ApiError(503, 'auth_persistence_failed', 'Reopen authentication after checking its state'); throw fault; } + if (kind === 'keys') keys = next; else users = next; + return result; + }); + saving = task.catch(() => {}); + return task; + } + function active(principal, seen = new Set()) { + if (fault || !principal) return false; + if (principal.id === 'owner') return true; + const current = keys.get(principal.id); + if (!current || current.revokedAt !== undefined || current.expiresAt <= clock() || seen.has(current.id) || (current.user_id !== 'owner' && (!users.has(current.user_id) || users.get(current.user_id).disabledAt !== undefined))) return false; + seen.add(current.id); + return active({ id: current.parentId ?? 'owner' }, seen); + } + function descendant(record, parentId) { + const seen = new Set(); + let current = record; + while (current && !seen.has(current.id)) { seen.add(current.id); if (current.parentId === parentId) return true; current = keys.get(current.parentId); } + return false; + } + function authenticate(header) { + if (fault) throw fault; + if (typeof header !== 'string' || !header.startsWith('Bearer ')) throw new ApiError(401, 'unauthorized', 'Bearer credential required'); + const value = digest(header.slice(7)); + const principal = [owner, ...keys.values()].find(k => timingSafeEqual(Buffer.from(k.digest, 'hex'), Buffer.from(value, 'hex')) && active(k)); + if (!principal) throw new ApiError(401, 'unauthorized', 'Credential invalid or expired'); + return structuredClone(principal); + } + function currentPrincipal(principal) { + if (fault) throw fault; + const current = principal?.id === 'owner' ? owner : keys.get(principal?.id); + if (!current || principal.digest !== current.digest || !active(current)) throw forbidden(); + return current; + } + function requireScope(principal, scope, botId) { + principal = currentPrincipal(principal); + if (!principal.scopes.includes(scope) || (botId && !principal.botIds.includes('*') && !principal.botIds.includes(botId))) throw forbidden(); + } + function requireOwner(principal) { if (currentPrincipal(principal).id !== 'owner') throw forbidden(); } + function managedKey(principal, id) { + requireScope(principal, 'keys.manage'); identifier(id); + if (id === 'owner') throw forbidden(); + const record = keys.get(id); + if (!record) throw new ApiError(404, 'not_found', 'Credential not found'); + if (principal.id !== 'owner' && !descendant(record, principal.id)) throw forbidden(); + return record; + } + function keyResponse(record, value) { + return { object: 'node.api_key', id: record.id, key: value, user_id: record.user_id, scopes: [...record.scopes], bot_ids: [...record.botIds], expires_at: Math.floor(record.expiresAt / 1000) }; + } + function userResponse(user) { + return { object: 'node.user', id: user.id, name: user.name, disabled: user.disabledAt !== undefined, ...(user.createdAt === undefined ? {} : { created_at: Math.floor(user.createdAt / 1000) }), ...(user.disabledAt === undefined ? {} : { disabled_at: Math.floor(user.disabledAt / 1000) }) }; + } + return { + keyFile, usersFile, authenticate, requireScope, + async createUser(principal, input) { + input = structuredClone(object(input, ['name'])); + return mutate('users', next => { + requireOwner(principal); + if (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 128 || /[\x00-\x1f\x7f]/.test(input.name)) throw badRequest('Invalid user name'); + const user = { id: 'nuser_' + randomBytes(12).toString('hex'), name: input.name.trim(), createdAt: clock() }; + next.set(user.id, user); return userResponse(user); + }); + }, + listUsers(principal) { requireOwner(principal); return [userResponse({ id: 'owner', name: 'Owner' }), ...[...users.values()].map(userResponse)]; }, + disableUser(principal, id) { + return mutate('users', next => { + requireOwner(principal); identifier(id); + if (id === 'owner') throw forbidden(); + const user = next.get(id); + if (!user) throw new ApiError(404, 'not_found', 'User not found'); + user.disabledAt ??= clock(); return userResponse(user); + }); + }, + async issue(principal, input) { + input = structuredClone(input); + return mutate('keys', next => { + requireScope(principal, 'keys.manage'); principal = currentPrincipal(principal); + const userId = input.user_id === undefined ? principal.user_id : identifier(input.user_id); + if (principal.id !== 'owner' && userId !== principal.user_id) throw forbidden(); + if (userId !== 'owner' && (!users.has(userId) || users.get(userId).disabledAt !== undefined)) throw badRequest('User must be active'); + const scopes = input.scopes, botIds = input.bot_ids, ttl = input.ttl_seconds ?? 3600; + if (!Array.isArray(scopes) || scopes.length === 0 || scopes.some(s => !SCOPES.includes(s) || !principal.scopes.includes(s)) || !Array.isArray(botIds) || botIds.length === 0 || !Number.isInteger(ttl) || ttl < 1 || ttl > 86400) throw badRequest('Invalid credential scope or lifetime'); + for (const id of botIds) { if (id !== '*') identifier(id); if (!principal.botIds.includes('*') && !principal.botIds.includes(id)) throw forbidden(); } + const value = secret(), id = 'nkey_' + randomBytes(12).toString('hex'); + const record = { id, user_id: userId, parentId: principal.id, digest: digest(value), scopes: [...new Set(scopes)], botIds: [...new Set(botIds)], expiresAt: Math.min(clock() + ttl * 1000, principal.expiresAt) }; + next.set(id, record); return keyResponse(record, value); + }); + }, + revoke(principal, id) { return mutate('keys', next => { managedKey(principal, id); next.get(id).revokedAt = clock(); return { id, revoked: true }; }); }, + rotateKey(principal, id) { + return mutate('keys', next => { + const previous = managedKey(principal, id); + if (!active(previous)) throw new ApiError(409, 'key_inactive', 'Only active credentials can be rotated'); + const value = secret(), replacement = { ...structuredClone(previous), id: 'nkey_' + randomBytes(12).toString('hex'), digest: digest(value) }; + next.get(id).revokedAt = clock(); next.set(replacement.id, replacement); + return keyResponse(replacement, value); + }); + }, + active, + async flush() { await saving; if (fault) throw fault; }, + }; +} diff --git a/tools/node-agent-api/cli.mjs b/tools/node-agent-api/cli.mjs new file mode 100644 index 0000000..38ada8b --- /dev/null +++ b/tools/node-agent-api/cli.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { startNodeAgentApi } from './server.mjs'; +import { createGrokNodeAdapter } from './grok-node.mjs'; +import { createCodexAdapter } from './codex.mjs'; +import { createHarness } from './runtime/harness.mjs'; +import { recoverServiceLocks } from './persistence.mjs'; +import { createSharedRuntime } from './shared-runtime.mjs'; + +const flags = process.argv.slice(2), options = {}; +for (let i = 0; i < flags.length; i += 2) { + const name = flags[i]; + if (!['--port','--state','--container','--allow-writes','--backend','--runtime-state','--namespace','--model','--base-url','--recover','--workspace'].includes(name) || flags[i + 1] === undefined || Object.hasOwn(options, name)) throw new Error('Usage: cli.mjs [--backend codex|grok] [--container NAME] [--workspace LINUX_PATH] [--port PORT] [--state DIRECTORY] [--recover true|false]'); + options[name] = flags[i + 1]; +} +const port = Number(options['--port'] ?? 18770); +if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port'); +if (options['--allow-writes'] !== undefined && !['true','false'].includes(options['--allow-writes'])) throw new Error('Invalid write policy'); +const root = path.resolve(import.meta.dirname, '../..'), stateDirectory = path.resolve(options['--state'] ?? path.join(root, '.lab/node-agent-shared')); +if (options['--recover'] === 'true') await recoverServiceLocks(stateDirectory); +const backend = options['--backend'] ?? 'codex'; +let adapter; +if (backend === 'grok') adapter = createGrokNodeAdapter({ container: options['--container'] ?? 'grok-node-local-vm', allowWrites: options['--allow-writes'] === 'true' }); +else if (backend === 'codex') { + const namespace = options['--namespace'] ?? 'grok-node-lab-shared', stateRoot = path.resolve(options['--runtime-state'] ?? path.join(root, '.lab/shared-runtime')); + const runtime = await createSharedRuntime({ container: options['--container'] ?? 'grok-node-local-vm', stateRoot, namespace, workspaceRoot: options['--workspace'] }); + const roster = { ...runtime.roster, + async agents() { return (await runtime.roster.agents()).map(row => ({ ...row, backend: 'codex_harness' })); }, + async createAgent(input) { const result = await runtime.roster.gateway('createAgent', { name: input.name, description: input.description ?? '', origin: 'user', isKickstartRequested: false, isIntroductionSuppressed: true }); const row = result.agent ?? result; if (typeof row.id !== 'string') throw new Error('GrokNode did not return a bot identifier'); return { object: 'node.agent', id: row.id, name: row.name, description: row.description ?? '', backend: 'codex_harness' }; }, + }; + const harness = createHarness({ runtime, namespace, stateRoot, model: options['--model'], baseUrl: options['--base-url'] }); + adapter = createCodexAdapter({ runtime, harness, roster, namespace, stateRoot, artifactRoot: path.join(stateDirectory, 'artifacts') }); +} else throw new Error('Unknown backend'); +const service = await startNodeAgentApi({ adapter, stateDirectory, port }); +console.log(JSON.stringify({ service: 'Node Agent API', version: '0.3.0', backend, origin: service.origin, ui: service.origin + '/ui/', owner_key_file: service.keyFile, writes_enabled: adapter.writesEnabled })); +async function stop() { await service.close(); process.exit(0); } +process.once('SIGTERM', () => { void stop(); }); process.once('SIGINT', () => { void stop(); }); diff --git a/tools/node-agent-api/client-cli.mjs b/tools/node-agent-api/client-cli.mjs new file mode 100644 index 0000000..38aeea3 --- /dev/null +++ b/tools/node-agent-api/client-cli.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { launchCodex } from './runtime/codex.mjs'; +import { readFile } from 'node:fs/promises'; +import { createSharedRuntime } from './shared-runtime.mjs'; + +const [command, botId, ...args] = process.argv.slice(2); +const allowed = new Set(['namespace', 'state-root', 'codex-bin', 'model', 'base-url', 'prompt', 'thread', 'api-origin', 'api-key-file', 'application', 'mode', 'session', 'container', 'workspace']); +let runtime; +try { + if (!['ensure', 'status', 'desktop', 'codex', 'resume', 'backup', 'recreate'].includes(command) || !botId) throw new Error('Usage: client-cli.mjs ensure|status|desktop|codex|resume|backup|recreate BOT_ID [--thread THREAD_ID] [--container NAME] [--workspace LINUX_PATH] [--state-root PATH] [--api-key-file FILE]'); + const options = {}; + for (let i = 0; i < args.length; i += 2) { + const key = args[i].slice(2); + if (!args[i].startsWith('--') || !allowed.has(key) || args[i + 1] === undefined || key in options) throw new Error('Invalid CLI option'); + options[key] = args[i + 1]; + } + const namespace = options.namespace ?? 'grok-node-lab-shared'; + const root = resolve(import.meta.dirname, '../..'), stateRoot = resolve(options['state-root'] ?? `${root}/.lab/shared-runtime`); + runtime = await createSharedRuntime({ namespace, stateRoot, container: options.container, workspaceRoot: options.workspace }); + const roster = runtime.roster; + await roster.requireAgent(botId); + if (command === 'codex' || command === 'resume') { + if (command === 'resume' && !/^[\w-]{1,128}$/.test(options.thread ?? '')) throw new Error('resume requires a valid --thread'); + process.exitCode = await launchCodex({ namespace, botId, stateRoot, runtime, codexBinary: options['codex-bin'], model: options.model, baseUrl: options['base-url'], threadId: command === 'resume' ? options.thread : undefined }, options.prompt); + } else if (command === 'desktop') { + const origin = options['api-origin'] ?? 'http://127.0.0.1:18770'; + if (new URL(origin).hostname !== '127.0.0.1') throw new Error('Local API origin required'); + const key = (await readFile(resolve(options['api-key-file'] ?? `${root}/.lab/node-agent-shared/owner.key`), 'utf8')).trim(); + async function post(route, body) { const r = await fetch(origin + route, { method: 'POST', headers: { authorization: 'Bearer ' + key, 'content-type': 'application/json' }, body: JSON.stringify(body) }); if (!r.ok) throw new Error('Authenticated desktop request failed'); return r.json(); } + const mode = options.mode ?? 'view'; + if (!['view', 'control'].includes(mode)) throw new Error('Desktop mode must be view or control'); + let session; + if (options.session) { + if (!/^[\w-]{1,128}$/.test(options.session)) throw new Error('Invalid session identifier'); + const response = await fetch(`${origin}/v1/agents/sessions/${options.session}`, { headers: { authorization: 'Bearer ' + key } }); + if (!response.ok) throw new Error('Authenticated session request failed'); + session = await response.json(); + if (session.agent_id !== botId) throw new Error('Session belongs to another bot'); + } else session = await post('/v1/agents/sessions', { agent_id: botId }); + const target = options.application ? { type: 'application', application_id: options.application } : { type: 'desktop' }; + const desktop = await post(`/v1/agents/sessions/${session.id}/desktop`, { mode, target }); + console.log(JSON.stringify({ botId, session_id: session.id, mode, target, desktopUrl: desktop.url, expires_at: desktop.expires_at })); + } else { + console.log(JSON.stringify(await runtime[command](botId))); + } +} catch (error) { + // Never print subprocess argv or token-bearing registry objects. + console.error(error?.cmd ? 'Codex command failed' : error.message); + process.exitCode = 1; +} finally { await runtime?.close?.(); } diff --git a/tools/node-agent-api/clipboard-x11.mjs b/tools/node-agent-api/clipboard-x11.mjs new file mode 100644 index 0000000..359ed6a --- /dev/null +++ b/tools/node-agent-api/clipboard-x11.mjs @@ -0,0 +1,54 @@ +// Standard Python ctypes + the image's libX11: no packages or GrokNode patches. +export const clipboardProgram = String.raw` +import ctypes as c, sys, time, select, json +L=c.CDLL('libX11.so.6'); U=c.c_ulong; P=c.c_void_p; I=c.c_int +def bind(name, args, result): + f=getattr(L,name); f.argtypes=args; f.restype=result; return f +openD=bind('XOpenDisplay',[c.c_char_p],P); atom=bind('XInternAtom',[P,c.c_char_p,I],U) +root=bind('XDefaultRootWindow',[P],U); create=bind('XCreateSimpleWindow',[P,U,I,I,c.c_uint,c.c_uint,c.c_uint,U,U],U) +flush=bind('XFlush',[P],I); pending=bind('XPending',[P],I); nextE=bind('XNextEvent',[P,P],I) +owner=bind('XSetSelectionOwner',[P,U,U,U],I); getOwner=bind('XGetSelectionOwner',[P,U],U) +change=bind('XChangeProperty',[P,U,U,U,I,I,P,I],I); send=bind('XSendEvent',[P,U,I,c.c_long,P],I) +convert=bind('XConvertSelection',[P,U,U,U,U,U],I) +getProp=bind('XGetWindowProperty',[P,U,U,c.c_long,c.c_long,I,U,c.POINTER(U),c.POINTER(I),c.POINTER(U),c.POINTER(U),c.POINTER(P)],I) +free=bind('XFree',[P],I) +class Request(c.Structure): + _fields_=[('type',I),('serial',U),('send_event',I),('display',P),('owner',U),('requestor',U),('selection',U),('target',U),('property',U),('time',U)] +class Notify(c.Structure): + _fields_=[('type',I),('serial',U),('send_event',I),('display',P),('requestor',U),('selection',U),('target',U),('property',U),('time',U)] +class Event(c.Union): + _fields_=[('type',I),('request',Request),('notify',Notify),('pad',c.c_long*24)] +d=openD(sys.argv[1].encode()); +if not d: raise RuntimeError('X display unavailable') +w=create(d,root(d),0,0,1,1,0,0,0); clip=atom(d,b'CLIPBOARD',0); utf=atom(d,b'UTF8_STRING',0); targets=atom(d,b'TARGETS',0); prop=atom(d,b'NODE_AGENT_CLIPBOARD',0) +event=Event() +if sys.argv[2]=='read': + if not getOwner(d,clip): print(json.dumps({'text':''})); sys.exit(0) + convert(d,clip,utf,prop,w,0); flush(d); end=time.monotonic()+5 + while time.monotonic() (runtime.shared || row.agent_id === session.agent_id) && row.task_status === 'running')) throw new ApiError(409, 'turn_active', 'Interrupt active work before changing the shared project or environment'); } + function enqueue(action) { tail = tail.then(action); return tail; } + async function syncRegistry() { + if (!registryFile) return; + const bots = await roster.agents(), environments = []; + for (const bot of bots) if ((await runtime.status(bot.id)).running) environments.push(...(await runtime.descriptor(bot.id)).environments); + await writeFile(registryFile, JSON.stringify({ version: 1, namespace, environments }), { mode: 0o600 }); + } + const adapter = { + backend: 'codex_harness', writesEnabled: true, independentSessions: true, + isolation: runtime.shared ? 'shared_container_separate_display' : 'per_bot_container', rawDesktopProtected: !runtime.shared, externalAdapter: !!runtime.shared, + diagnostic: error => harness.redact(error?.message ?? 'Unknown Harness failure'), + agents: () => roster.agents(), requireAgent: id => roster.requireAgent(id), createAgent: input => roster.createAgent(input), + attachStore(value) { + store = value; + for (const session of store.list()) if (session.task_status === 'running') { + for (const turn of session.turns ?? []) if (turn.status === 'running') turn.status = 'unknown'; + for (const action of session.actions ?? []) if (action.status === 'pending') action.status = 'unknown'; + void enqueue(async () => { await store.update(session.id, { turns: session.turns, actions: session.actions, task_status: 'unknown', active_turn_id: null }); await store.append(session.id, 'node.session.turn.unknown', { reason: 'service_restart', replayed: false }); }); + } + unsubscribe = harness.subscribe(event => { void enqueue(async () => { + const message = event.message, params = message.params ?? {}; + const matching = store.list().filter(session => session.agent_id === event.botId && (session.thread_id === params.threadId || message.method === 'lab/status')); + for (const session of matching) { + const turns = structuredClone(session.turns ?? []), actions = structuredClone(session.actions ?? []); + if (message.method === 'lab/status') { + liveThreads.delete(session.thread_id); + const wasRunning = session.task_status === 'running'; + for (const turn of turns) if (turn.status === 'running') { turn.status = 'unknown'; turn.ended_at = Date.now(); } + for (const action of actions) if (action.status === 'pending') action.status = 'unknown'; + await store.update(session.id, { turns, actions, ...(wasRunning ? { task_status: 'unknown', active_turn_id: null } : {}) }); + await store.append(session.id, wasRunning ? 'node.session.turn.unknown' : 'node.harness.disconnected', { reason: 'harness_disconnected', replayed: false }); + continue; + } + if (params.turn?.id && ['turn/started', 'turn/completed'].includes(message.method)) { + let turn = turns.find(turn => turn.id === params.turn.id); + if (!turn) { turn = { id: params.turn.id, object: 'node.turn', started_at: Date.now(), items: [], status: 'running', usage: null }; turns.push(turn); } + let status = outcomes[params.turn.status] ?? 'unknown'; + if (status === 'interrupted' && session.cancel_requested === turn.id) status = 'cancelled'; + turn.status = status; turn.error = params.turn.error ?? null; + if (message.method === 'turn/completed') turn.ended_at = Date.now(); + await store.update(session.id, { turns, task_status: status, active_turn_id: status === 'running' ? turn.id : null }); + } + if (message.method === 'thread/tokenUsage/updated') { + const turn = turns.find(turn => turn.id === params.turnId); + if (turn) { turn.usage = { native: params.tokenUsage, cost: null, source: 'thread/tokenUsage/updated' }; await store.update(session.id, { turns }); } + } + if (message.id != null && ['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/tool/call'].includes(message.method)) { + actions.push({ object: 'node.required_action', id: 'act_' + randomUUID(), request_id: message.id, thread_id: params.threadId, turn_id: params.turnId, item_id: params.itemId, type: message.method, status: 'pending', details: params, created_at: Date.now() }); + await store.update(session.id, { actions }); + } + if (params.item?.id) { + const turn = turns.find(turn => turn.id === params.turnId); + if (turn) { const index = turn.items.findIndex(item => item.id === params.item.id); const item = { ...params.item, turn_id: params.turnId }; if (index < 0) turn.items.push(item); else turn.items[index] = item; await store.update(session.id, { turns }); } + } + await store.append(session.id, 'node.harness.event', { method: message.method, params }); + } + }).catch(() => { /* Persistence faults keep the tail rejected: subsequent operations fail closed. */ }); }); + }, + async bindSession(session) { + await roster.requireAgent(session.agent_id); + await runtime.ensure(session.agent_id); await syncRegistry(); + const result = await harness.request(session.agent_id, 'thread/start', {}); + if (!result.thread?.id) throw new ApiError(502, 'harness_schema', 'Harness did not return a thread'); + await store.update(session.id, { thread_id: result.thread.id, context: 'codex_harness', task_status: 'idle', turns: [], actions: [], ...(runtime.shared ? { executor_container_id: (await runtime.status(session.agent_id)).containerId } : {}) }); + liveThreads.add(result.thread.id); + }, + async resume(session) { + if (!session.thread_id) throw new ApiError(409, 'thread_missing', 'Session has no Harness thread'); + if (runtime.shared) { + await runtime.ensure(session.agent_id); + const current = await runtime.status(session.agent_id); + if (session.executor_container_id && session.executor_container_id !== current.containerId) { await harness.disconnect(session.agent_id); liveThreads.delete(session.thread_id); } + await store.update(session.id, { executor_container_id: current.containerId }); + } + if (liveThreads.has(session.thread_id)) return; + await runtime.ensure(session.agent_id); + try { await harness.request(session.agent_id, 'thread/resume', { threadId: session.thread_id }); } + catch (error) { + if (session.turns?.length || !error.message.includes('no rollout found')) throw error; + const empty = await harness.request(session.agent_id, 'thread/start', {}); + if (!empty.thread?.id) throw new ApiError(502, 'harness_schema', 'Harness did not return an empty thread'); + await store.update(session.id, { thread_id: empty.thread.id }); + liveThreads.add(empty.thread.id); + await store.append(session.id, 'node.thread.empty_recreated', { replayed: false }); return; + } + liveThreads.add(session.thread_id); + const restored = await harness.request(session.agent_id, 'thread/read', { threadId: session.thread_id }); + if (Array.isArray(restored.thread?.turns)) { + const turns = restored.thread.turns.map(turn => ({ object: 'node.turn', id: turn.id, status: turn.status === 'interrupted' && session.cancel_requested === turn.id ? 'cancelled' : outcomes[turn.status] ?? 'unknown', items: (turn.items ?? []).map(item => ({ ...item, turn_id: turn.id })), usage: session.turns?.find(old => old.id === turn.id)?.usage ?? null, request_id: session.turns?.find(old => old.id === turn.id)?.request_id ?? null })); + await store.update(session.id, { turns, task_status: turns.at(-1)?.status ?? 'idle' }); + } + }, + async inputSession(session, text, requestId) { + await tail; + if (session.task_status === 'running') throw new ApiError(409, 'turn_active', 'Wait for or cancel the active turn before submitting another'); + await this.resume(session); + const result = await harness.request(session.agent_id, 'turn/start', { threadId: session.thread_id, input: [{ type: 'text', text }] }); + if (!result.turn?.id) throw new ApiError(502, 'harness_schema', 'Harness did not return a turn'); + await enqueue(async () => { + const turns = structuredClone(session.turns); + let turn = turns.find(turn => turn.id === result.turn.id); + if (!turn) { turn = { object: 'node.turn', id: result.turn.id, status: outcomes[result.turn.status] ?? 'unknown', started_at: Date.now(), items: [], usage: null }; turns.push(turn); } + turn.request_id = requestId; + await store.update(session.id, { turns, active_turn_id: turn.status === 'running' ? turn.id : null, task_status: turn.status }); + }); + return { accepted: true, turn_id: result.turn.id }; + }, + async itemsSession(session) { await tail; return (session.turns ?? []).flatMap(turn => turn.items); }, + async cancel(session, turnId) { + await tail; identifier(turnId); + if (session.active_turn_id !== turnId || session.task_status !== 'running') throw new ApiError(409, 'turn_not_active', 'Turn does not belong to the active session'); + await store.update(session.id, { cancel_requested: turnId }); + const processes = [...new Set((session.turns?.find(turn => turn.id === turnId)?.items ?? []).filter(item => item.type === 'commandExecution' && item.status === 'inProgress' && /^[0-9]{1,10}$/.test(item.processId ?? '')).map(item => item.processId))]; + await harness.request(session.agent_id, 'turn/interrupt', { threadId: session.thread_id, turnId }); + const stopped = []; + for (const processId of processes) { + const result = await harness.request(session.agent_id, 'thread/backgroundTerminals/terminate', { threadId: session.thread_id, processId }); + stopped.push({ process_id: processId, terminated: result.terminated === true }); + } + if (stopped.length) await store.append(session.id, 'node.turn.processes.stopped', { turn_id: turnId, processes: stopped }); + return { accepted: true, turn_id: turnId, completed: false }; + }, + async handoff(session) { requireIdleBot(session); await harness.request(session.agent_id, 'thread/unsubscribe', { threadId: session.thread_id }); if (runtime.shared) await harness.disconnect(session.agent_id); liveThreads.delete(session.thread_id); return { thread_id: session.thread_id, bot_id: session.agent_id, target: 'codex_cli', replayed: false }; }, + async actions(session) { await tail; return (session.actions ?? []).filter(action => action.status === 'pending'); }, + async answer(session, actionId, decision) { + await tail; const actions = structuredClone(session.actions ?? []), action = actions.find(action => action.id === actionId && action.status === 'pending'); + if (!action || !['accept', 'decline'].includes(decision)) throw new ApiError(409, 'action_unavailable', 'Approval is not pending or decision is invalid'); + if (busyActions.has(actionId)) throw new ApiError(409, 'action_busy', 'Approval is already being answered'); + busyActions.add(actionId); + try { + action.status = 'responding'; await store.update(session.id, { actions }); + if (action.type === 'item/tool/call') { + if (action.details.tool !== 'node_approved_shell') throw new ApiError(409, 'tool_unavailable', 'Tool is not implemented'); + let result = { denied: true, executed: false }; + if (decision === 'accept') result = await runtime.approvedShell(session.agent_id, action.details.arguments?.command); + result = harness.redact(result); + await harness.answer(session.agent_id, action.request_id, { contentItems: [{ type: 'inputText', text: JSON.stringify(result) }], success: decision === 'accept' && result.exit_code === 0 }); + } else await harness.answer(session.agent_id, action.request_id, { decision }); + action.status = decision; await store.update(session.id, { actions }); + await store.append(session.id, 'node.action.resolved', { action_id: actionId, decision }); + return { answered: true }; + } finally { busyActions.delete(actionId); } + }, + async applications(session) { return runtime.applications(session.agent_id); }, + async launchApplication(session, kind) { if (!runtime.launchApplication) throw new ApiError(409, 'unsupported', 'Application launch is unavailable on this backend'); return runtime.launchApplication(session.agent_id, kind); }, + async desktop(id, target, mode = 'view') { + if (runtime.shared) return runtime.desktop(id, target, mode); + await roster.requireAgent(id); const status = await runtime.status(id); + if (!status.running) throw new ApiError(409, 'desktop_unavailable', 'Bot environment is not running'); + const descriptor = await runtime.descriptor(id); + const authToken = descriptor.environments[0].authToken; + target = target ?? { type: 'desktop' }; + if (!['view', 'control'].includes(mode)) throw new ApiError(400, 'desktop_mode_invalid', 'Invalid desktop mode'); + const port = mode === 'view' ? status.desktopReadOnlyPort : status.desktopInteractivePort; + let path = '/desktop', applicationId = null; + if (target.type === 'application') { + applicationId = normalizeApplicationId(target.application_id); + if (!applicationId) throw new ApiError(400, 'application_invalid', 'Invalid application id'); + const windows = await runtime.applications(id); + if (!windows.some(app => app.application_id === applicationId)) throw new ApiError(404, 'application_not_found', 'Application window is no longer available'); + path = `/application/${applicationId}`; + } else if (target.type !== 'desktop') { + throw new ApiError(400, 'desktop_target_invalid', 'Invalid desktop target'); + } + return { object: 'node.environment', id: 'nenv_' + id, agent_id: id, type: 'grok_node_codex', target: { type: target.type, application_id: applicationId }, isolation: 'per_bot_container', container: status.containerName, containerId: status.containerId, containerGenerationImmutableId: status.containerId, applicationId, display: 1, websocketUrl: `ws://127.0.0.1:${port}${path}`, websocketHeaders: { authorization: 'Bearer ' + authToken } }; + }, + async viewerAsset(asset, botId) { + if (!/^(vnc\.html|package\.json|(?:app|core|vendor)\/[A-Za-z0-9_./-]+)$/.test(asset) || asset.split('/').includes('..')) throw new ApiError(404, 'not_found', 'Asset unavailable'); + const status = await runtime.status(botId); + if (!status.running) throw new ApiError(409, 'desktop_unavailable', 'Bot environment unavailable'); + return (await execute('docker', ['exec', status.containerName, 'cat', '/usr/share/novnc/' + asset], { encoding: null, maxBuffer: 4 * 1024 * 1024 })).stdout; + }, + async clipboard(session, text) { if (text === undefined) return { text: await runtime.clipboard.read(session.agent_id) }; await runtime.clipboard.write(session.agent_id, text); return { written: true }; }, + async project(session, action, input) { if (action === 'import') requireIdleBot(session); return runtime.project(session.agent_id, action, input); }, + async exportProject(session, reserve = async () => {}) { + requireIdleBot(session); + await mkdir(artifactRoot, { recursive: true, mode: 0o700 }); + const id = 'art_' + randomUUID(), file = path.join(artifactRoot, id + '.tgz'); + const inventory = await runtime.project(session.agent_id, 'inventory'); + await reserve(id, inventory.bytes + 1024 * 1024); + const result = await runtime.exportProject(session.agent_id, file); + await reserve(id, result.size); + const artifact = { id, name: 'project.tgz', size: result.size, sha256: createHash('sha256').update(await readFile(file)).digest('hex'), file }; + await store.update(session.id, { artifacts: [...(session.artifacts ?? []), artifact] }); + const { file: privatePath, ...publicArtifact } = artifact; + return { artifact: { ...publicArtifact, download_url: `/v1/agents/sessions/${session.id}/artifacts/${id}/content` } }; + }, + async recreate(session) { + requireIdleBot(session); + await harness.disconnect(session.agent_id); liveThreads.delete(session.thread_id); + const result = await runtime.recreate(session.agent_id); await syncRegistry(); + await store.append(session.id, 'node.environment.recreated', { processes_restored: false }); + return { running: result.running, preserved_files: true, processes_restored: false }; + }, + async restore(session, input) { + requireIdleBot(session); + identifier(input.backup_id); + const n = resourceNames(namespace, session.agent_id), backup = path.join(stateRoot, namespace, n.key, 'backups', input.backup_id); + await harness.disconnect(session.agent_id); liveThreads.delete(session.thread_id); + await runtime.ensure(session.agent_id); await runtime.restore(session.agent_id, backup); + await store.append(session.id, 'node.environment.restored', { backup_id: input.backup_id, processes_restored: false }); + return { restored: true, processes_restored: false }; + }, + async artifact(session, id) { const artifact = session.artifacts?.find(a => a.id === id); if (!artifact || !path.resolve(artifact.file).startsWith(path.resolve(artifactRoot) + path.sep) || !(await lstat(artifact.file)).isFile()) throw new ApiError(404, 'not_found', 'Artifact not found'); return { bytes: await readFile(artifact.file), name: artifact.name }; }, + async backup(session) { + requireIdleBot(session); + const result = await runtime.backup(session.agent_id), home = await harness.home(session.agent_id); + const archive = path.join(result.path, 'mac-session.tgz'); + await execute('tar', ['-czf', archive, '-C', home, '.'], { timeout: 60000 }); + await writeFile(path.join(result.path, 'session.json'), JSON.stringify(session), { mode: 0o600 }); + return { backup_id: path.basename(result.path), preserved: ['source', 'uncommitted_changes', 'conversation', 'logs'], processes_restored: false }; + }, + async flush() { await tail; }, + async close() { unsubscribe?.(); await harness.close(); await tail; await runtime.close?.(); }, + }; + return adapter; +} diff --git a/tools/node-agent-api/desktop.mjs b/tools/node-agent-api/desktop.mjs new file mode 100644 index 0000000..3cd11ff --- /dev/null +++ b/tools/node-agent-api/desktop.mjs @@ -0,0 +1,265 @@ +import { randomBytes } from 'node:crypto'; +import { appendFile } from 'node:fs/promises'; +import path from 'node:path'; +import { WebSocket, WebSocketServer } from 'ws'; +import { ApiError, badRequest, forbidden } from './errors.mjs'; +import { createRfbFilter } from './rfb-filter.mjs'; +import { normalizeApplicationId } from './runtime/applications.mjs'; + +const nonce = () => randomBytes(32).toString('base64url'); +const STYLE = ''; + +function normalizeTarget(value = { type: 'desktop' }) { + if (!value || Array.isArray(value) || typeof value !== 'object') throw badRequest('Invalid desktop target'); + if (value.type === 'desktop') { + if (Object.keys(value).some(key => key !== 'type')) throw badRequest('Invalid desktop target'); + return { type: 'desktop', applicationId: null }; + } + if (value.type !== 'application') throw badRequest('Invalid desktop target'); + const keys = Object.keys(value).sort(); + if (keys.length !== 2 || !keys.includes('type') || !keys.includes('application_id')) throw badRequest('Invalid desktop target'); + const applicationId = normalizeApplicationId(value.application_id); + if (!applicationId) throw badRequest('Invalid application id'); + return { type: 'application', applicationId }; +} + +function sameEnvironment(left, right) { + return left?.display === right?.display + && left?.websocketUrl === right?.websocketUrl + && left?.containerGenerationImmutableId === right?.containerGenerationImmutableId + && (left?.applicationId ?? null) === (right?.applicationId ?? null); +} + +export function createDesktop({ adapter, auth, store, governance, stateDirectory, clock = Date.now }) { + const tickets = new Map(), viewers = new Map(), leases = new Map(), connections = new Set(); + const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 }); + + function diagnosticReason(error) { + const raw = error?.code ?? error?.status ?? error?.name; + return typeof raw === 'string' && /^[A-Za-z0-9_.-]{1,50}$/.test(raw) ? raw.toLowerCase() : 'transport_error'; + } + async function logDiagnostic(stage, error) { + if (!stateDirectory) return; + try { + await appendFile(path.join(stateDirectory, 'diagnostics.log'), JSON.stringify({ at: clock(), stage, reason: diagnosticReason(error) }) + '\n', { mode: 0o600 }); + } catch { /* Diagnostics must never break the desktop path. */ } + } + + function disconnect(viewer) { + if (leases.get(viewer.botId)?.id === viewer.id) leases.delete(viewer.botId); + for (const pair of [...connections]) if (pair.viewer === viewer) { + connections.delete(pair); + pair.client.terminate(); + pair.upstream.terminate(); + } + } + + function viewerCookies(req) { + const values = []; + for (const part of String(req.headers.cookie ?? '').split(';')) { + const [name, ...rest] = part.trim().split('='); + if (name !== 'nodeviewer') continue; + const value = rest.join('='); + if (/^[A-Za-z0-9_-]{20,256}$/.test(value)) values.push(value); + } + return values; + } + + function selectViewer(req, pathname) { + const lane = /^\/desktop\/lane\/([A-Za-z0-9_-]+)(?:\/|$)/.exec(pathname); + const ids = viewerCookies(req); + let id; + if (lane) id = ids.find(value => value === lane[1]); + else id = ids.find(value => viewers.get(value)?.target.type === 'desktop'); + const viewer = viewers.get(id); + if (!viewer || viewer.expiresAt <= clock() || !auth.active(viewer.principal)) { + if (viewer) { disconnect(viewer); viewers.delete(viewer.id); } + throw new ApiError(401, 'viewer_expired', 'Desktop authorization expired'); + } + if (lane && (viewer.target.type !== 'application' || viewer.id !== lane[1])) throw forbidden(); + if (!lane && viewer.target.type !== 'desktop') throw forbidden(); + return viewer; + } + + function controls(viewer) { + return viewer?.mode === 'control' + && viewer.expiresAt > clock() + && auth.active(viewer.principal) + && leases.get(viewer.botId)?.id === viewer.id; + } + + function socketPath(viewer) { + return viewer.target.type === 'application' + ? `desktop/lane/${viewer.id}/socket` + : 'desktop/socket'; + } + + return { + async issue(principal, session, input, origin) { + const mode = input.mode ?? 'view', ttl = input.ttl_seconds ?? 60; + if (!['view', 'control'].includes(mode) || !Number.isInteger(ttl) || ttl < 1 || ttl > 300) throw badRequest('Invalid desktop mode or ticket lifetime'); + auth.requireScope(principal, mode === 'control' ? 'desktop.control' : 'desktop.view', session.agent_id); + const target = normalizeTarget(input.target); + if (mode === 'control') { + const held = leases.get(session.agent_id); + if (held && controls(held)) throw new ApiError(409, 'desktop_busy', 'A controller already holds this desktop'); + } + const environment = await adapter.desktop(session.agent_id, { + type: target.type, + ...(target.applicationId ? { application_id: target.applicationId } : {}), + }, mode); + if (!environment?.websocketUrl || (environment.applicationId ?? null) !== target.applicationId) throw new ApiError(502, 'desktop_upstream', 'Desktop upstream returned an invalid lane'); + for (const [key, ticket] of tickets) if (ticket.expiresAt <= clock()) tickets.delete(key); + if (tickets.size >= 1000) throw new ApiError(429, 'desktop_capacity', 'Desktop ticket capacity reached'); + const expiresAt = clock() + ttl * 1000, ticket = nonce(); + tickets.set(ticket, { principal, botId: session.agent_id, sessionId: session.id, target, environment, mode, expiresAt }); + await store.append(session.id, 'node.desktop.authorization.created', { mode, target_type: target.type, expires_at: Math.floor(expiresAt / 1000) }); + return { object: 'node.desktop.authorization', agent_id: session.agent_id, mode, display: environment.display, url: `${origin}/desktop/open?ticket=${ticket}`, expires_at: Math.floor(expiresAt / 1000), single_use: true, server_enforced: true, pauses_agent: false }; + }, + + async handle(req, res, url) { + if (url.pathname === '/desktop/open' && req.method === 'GET') { + const id = url.searchParams.get('ticket'), ticket = tickets.get(id); + tickets.delete(id); + if (!ticket || ticket.expiresAt <= clock() || !auth.active(ticket.principal)) throw new ApiError(401, 'ticket_invalid', 'Desktop ticket expired, invalid or already used'); + const current = await adapter.desktop(ticket.botId, { + type: ticket.target.type, + ...(ticket.target.applicationId ? { application_id: ticket.target.applicationId } : {}), + }, ticket.mode); + if (!sameEnvironment(ticket.environment, current)) throw new ApiError(409, 'desktop_changed', 'Bot desktop was reassigned; request a new authorization'); + const lease = leases.get(ticket.botId); + if (ticket.mode === 'control' && lease && controls(lease)) throw new ApiError(409, 'desktop_busy', 'A controller already holds this desktop'); + const viewer = { ...ticket, environment: current, id: nonce(), expiresAt: clock() + 15 * 60 * 1000 }; + viewers.set(viewer.id, viewer); + if (viewer.mode === 'control') leases.set(viewer.botId, viewer); + if (viewer.target.type === 'application') { + const location = `/desktop/lane/${viewer.id}/vnc.html`; + res.writeHead(303, { + 'Set-Cookie': `nodeviewer=${viewer.id}; HttpOnly; SameSite=Strict; Path=/desktop/lane/${viewer.id}; Max-Age=900`, + Location: location, + }); + } else { + res.writeHead(303, { + 'Set-Cookie': `nodeviewer=${viewer.id}; HttpOnly; SameSite=Strict; Path=/desktop; Max-Age=900`, + Location: '/desktop/vnc.html', + }); + } + res.end(); return; + } + + const viewer = selectViewer(req, url.pathname); + if (req.method !== 'GET') throw forbidden(); + + const lane = /^\/desktop\/lane\/[A-Za-z0-9_-]+(?:\/(.*))?$/.exec(url.pathname); + const asset = lane ? (lane[1] || 'vnc.html') : url.pathname.slice('/desktop/'.length); + if (asset === 'mandatory.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + autoconnect: true, resize: 'scale', reconnect: false, + view_only: !controls(viewer), host: url.hostname, port: url.port, + encrypt: url.protocol === 'https:', path: socketPath(viewer), + })); + return; + } + if (asset === 'defaults.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{}'); return; } + + const content = await adapter.viewerAsset(asset, viewer.botId); + const extension = asset.split('.').at(-1); + const type = { html: 'text/html; charset=utf-8', js: 'text/javascript', css: 'text/css', png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon', json: 'application/json' }[extension]; + if (!type) throw new ApiError(404, 'not_found', 'Asset type unavailable'); + res.writeHead(200, { 'Content-Type': type }); + res.end(asset === 'vnc.html' ? Buffer.from(content).toString().replace('', STYLE + '') : content); + }, + + async upgrade(req, socket, head, origin) { + let viewer, release = () => {}; + try { + const requestUrl = new URL(req.url, origin); + if (req.headers.host !== new URL(origin).host || req.headers.origin !== origin) throw forbidden(); + const laneSocket = /^\/desktop\/lane\/[A-Za-z0-9_-]+\/socket$/; + if (requestUrl.pathname !== '/desktop/socket' && !laneSocket.test(requestUrl.pathname)) throw forbidden(); + viewer = selectViewer(req, requestUrl.pathname); + const current = await adapter.desktop(viewer.botId, { + type: viewer.target.type, + ...(viewer.target.applicationId ? { application_id: viewer.target.applicationId } : {}), + }, viewer.mode); + if (!sameEnvironment(viewer.environment, current)) throw new ApiError(409, 'desktop_changed', 'Desktop reassigned'); + viewer.environment = current; + if (governance) release = await governance.acquireStream({ id: viewer.principal.user_id ?? viewer.principal.id }); + wss.handleUpgrade(req, socket, head, client => { + const upstream = new WebSocket(current.websocketUrl, ['binary'], { maxPayload: 4 * 1024 * 1024, headers: current.websocketHeaders ?? {} }); + const pair = { client, upstream, viewer }; + connections.add(pair); + const filter = createRfbFilter(() => controls(viewer)); + let waiting = [], waitingBytes = 0, closed = false; + const close = reason => { + if (closed) return; + closed = true; + release(); + connections.delete(pair); + if (leases.get(viewer.botId)?.id === viewer.id) leases.delete(viewer.botId); + client.terminate(); + upstream.terminate(); + if (reason && reason !== 'client_closed') void logDiagnostic('upstream-transport', reason); + }; + const ttlMs = Math.max(1, Math.min(viewer.expiresAt, viewer.principal.expiresAt) - clock()); + const timer = setTimeout(() => close(new ApiError(408, 'viewer_expired', 'Viewer expired')), ttlMs); timer.unref(); + upstream.on('open', () => { for (const message of waiting) upstream.send(message); waiting = []; waitingBytes = 0; }); + client.on('message', bytes => { + try { + selectViewer(req, requestUrl.pathname); + for (const message of filter(bytes)) { + if (upstream.readyState === WebSocket.OPEN && upstream.bufferedAmount < 1024 * 1024) upstream.send(message); + else if (upstream.readyState === WebSocket.CONNECTING && (waitingBytes += message.length) < 1024 * 1024) waiting.push(message); + else close(new ApiError(502, 'transport_error', 'Desktop transport unavailable')); + } + } catch (error) { close(error); } + }); + upstream.on('message', (bytes, binary) => { + if (auth.active(viewer.principal) && viewer.expiresAt > clock() && client.readyState === WebSocket.OPEN && client.bufferedAmount < 4 * 1024 * 1024) client.send(bytes, { binary }); + else close(new ApiError(408, 'viewer_expired', 'Viewer expired')); + }); + client.on('close', () => { clearTimeout(timer); close('client_closed'); }); + upstream.on('close', () => { clearTimeout(timer); close(new ApiError(502, 'transport_closed', 'Desktop transport closed')); }); + client.on('error', error => { clearTimeout(timer); close(error); }); + upstream.on('error', error => { clearTimeout(timer); close(error); }); + }); + } catch (error) { + release(); + if (error instanceof ApiError && error.status !== 401 && error.status !== 403) await logDiagnostic('desktop-upgrade', error); + const status = error instanceof ApiError ? error.status : 500; + const reason = status === 409 ? 'Conflict' : status === 401 ? 'Unauthorized' : status === 403 ? 'Forbidden' : 'Internal Server Error'; + socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`); + } + }, + + revokeKey(id) { + for (const [key, viewer] of [...viewers]) { + if (viewer.principal.id === id || !auth.active(viewer.principal)) { + disconnect(viewer); viewers.delete(key); + } + } + }, + hasControl(principal, session) { + const lease = leases.get(session.agent_id); + return !!lease && lease.principal.id === principal.id && controls(lease); + }, + closeSession(id) { + const botId = store.get(id)?.agent_id; + if (!botId) return; + for (const [ticket, value] of [...tickets]) if (value.botId === botId) tickets.delete(ticket); + for (const [key, viewer] of [...viewers]) if (viewer.botId === botId) { disconnect(viewer); viewers.delete(key); } + }, + handback(principal, session) { + auth.requireScope(principal, 'desktop.control', session.agent_id); + const lease = leases.get(session.agent_id); + if (lease && principal.id !== 'owner' && lease.principal.id !== principal.id) throw forbidden(); + if (lease) { disconnect(lease); viewers.delete(lease.id); } + return { released: true }; + }, + close() { + for (const viewer of [...viewers.values()]) disconnect(viewer); + tickets.clear(); viewers.clear(); wss.close(); + }, + }; +} diff --git a/tools/node-agent-api/errors.mjs b/tools/node-agent-api/errors.mjs new file mode 100644 index 0000000..bb8e687 --- /dev/null +++ b/tools/node-agent-api/errors.mjs @@ -0,0 +1,13 @@ +export class ApiError extends Error { + constructor(status, code, message) { super(message); this.status = status; this.code = code; } +} +export const badRequest = message => new ApiError(400, 'invalid_request', message); +export const forbidden = () => new ApiError(403, 'permission_denied', 'The credential does not allow this operation'); +export function identifier(value) { + if (typeof value !== 'string' || !/^[\p{L}\p{N}][\p{L}\p{N}_-]{0,127}$/u.test(value)) throw badRequest('Invalid identifier'); + return value; +} +export function object(value, keys) { + if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !keys.includes(key))) throw badRequest('Unexpected request fields'); + return value; +} diff --git a/tools/node-agent-api/gap-analysis.md b/tools/node-agent-api/gap-analysis.md new file mode 100644 index 0000000..b0a1248 --- /dev/null +++ b/tools/node-agent-api/gap-analysis.md @@ -0,0 +1,30 @@ +# Node Agent API 0.3.0 差距与验收 + +更新:2026-10-06。当前需求为原始 GrokNode 的“不同 bot 桌面、共享容器与数据”,Node Agent API 不修改原程序。早期双容器隔离目标已经取消,旧报告只作历史原型证据。 + +## 当前实现 + +| 能力 | 当前行为 | 证据/边界 | +| --- | --- | --- | +| Mac Harness + Linux 执行 | 原生 Codex app-server 在 Mac,Docker stdio 执行端附加到现有 grok-node-local-vm | shared-harness-verification.json;UID 1000、无能力边界、no-new-privileges;不复制 Mac auth | +| 共享运行时 | 原接口管理 bot 和桌面,A/B 共用容器/项目、桌面不同 | shared-project-verification.json;编号动态查询,不硬编码 | +| 独立对话 | Mac 端独立 thread、原生 turn/items/events | 共享数据不表示共用一个对话 thread | +| 项目链路 | 对话→改代码/删除→安装依赖→测试→diff→导出 | shared-project-verification.json,真实原生工具与测试结果 | +| CLI 续接 | 网页→CLI→网页使用同一 thread | shared-cli-verification.json;不重放前一任务 | +| 审批与取消 | 原生待审批持久保留;拒绝不执行;取消定向停止当前回合记录的进程 | shared-lifecycle-verification.json;不停止共享盒子或其他 thread | +| fail-closed | 原生环境管理器拒绝缺失执行端,include_local=false | shared-lifecycle-verification.json;没有 Mac 工具回落 | +| 项目与会话恢复 | 外部项目/Git/profile/session 备份,恢复前保全当前项目 | shared-project-verification.json;覆盖归档条目,不删除额外新文件,不恢复进程内存 | +| 整桌面/单应用 | 短期 ticket、独立 cookie、服务端观看过滤、单 bot 控制租约 | 实际 GUI 验收以 shared-desktop-verification.json 为准;缺失文件即尚未完成 | +| 多用户与运维 | scoped keys、轮换/撤销、配额、审计、持久签名 webhook | service-load-verification.json;41 项实际服务/独立配额 fixture 检查 | +| 契约 | README 与 OpenAPI 描述实际端点、后端和限制 | tests/node-agent-api.test.mjs 逐路由校验响应;最终计数以最新测试报告为准 | + +## 保留的限制 + +- 原始 GrokNode 的 VNC 端口保留原访问策略;API 只保护自身 REST/viewer 入口。 +- 所有 bot 共用 Linux 数据。API bot grants 控制接口,不是文件系统安全隔离。 +- 不提供 OpenAI SDK 互换、canonical OpenAI items、OTLP 导出或泛用工具结果协议;trace 为本地 JSON,未知成本为 null。 +- 原 GrokNode root 进程、既有挂载、登录和软件保持原样。新 Codex 执行进程的权限边界不能解释为已改造原程序的整体安全模型。 +- 项目备份不暂停原 GrokNode 任务;共享项目的并发写入由用户协调。盒子生命周期仍交给 GrokNode,API recreate 在共享后端明确拒绝。 +- 旧实验副本源码、未提交修改、对话和备份不删除、不部署到原程序。当前服务不依赖旧的每 bot 容器或实验 host 注册表。 + +本项目参考 agents/sessions/events 的公开概念,验收按上述本地功能与约束,不把相似路径描述为 OpenAI Agents API 等价替代。 diff --git a/tools/node-agent-api/governance.mjs b/tools/node-agent-api/governance.mjs new file mode 100644 index 0000000..d597fa2 --- /dev/null +++ b/tools/node-agent-api/governance.mjs @@ -0,0 +1,147 @@ +import { createHash } from 'node:crypto'; +import { chmod, lstat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { privateDirectory } from './runtime/common.mjs'; +import { ApiError, badRequest, identifier } from './errors.mjs'; +import { acquireStateLock, atomicJson, readPrivateJson } from './persistence.mjs'; + +const hash = value => createHash('sha256').update(identifier(value)).digest('hex'); +const natural = value => Number.isSafeInteger(value) && value >= 0; +const defaults = { requests: 120, windowMs: 60000, streams: 4, storageBytes: 64 * 1024 * 1024 }; +const actions = ['request', 'stream', 'storage', 'authorization', 'webhook', 'key_rotation']; +const outcomes = ['allowed', 'denied', 'succeeded', 'failed']; + +/** Deliberately never copies arbitrary fields, headers, error messages or request bodies. */ +export function sanitizeAuditRecord(input, now = Date.now()) { + if (!input || !actions.includes(input.action) || !outcomes.includes(input.outcome) || !natural(now)) throw badRequest('Invalid audit record'); + const record = { at: now, principal: hash(input.principal?.id), action: input.action, outcome: input.outcome }; + for (const field of ['resourceId', 'requestId']) if (input[field] !== undefined) record[field] = hash(input[field]); + if (input.status !== undefined) { + if (!Number.isInteger(input.status) || input.status < 100 || input.status > 599) throw badRequest('Invalid audit status'); + record.status = input.status; + } + return record; +} + +/** One writer per directory. Call after authentication; principal.id must be the auth-issued ID. + * Request counters and storage reservations survive restart; stream leases are process-local. + * reserveStorage sets an absolute byte reservation BEFORE writing; release only after deletion. + * A crash may conservatively leak a reservation: reconcile it against the owning storage layer. + * This is logical accounting, not a filesystem scanner. The bounded journal retains newest entries. + */ +export async function createGovernance(directory, options = {}) { + const clock = options.clock ?? Date.now, limits = { ...defaults, ...options.limits }; + const journalLimit = options.journalLimit ?? 10000, maxPrincipals = options.maxPrincipals ?? 10000; + const overrides = structuredClone(options.principals ?? {}); + function validateLimits(value) { + if (Object.keys(value).some(k => !Object.hasOwn(defaults, k)) || Object.values(value).some(v => !natural(v)) || value.windowMs < 1) throw badRequest('Invalid governance limits'); + return value; + } + validateLimits(limits); + for (const [id, value] of Object.entries(overrides)) { identifier(id); validateLimits({ ...limits, ...value }); } + if (!natural(journalLimit) || journalLimit < 1 || !natural(maxPrincipals) || maxPrincipals < 1) throw badRequest('Invalid governance capacity'); + const root = await privateDirectory(join(directory, 'governance')); + const unlock = await acquireStateLock(root), file = join(root, 'journal.json'); + let state; + try { + state = await readPrivateJson(file, { version: 1, principals: {}, journal: [] }); + if (state.version !== 1 || !state.principals || typeof state.principals !== 'object' || Array.isArray(state.principals) || !Array.isArray(state.journal) || Object.keys(state).some(k => !['version', 'principals', 'journal'].includes(k))) throw new Error('Invalid governance state'); + for (const [id, value] of Object.entries(state.principals)) { + if (!/^[a-f0-9]{64}$/.test(id) || !natural(value.start) || !natural(value.requests) || !value.storage || typeof value.storage !== 'object' || Array.isArray(value.storage) || Object.keys(value).some(k => !['start', 'requests', 'storage'].includes(k)) || Object.entries(value.storage).some(([key, bytes]) => !/^[a-f0-9]{64}$/.test(key) || !natural(bytes)) || !natural(Object.values(value.storage).reduce((a, b) => a + b, 0))) throw new Error('Invalid governance state'); + } + for (const record of state.journal) { + if (!record || !natural(record.at) || !/^[a-f0-9]{64}$/.test(record.principal) || !actions.includes(record.action) || !outcomes.includes(record.outcome) || Object.keys(record).some(k => !['at', 'principal', 'action', 'outcome', 'resourceId', 'requestId', 'status'].includes(k)) || ['resourceId', 'requestId'].some(k => record[k] !== undefined && !/^[a-f0-9]{64}$/.test(record[k])) || (record.status !== undefined && (!Number.isInteger(record.status) || record.status < 100 || record.status > 599))) throw new Error('Invalid audit journal'); + } + try { if (!(await lstat(file)).isFile()) throw new Error('Unsafe journal'); await chmod(file, 0o600); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + } catch (error) { await unlock(); throw error; } + const streams = new Map(); + let tail = Promise.resolve(), closed = false, closeTask, fault; + function transact(operation) { + if (closed) return Promise.reject(new ApiError(503, 'governance_closed', 'Governance is closed')); + const task = tail.then(async () => { + if (fault) throw fault; + const next = structuredClone(state), result = operation(next); + try { await atomicJson(file, next); } + catch { fault = new ApiError(503, 'governance_persistence_failed', 'Reopen governance after checking state'); throw fault; } + state = next; + if (result instanceof ApiError) throw result; + return result; + }); + tail = task.catch(() => {}); + return task; + } + function context(principal) { + const id = identifier(principal?.id); + return { id: hash(id), principal: { id }, limit: { ...limits, ...overrides[id] } }; + } + function entry(next, ctx) { + if (!next.principals[ctx.id]) { + if (Object.keys(next.principals).length >= maxPrincipals) throw new ApiError(503, 'principal_capacity', 'Governance principal capacity reached'); + next.principals[ctx.id] = { start: clock(), requests: 0, storage: {} }; + } + return next.principals[ctx.id]; + } + function audit(next, record) { + next.journal.push(sanitizeAuditRecord(record, clock())); + next.journal = next.journal.slice(-journalLimit); + } + return { + journalFile: file, + consumeRequest(principal) { + const ctx = context(principal); + return transact(next => { + const value = entry(next, ctx), now = clock(); + if (now >= value.start + ctx.limit.windowMs) { value.start = now; value.requests = 0; } + const allowed = value.requests < ctx.limit.requests; + audit(next, { principal: ctx.principal, action: 'request', outcome: allowed ? 'allowed' : 'denied' }); + if (!allowed) { + const error = new ApiError(429, 'request_rate_limit', 'Principal request rate exceeded'); + error.retryAfterMs = Math.max(1, value.start + ctx.limit.windowMs - now); + return error; + } + value.requests++; + return { remaining: ctx.limit.requests - value.requests, resetAt: value.start + ctx.limit.windowMs }; + }); + }, + async acquireStream(principal) { + const ctx = context(principal); + // Pending acquisitions count immediately, before the audit fsync yields. + const count = streams.get(ctx.id) ?? 0; + if (count >= ctx.limit.streams) { + await transact(next => audit(next, { principal: ctx.principal, action: 'stream', outcome: 'denied' })); + throw new ApiError(429, 'stream_limit', 'Principal stream quota exceeded'); + } + streams.set(ctx.id, count + 1); + let released = false; + const release = () => { if (!released) { released = true; streams.set(ctx.id, streams.get(ctx.id) - 1); } }; + try { await transact(next => { entry(next, ctx); audit(next, { principal: ctx.principal, action: 'stream', outcome: 'allowed' }); }); } + catch (error) { release(); throw error; } + return release; + }, + reserveStorage(principal, resourceId, bytes) { + const ctx = context(principal), resource = hash(resourceId); + if (!natural(bytes)) throw badRequest('Invalid storage byte count'); + return transact(next => { + const value = entry(next, ctx), total = Object.values(value.storage).reduce((a, b) => a + b, 0) - (value.storage[resource] ?? 0) + bytes; + const allowed = Number.isSafeInteger(total) && (total <= ctx.limit.storageBytes || bytes <= (value.storage[resource] ?? 0)); + audit(next, { principal: ctx.principal, action: 'storage', outcome: allowed ? 'allowed' : 'denied', resourceId }); + if (!allowed) return new ApiError(413, 'storage_quota', 'Principal storage quota exceeded'); + if (bytes === 0) delete value.storage[resource]; else value.storage[resource] = bytes; + return { storageBytes: total, remaining: Math.max(0, ctx.limit.storageBytes - total) }; + }); + }, + recordAudit(input) { + const record = sanitizeAuditRecord(input, clock()); + return transact(next => { next.journal.push(record); next.journal = next.journal.slice(-journalLimit); return structuredClone(record); }); + }, + async usage(principal) { + const ctx = context(principal); await tail; + if (closed || fault) throw fault ?? new ApiError(503, 'governance_closed', 'Governance is closed'); + const value = state.principals[ctx.id]; + return { requests: value && clock() < value.start + ctx.limit.windowMs ? value.requests : 0, streams: streams.get(ctx.id) ?? 0, storageBytes: Object.values(value?.storage ?? {}).reduce((a, b) => a + b, 0), limits: ctx.limit }; + }, + async flush() { await tail; if (fault) throw fault; }, + close() { closed = true; closeTask ??= tail.then(unlock); return closeTask; }, + }; +} diff --git a/tools/node-agent-api/grok-node.mjs b/tools/node-agent-api/grok-node.mjs new file mode 100644 index 0000000..d12dd8b --- /dev/null +++ b/tools/node-agent-api/grok-node.mjs @@ -0,0 +1,66 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { ApiError, identifier } from './errors.mjs'; +import { OWNER, BOT, resourceNames } from './runtime/common.mjs'; + +const run = promisify(execFile); +const METHODS = new Set(['listAgents', 'getForeverBoxStatus', 'ensureForeverBox', 'getAgentTranscript', 'sendPrompt', 'createAgent', 'promptAcceptanceStatus']); +const gatewayProgram = String.raw` +const fs=require('node:fs'); +const g=JSON.parse(fs.readFileSync('/home/box/sand-data/gateway.json','utf8')); +const token=g.token??g.authToken; +if(!token)throw new Error('Gateway authentication unavailable'); +(async()=>{const method=process.argv[1],body=process.argv[2];const response=await fetch('http://127.0.0.1:1340/api/'+method,{method:'POST',headers:{authorization:'Bearer '+token,'content-type':'application/json'},body,signal:AbortSignal.timeout(20000)});if(!response.ok){console.log(JSON.stringify({node_api_upstream_error:response.status}));process.exitCode=1;return}console.log(JSON.stringify(await response.json()))})().catch(()=>{console.error('Gateway unavailable');process.exitCode=1});`; + +export function createGrokNodeAdapter({ container = 'grok-node-local-vm', allowWrites = false, execute = run } = {}) { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(container)) throw new Error('Invalid container name'); + if (allowWrites && !container.startsWith('grok-node-lab-')) throw new Error('Writes require an experimental GrokNode container'); + async function writeGuard(botId) { + if (!allowWrites) throw new ApiError(403, 'writes_disabled', 'Writes are enabled only for an experimental GrokNode backend'); + let info; + try { info = JSON.parse((await execute('docker', ['inspect', container], { encoding: 'utf8', timeout: 10000, maxBuffer: 1024 * 1024 })).stdout)[0]; } + catch { throw new ApiError(502, 'upstream_unavailable', 'Experimental container ownership unavailable'); } + const labels = info?.Config?.Labels ?? {}, namespace = labels[OWNER]; + try { resourceNames(namespace, 'validate'); } catch { throw new ApiError(403, 'unsafe_backend', 'Experimental namespace label required'); } + if (info.Name !== '/' + container || !container.startsWith(namespace + '-') || !Array.isArray(info.Mounts) || info.Mounts.some(mount => mount.Type === 'bind' || mount.Destination === '/root/.codex') || (botId && labels[BOT] && labels[BOT] !== botId)) throw new ApiError(403, 'unsafe_backend', 'Container ownership or mount isolation check failed'); + } + async function call(method, args = {}) { + if (!METHODS.has(method)) throw new Error('Unsupported gateway method'); + try { + const result = await execute('docker', ['exec', container, 'node', '-e', gatewayProgram, '--', method, JSON.stringify(args)], { timeout: 25000, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); + return JSON.parse(result.stdout); + } catch { throw new ApiError(502, 'upstream_unavailable', 'GrokNode gateway unavailable; outcome may be unknown'); } + } + async function agents() { + const rows = await call('listAgents'); + if (!Array.isArray(rows)) throw new ApiError(502, 'upstream_schema', 'Unexpected GrokNode roster'); + return rows.filter(a => typeof a.id === 'string').map(a => ({ object: 'node.agent', id: a.id, name: String(a.name ?? a.id), description: String(a.description ?? ''), backend: 'grok_node' })); + } + async function requireAgent(id) { identifier(id); const agent = (await agents()).find(a => a.id === id); if (!agent) throw new ApiError(404, 'not_found', 'Bot not found'); return agent; } + async function desktop(id) { + await requireAgent(id); + const status = await call('getForeverBoxStatus', { id }); + if (status.state !== 'running' || !status.vncUrl) throw new ApiError(409, 'desktop_unavailable', 'Bot desktop is not running'); + const url = new URL(status.vncUrl); + if (!['127.0.0.1', 'localhost'].includes(url.hostname) || url.protocol !== 'http:' || !['6080','6081'].includes(url.port)) throw new ApiError(502, 'upstream_schema', 'Unsupported desktop endpoint'); + const embedded = url.searchParams.get('path') ?? ''; + const query = embedded.indexOf('?'); + const display = query < 0 ? '1' : new URLSearchParams(embedded.slice(query + 1)).get('token'); + if (!display || !/^[1-9][0-9]{0,2}$/.test(display)) throw new ApiError(502, 'upstream_schema', 'Invalid desktop display'); + return { object: 'node.environment', id: 'nenv_' + id, agent_id: id, type: 'grok_node', isolation: 'shared_container_separate_display', container, display: Number(display), websocketUrl: `ws://127.0.0.1:${url.port}/websockify${url.port === '6081' ? '?token=' + display : ''}` }; + } + return { + agents, requireAgent, desktop, writesEnabled: allowWrites, + // Internal bridge to existing authenticated GrokNode APIs. REST routes + // remain explicit; callers cannot select arbitrary gateway methods. + gateway: call, + async items(id) { await requireAgent(id); const rows = await call('getAgentTranscript', { id }); return Array.isArray(rows) ? rows : rows.entries ?? []; }, + async input(id, text, requestId) { await writeGuard(id); await requireAgent(id); return call('sendPrompt', { agentId: id, prompt: text, clientNonce: requestId }); }, + async createAgent(input) { await writeGuard(); return call('createAgent', { name: input.name, description: input.description ?? '', origin: 'user', isKickstartRequested: false, isIntroductionSuppressed: true }); }, + async viewerAsset(asset) { + if (!/^(vnc\.html|(?:app|core|vendor)\/[A-Za-z0-9_./-]+|(?:defaults|mandatory)\.json)$/.test(asset) || asset.split('/').includes('..')) throw new ApiError(404, 'not_found', 'Asset not found'); + try { const result = await execute('docker', ['exec', container, 'cat', '/usr/share/novnc/' + asset], { encoding: null, timeout: 10000, maxBuffer: 4 * 1024 * 1024 }); return result.stdout; } + catch { throw new ApiError(404, 'not_found', 'Asset unavailable'); } + }, + }; +} diff --git a/tools/node-agent-api/input.mjs b/tools/node-agent-api/input.mjs new file mode 100644 index 0000000..84f4bfe --- /dev/null +++ b/tools/node-agent-api/input.mjs @@ -0,0 +1,64 @@ +import { randomUUID, createHash } from 'node:crypto'; +import { ApiError, badRequest, identifier, object } from './errors.mjs'; + +export function metadata(value) { + if (value === null) return {}; + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length > 16 || Object.entries(value).some(([key, text]) => key.length > 64 || typeof text !== 'string' || text.length > 512)) throw badRequest('metadata must contain up to 16 string values (keys <=64, values <=512 characters)'); + return value; +} + +export function message(input, headerKey) { + object(input, ['events', 'idempotency_key']); + if (!Array.isArray(input.events) || input.events.length !== 1) throw badRequest('Submit one event per request'); + const event = input.events[0]; + let text; + if (event?.type === 'message') { object(event, ['type', 'text']); text = event.text; } + else if (event?.type === 'agent.session.input.message') { + object(event, ['type', 'input']); + if (!Array.isArray(event.input) || event.input.length !== 1) throw badRequest('One user input item is supported'); + const item = object(event.input[0], ['role', 'content']); + if (item.role !== 'user' || !Array.isArray(item.content) || item.content.length !== 1) throw badRequest('One user input_text content is supported'); + const content = object(item.content[0], ['type', 'text']); + if (content.type !== 'input_text') throw badRequest('Only input_text is supported'); + text = content.text; + } else throw badRequest('Only message input is supported; cancellation and tool results are unavailable'); + if (typeof text !== 'string' || !text.trim() || text.length > 16000) throw badRequest('Message must contain 1..16000 characters'); + if (headerKey !== undefined && input.idempotency_key !== undefined && headerKey !== input.idempotency_key) throw badRequest('Header and body idempotency keys disagree'); + const key = headerKey ?? input.idempotency_key; + const requestId = key === undefined ? 'nreq_' + randomUUID() : identifier(key); + return { text, requestId, fingerprint: createHash('sha256').update(text).digest('hex'), legacyFingerprint: createHash('sha256').update(JSON.stringify({ type: 'message', text })).digest('hex') }; +} + +export function createInputHandler({ adapter, store }) { + const queues = new Map(); + return async (session, input, headerKey) => { + const normalized = message(input, headerKey); + const previous = queues.get(session.id) ?? Promise.resolve(); + const task = previous.catch(() => {}).then(async () => { + const { text, requestId, fingerprint, legacyFingerprint } = normalized; + if (session.status === 'closed') throw new ApiError(409, 'session_closed', 'Session attachment is closed'); + const old = Object.hasOwn(session.requests, requestId) ? session.requests[requestId] : undefined; + if (old) { + if (![fingerprint, legacyFingerprint].includes(old.fingerprint)) throw new ApiError(409, 'idempotency_conflict', 'Request key already used with different input'); + return { request_id: requestId, status: old.status, replayed: true, completed: false }; + } + if (Object.keys(session.requests).length >= 1000) throw new ApiError(409, 'request_limit', 'Session request limit reached'); + // Reserve an outcome event before contacting the backend. + if (session.events.length >= 9999) throw new ApiError(409, 'event_limit', 'Session event limit reached'); + await store.update(session.id, { status: 'pending', requests: { ...session.requests, [requestId]: { fingerprint, status: 'pending' } } }); + try { + const accepted = adapter.inputSession ? await adapter.inputSession(session, text, requestId) : await adapter.input(session.agent_id, text, requestId); + await store.update(session.id, { requests: { ...session.requests, [requestId]: { ...session.requests[requestId], status: 'accepted' } }, status: session.status === 'closed' ? 'closed' : 'accepted' }); + await store.append(session.id, 'node.session.input.accepted', { request_id: requestId }); + return { request_id: requestId, status: 'accepted', completed: false, ...(accepted?.turn_id ? { turn_id: accepted.turn_id } : {}) }; + } catch (error) { + const status = ['writes_disabled', 'unsafe_backend', 'turn_active', 'session_closed', 'unsupported'].includes(error.code) ? 'rejected' : 'unknown'; + await store.update(session.id, { requests: { ...session.requests, [requestId]: { ...session.requests[requestId], status } }, status: session.status === 'closed' ? 'closed' : status }); + await store.append(session.id, 'node.session.input.' + status, { request_id: requestId, replayed: false }); + throw error; + } + }); + queues.set(session.id, task); + try { return await task; } finally { if (queues.get(session.id) === task) queues.delete(session.id); } + }; +} diff --git a/tools/node-agent-api/openapi.json b/tools/node-agent-api/openapi.json new file mode 100644 index 0000000..0f90843 --- /dev/null +++ b/tools/node-agent-api/openapi.json @@ -0,0 +1,4160 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Node Agent API", + "version": "0.3.0", + "description": "Independent loopback Node Agent API using the original GrokNode interfaces without modifying its program. The default codex backend runs Codex Harness on Mac and attaches a low-privilege stdio executor to GrokNode's existing shared container/data. Bot desktops remain allocated by GrokNode. API authorization covers its viewer and REST paths; original GrokNode VNC ports keep their original access policy. Not OpenAI SDK compatible." + }, + "servers": [ + { + "url": "http://127.0.0.1:18770", + "description": "Default loopback service; CLI port is configurable" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/health": { + "get": { + "operationId": "getHealth", + "summary": "Service identity, not upstream readiness", + "security": [], + "responses": { + "200": { + "description": "Service identity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents": { + "get": { + "operationId": "listAgents", + "summary": "List bots in the credential's scope", + "x-required-scopes": [ + "agents.read" + ], + "responses": { + "200": { + "description": "Authorized bots", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentList" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/After" + }, + { + "$ref": "#/components/parameters/Order" + } + ] + }, + "post": { + "operationId": "createAgent", + "summary": "Create a bot through the original GrokNode gateway", + "x-required-scopes": [ + "agents.write" + ], + "description": "Requires agents.write and wildcard bot scope. The default codex backend calls the original GrokNode createAgent gateway without changing its program, model configuration or kickstart policy. The legacy grok backend defaults to read-only; enabling its write flag does not waive its additional backend ownership and mount checks.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAgent" + } + } + } + }, + "responses": { + "201": { + "description": "Upstream bot creation result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/{agent_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/AgentId" + } + ], + "get": { + "operationId": "getAgent", + "summary": "Read bot profile", + "x-required-scopes": [ + "agents.read" + ], + "responses": { + "200": { + "description": "Bot profile", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Agent" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions": { + "get": { + "operationId": "listSessions", + "summary": "List authorized API attachments", + "x-required-scopes": [ + "sessions.read" + ], + "responses": { + "200": { + "description": "Sessions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionList" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/After" + }, + { + "$ref": "#/components/parameters/Order" + }, + { + "name": "agent_id", + "in": "query", + "schema": { + "$ref": "#/components/schemas/Identifier" + }, + "description": "Filter by bot ID before pagination" + } + ] + }, + "post": { + "operationId": "createSession", + "summary": "Create an independent Harness thread or reuse a legacy transcript attachment", + "x-required-scopes": [ + "sessions.write" + ], + "description": "The codex backend creates a new independent Harness thread (201); the legacy grok backend may reuse an open attachment to the bot's existing transcript (200). Neither starts a turn. agent_id takes precedence over agent.id when both are supplied. Independent threads do not isolate files in the shared container.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSession" + }, + "example": { + "agent_id": "your_bot_id" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Session" + }, + "201": { + "$ref": "#/components/responses/Session" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "get": { + "operationId": "getSession", + "summary": "Read attachment and input acceptance state", + "x-required-scopes": [ + "sessions.read" + ], + "responses": { + "200": { + "$ref": "#/components/responses/Session" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "patch": { + "operationId": "updateSessionMetadata", + "summary": "Replace attachment metadata; null clears it", + "x-required-scopes": [ + "sessions.write" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "metadata" + ], + "properties": { + "metadata": { + "type": "object", + "maxProperties": 16, + "additionalProperties": { + "type": "string", + "maxLength": 512 + }, + "description": "String map; keys up to 64 UTF-16 code units, values up to 512. null clears metadata in requests.", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Session" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/items": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "get": { + "operationId": "listItems", + "summary": "Read Harness turn items or the legacy GrokNode transcript", + "x-required-scopes": [ + "sessions.read" + ], + "responses": { + "200": { + "description": "Unnormalized upstream transcript items", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemList" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/After" + }, + { + "$ref": "#/components/parameters/Order" + } + ], + "description": "Paged transcript; upstream fields are preserved. Missing IDs receive a synthetic ID derived from item index and contents. Changes to such items invalidate their cursors. Does not represent OpenAI canonical tool/turn items." + } + }, + "/v1/agents/sessions/{session_id}/environment": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "get": { + "operationId": "getEnvironment", + "summary": "Read bot container and display mapping", + "x-required-scopes": [ + "sessions.read" + ], + "responses": { + "200": { + "description": "Current desktop mapping without raw WebSocket URL", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Environment" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/events": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "get": { + "operationId": "listEvents", + "summary": "Read persistent events or subscribe using SSE", + "x-required-scopes": [ + "sessions.read" + ], + "description": "Accept: text/event-stream selects SSE. Replays persisted events and streams new ones. after / Last-Event-ID resume from a persistent event ID; unknown IDs return JSON 400 before streaming. limit / order apply only to JSON mode. Transcript changes are observed by polling and emit durable node.session.items.changed notifications; retrieve items for content. Upstream-unavailable notices have no cursor. Stream closes under backpressure; reconnect from the last persisted ID.", + "parameters": [ + { + "name": "Last-Event-ID", + "in": "header", + "description": "Last persisted nevt_* ID; takes precedence over after", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/After" + }, + { + "$ref": "#/components/parameters/Order" + } + ], + "responses": { + "200": { + "description": "Persistent event list or SSE stream", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventList" + } + }, + "text/event-stream": { + "schema": { + "type": "string" + }, + "example": "id: nevt_example\nevent: node.session.created\ndata: {\"id\":\"nevt_example\",\"type\":\"node.session.created\",\"session_id\":\"nsess_example\",\"created_at\":1790000000,\"data\":{\"agent_id\":\"your_bot_id\"}}\n\n" + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "submitMessage", + "summary": "Submit one message or cancel the active Harness turn", + "x-required-scopes": [ + "sessions.write" + ], + "description": "Requires a write-enabled backend. 202 acknowledges input or interruption, not task completion; inspect turns for the outcome. Message retries with the same key and logical text return saved status without resubmission; changed text returns 409. Concurrent message submissions are serialized. Pending submissions recovered after restart become unknown and are not resent. Cancel input is supported only when capabilities.events.cancellation is true, has no idempotency_key field, interrupts the native turn and terminates only its recorded processes in its thread; it never stops the shared box.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitEvent" + }, + "example": { + "events": [ + { + "type": "message", + "text": "Run project tests" + } + ], + "idempotency_key": "nreq_example_1" + } + } + } + }, + "responses": { + "202": { + "description": "Input acceptance or saved idempotent response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitResult" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "schema": { + "$ref": "#/components/schemas/Identifier" + }, + "description": "Optional; must agree with body idempotency_key if both are supplied. Retries reuse key, session ID and logical text." + } + ] + } + }, + "/v1/agents/sessions/{session_id}/desktop": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "post": { + "operationId": "authorizeDesktop", + "summary": "Issue a single-use, bot-bound desktop link", + "x-required-scopes": [ + "sessions.read" + ], + "description": "Requires sessions.read plus desktop.view for view mode or desktop.control for control mode. Issues a single-use capability; opening it establishes the viewer and any control lease. Application viewers use a separate cookie path per window and can coexist. One controller lease per bot; handing back closes that controller. Viewer connections expire after 15 minutes. Control does not pause the bot.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthorizeDesktop" + } + } + } + }, + "responses": { + "201": { + "description": "Sensitive capability URL; open in a local browser", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DesktopAuthorization" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/handback": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "post": { + "operationId": "handbackDesktop", + "summary": "Release a desktop control lease and disconnect its viewer", + "x-required-scopes": [ + "sessions.read", + "desktop.control" + ], + "description": "Controller credential or owner only. Does not cancel or pause bot work. No request body is read.", + "responses": { + "200": { + "description": "Lease released or already absent", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "released" + ], + "properties": { + "released": { + "type": "boolean", + "enum": [ + true + ] + } + } + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/keys": { + "post": { + "operationId": "issueKey", + "summary": "Issue a scoped, expiring API credential", + "x-required-scopes": [ + "keys.manage" + ], + "description": "Scopes and bot IDs cannot exceed issuer permissions; expiry cannot exceed the parent's. Parent revocation invalidates descendants. Plaintext key appears only at issuance.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueKey" + } + } + } + }, + "responses": { + "201": { + "description": "New credential, including one-time plaintext secret", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKey" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/keys/{key_id}": { + "delete": { + "operationId": "revokeKey", + "summary": "Revoke a derived API credential", + "x-required-scopes": [ + "keys.manage" + ], + "description": "Owner may revoke any derived key. A delegated manager may revoke only its descendants. Owner key cannot be revoked here. Descendant authentication and desktop connections are invalidated.", + "parameters": [ + { + "name": "key_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/Identifier" + } + } + ], + "responses": { + "200": { + "description": "Revoked credential", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "revoked" + ], + "properties": { + "id": { + "type": "string" + }, + "revoked": { + "type": "boolean", + "enum": [ + true + ] + } + } + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/desktop/open": { + "get": { + "operationId": "openDesktop", + "summary": "Consume a single-use desktop ticket", + "security": [], + "parameters": [ + { + "name": "ticket", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "303": { + "description": "Redirects to the authorized viewer with an HttpOnly, SameSite=Strict cookie valid for 900 seconds. Whole desktops use Path=/desktop; application viewers use their own Path=/desktop/lane/{viewer_id}.", + "headers": { + "Location": { + "schema": { + "type": "string", + "enum": [ + "/desktop/vnc.html" + ] + } + }, + "Set-Cookie": { + "schema": { + "type": "string" + } + }, + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/desktop/mandatory.json": { + "get": { + "operationId": "getViewerSettings", + "summary": "Server-selected noVNC settings", + "security": [ + { + "viewerCookie": [] + } + ], + "responses": { + "200": { + "description": "Mandatory viewer settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewerSettings" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/desktop/defaults.json": { + "get": { + "operationId": "getViewerDefaults", + "summary": "Empty noVNC defaults", + "security": [ + { + "viewerCookie": [] + } + ], + "responses": { + "200": { + "description": "Empty settings", + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 0 + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/desktop/vnc.html": { + "get": { + "operationId": "getViewerPage", + "summary": "noVNC page with control bar hidden", + "security": [ + { + "viewerCookie": [] + } + ], + "responses": { + "200": { + "description": "Viewer HTML", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/desktop/{asset}": { + "get": { + "operationId": "getViewerAsset", + "summary": "Allowlisted noVNC static resources", + "security": [ + { + "viewerCookie": [] + } + ], + "description": "asset may contain nested paths under app/, core/ or vendor/. Dot-dot segments are rejected. This parameter documents a nested asset path, not an arbitrary filesystem path.", + "parameters": [ + { + "name": "asset", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "core/rfb.js" + } + } + ], + "responses": { + "200": { + "description": "Resource with its native content type", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/desktop/socket": { + "get": { + "operationId": "connectDesktopSocket", + "summary": "WebSocket upgrade for a cookie-authorized RFB connection", + "security": [ + { + "viewerCookie": [] + } + ], + "description": "Upgrade requires Host and Origin matching this service. Binary RFB 3.8 / None-security client stream. Server drops keyboard, pointer, clipboard, power, resize and extended keyboard messages in view mode. Connection errors close the socket. Ordinary HTTP GET is not a REST operation.", + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string", + "example": "http://127.0.0.1:18770" + } + } + ], + "x-websocket": true, + "responses": { + "101": { + "description": "WebSocket connection established" + }, + "403": { + "description": "WebSocket upgrade rejected; no JSON body" + }, + "default": { + "description": "Rejected WebSocket upgrade; authentication, changed environment, quota or upstream error status, with no JSON body" + } + } + } + }, + "/desktop/lane/{viewer_id}/mandatory.json": { + "parameters": [{ "$ref": "#/components/parameters/ViewerId" }], + "get": { + "operationId": "getApplicationViewerSettings", + "summary": "Server-selected settings for one application viewer", + "security": [{ "viewerCookie": [] }], + "responses": { + "200": { + "description": "Application viewer settings with its own socket path", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ViewerSettings" } } } + }, + "default": { "$ref": "#/components/responses/Error" } + } + } + }, + "/desktop/lane/{viewer_id}/defaults.json": { + "parameters": [{ "$ref": "#/components/parameters/ViewerId" }], + "get": { + "operationId": "getApplicationViewerDefaults", + "summary": "Empty defaults for one application viewer", + "security": [{ "viewerCookie": [] }], + "responses": { + "200": { + "description": "Empty settings", + "content": { "application/json": { "schema": { "type": "object", "maxProperties": 0 } } } + }, + "default": { "$ref": "#/components/responses/Error" } + } + } + }, + "/desktop/lane/{viewer_id}/vnc.html": { + "parameters": [{ "$ref": "#/components/parameters/ViewerId" }], + "get": { + "operationId": "getApplicationViewerPage", + "summary": "noVNC page for one application, with the control bar hidden", + "security": [{ "viewerCookie": [] }], + "responses": { + "200": { "description": "Authorized application viewer HTML" }, + "default": { "$ref": "#/components/responses/Error" } + } + } + }, + "/desktop/lane/{viewer_id}/{asset}": { + "parameters": [ + { "$ref": "#/components/parameters/ViewerId" }, + { "name": "asset", "in": "path", "required": true, "schema": { "type": "string", "example": "core/rfb.js" } } + ], + "get": { + "operationId": "getApplicationViewerAsset", + "summary": "Allowlisted noVNC resources for one application viewer", + "security": [{ "viewerCookie": [] }], + "responses": { + "200": { "description": "Resource with its native content type" }, + "default": { "$ref": "#/components/responses/Error" } + } + } + }, + "/desktop/lane/{viewer_id}/socket": { + "parameters": [{ "$ref": "#/components/parameters/ViewerId" }], + "get": { + "operationId": "upgradeApplicationViewerSocket", + "summary": "Cookie-authorized WebSocket upgrade for one application viewer", + "security": [{ "viewerCookie": [] }], + "parameters": [{ "name": "Origin", "in": "header", "required": true, "schema": { "type": "string", "example": "http://127.0.0.1:18770" } }], + "responses": { + "101": { "description": "Application RFB WebSocket established" }, + "403": { "description": "WebSocket upgrade rejected; no JSON body" }, + "default": { "description": "Rejected WebSocket upgrade; authentication, changed environment, quota or upstream error status, with no JSON body" } + } + } + }, + "/v1/agents/sessions/{session_id}/close": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "post": { + "operationId": "closeSession", + "summary": "Close an API attachment while preserving its records", + "x-required-scopes": [ + "sessions.write" + ], + "description": "Revokes this attachment's desktop tickets/viewers. Pending input prevents closing. Does not delete the bot, transcript, files, or cancel bot work. A later attachment to the same bot can be created.", + "responses": { + "200": { + "$ref": "#/components/responses/Session" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/usage": { + "get": { + "operationId": "getUsage", + "summary": "Read the caller's governance counters and limits", + "description": "Counters are keyed by quota principal (derived key user_id, else owner). requests counts the current rate window; streams counts live SSE/desktop streams (limit 4); storageBytes counts logical reservations incl. session state and export artifacts (default cap 64 MiB).", + "responses": { + "200": { + "description": "Per-principal usage", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Usage" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/webhooks": { + "post": { + "operationId": "configureWebhooks", + "summary": "Configure the loopback webhook destination and signing key", + "x-required-scopes": [ + "owner" + ], + "description": "Owner credential only (not a scope). Destination must be a literal loopback HTTP(S) URL (127.0.0.1 or [::1], no userinfo/query/fragment). First configure creates the signing key and enables the outbox; later calls change the destination and keep the key. Returns destination plus signingKey {id, secret}: provision the secret out of band.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "destination" + ], + "properties": { + "destination": { + "type": "string", + "maxLength": 2048 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Webhook configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookConfiguration" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/webhooks/rotate": { + "post": { + "operationId": "rotateWebhookKey", + "summary": "Rotate the webhook signing key", + "x-required-scopes": [ + "owner" + ], + "description": "Owner credential only. Rotation affects the next attempt, including queued retries. Keep the previous key configured on receivers during overlap.", + "responses": { + "200": { + "description": "New signing key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookKey" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/webhooks/dispatch": { + "post": { + "operationId": "dispatchWebhooks", + "summary": "Deliver due webhook envelopes now", + "x-required-scopes": [ + "owner" + ], + "description": "Owner credential only. The service also dispatches on a 1s timer while webhooks stay enabled; a dispatch failure disables further attempts until reconfigured. Delivery is at-least-once: a crash after HTTP success can replay the same delivery id.", + "responses": { + "200": { + "description": "Attempted deliveries", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookDelivery" + } + } + } + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/webhooks/{delivery_id}": { + "parameters": [ + { + "name": "delivery_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/Identifier" + } + } + ], + "get": { + "operationId": "getWebhookDelivery", + "summary": "Read one webhook delivery (owner, own principal only)", + "x-required-scopes": [ + "owner" + ], + "description": "Owner credential only; a delivery is visible only under the quota principal that enqueued it. Status is pending, delivering, retry, delivered or failed with attempts, nextAttemptAt, lastError (none/interrupted/timeout/transport/http_status) and lastStatus.", + "responses": { + "200": { + "description": "Delivery status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDelivery" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/capabilities": { + "get": { + "operationId": "getCapabilities", + "summary": "Discover supported features and explicit backend limitations", + "responses": { + "200": { + "description": "Authenticated capability declaration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Capabilities" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/keys/{key_id}/rotate": { + "parameters": [ + { + "name": "key_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/Identifier" + } + } + ], + "post": { + "operationId": "rotateKey", + "summary": "Rotate a derived API credential", + "x-required-scopes": [ + "keys.manage" + ], + "description": "Owner may rotate any derived key; a delegated manager only its descendants. Only active keys rotate (409 key_inactive). The old id is revoked and a new id with a fresh secret is issued; desktop viewers on the old key disconnect. The plaintext key appears only in this response.", + "responses": { + "201": { + "description": "Replacement credential, including one-time plaintext secret", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKey" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/users": { + "post": { + "operationId": "createUser", + "summary": "Create a private API user identity", + "x-required-scopes": [ + "owner" + ], + "description": "Owner credential only. Names are 1..128 chars with no control characters. Derived keys can be bound to a user via user_id so per-user quotas, revocation and disable cascade apply.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "New user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "get": { + "operationId": "listUsers", + "summary": "List the owner identity and all users", + "x-required-scopes": [ + "owner" + ], + "description": "Owner credential only. First entry is always the owner identity; then created users with disabled flags and timestamps.", + "responses": { + "200": { + "description": "Users", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserList" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/users/{user_id}/disable": { + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/Identifier" + } + } + ], + "post": { + "operationId": "disableUser", + "summary": "Disable a user identity", + "x-required-scopes": [ + "owner" + ], + "description": "Owner credential only. Disabled users derived keys stop authenticating and their desktop viewers disconnect. The owner identity cannot be disabled.", + "responses": { + "200": { + "description": "Disabled user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/turns": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "get": { + "operationId": "listTurns", + "summary": "List Harness turns (codex backend)", + "x-required-scopes": [ + "sessions.read" + ], + "description": "Codex backend only. Each turn carries id, status (running/completed/failed/interrupted/cancelled/unknown), started/ended timestamps, items with turn ids, native usage (tokenUsage snapshot, cost always null) and the submitting request id. Unknown means the outcome was never observed, never success.", + "parameters": [ + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/After" + }, + { + "$ref": "#/components/parameters/Order" + } + ], + "responses": { + "200": { + "description": "Turns", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TurnList" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/traces": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "get": { + "operationId": "listTraces", + "summary": "List turn traces with spans (codex backend)", + "x-required-scopes": [ + "sessions.read" + ], + "description": "Codex backend only. One entry per turn: turn_id, started/ended timestamps, status, usage and spans (the turn items). Not OTLP; no model-cost attribution (usage.cost is always null). Unknown spans must not be read as success.", + "responses": { + "200": { + "description": "Traces", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TurnTraceList" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/actions": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "get": { + "operationId": "listActions", + "summary": "List pending required actions (codex backend)", + "x-required-scopes": [ + "sessions.read" + ], + "description": "Codex backend only. Pending approvals for item/commandExecution/requestApproval, item/fileChange/requestApproval and item/tool/call, each with request/thread/turn/item ids and native details. Survives disconnects; answer via POST actions/{action_id}.", + "parameters": [ + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/After" + }, + { + "$ref": "#/components/parameters/Order" + } + ], + "responses": { + "200": { + "description": "Pending actions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequiredActionList" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/actions/{action_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + }, + { + "name": "action_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/Identifier" + } + } + ], + "post": { + "operationId": "answerAction", + "summary": "Approve or decline a required action (codex backend)", + "x-required-scopes": [ + "sessions.write" + ], + "description": "Codex backend only. Decision is accept or decline. item/tool/call only executes node_approved_shell (anything else is 409 tool_unavailable); command and file approvals forward the decision to the Harness. A declined shell reports denied without executing, and denial is what the backend records. 409 action_unavailable when not pending, 409 action_busy when already being answered.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnswerAction" + } + } + } + }, + "responses": { + "200": { + "description": "Answer recorded", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "answered" + ], + "properties": { + "answered": { + "type": "boolean", + "enum": [ + true + ] + } + } + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/resume": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "post": { + "operationId": "resumeSession", + "summary": "Resume a Harness thread after restart (codex backend)", + "x-required-scopes": [ + "sessions.write" + ], + "description": "Codex backend only. Re-attaches a live thread (thread/resume) and restores the turn list from thread/read, preserving previously recorded usage and request ids. 409 thread_missing when the session has no Harness thread. No request body is read.", + "responses": { + "200": { + "$ref": "#/components/responses/Session" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/handoff": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "post": { + "operationId": "handoffSession", + "summary": "Hand a thread to codex_cli (codex backend)", + "x-required-scopes": [ + "sessions.write" + ], + "description": "Codex backend only. Requires an idle bot (409 turn_active otherwise). Unsubscribes the thread and reports thread_id, bot_id, target codex_cli and replayed false. No request body is read.", + "responses": { + "200": { + "description": "Handoff receipt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionHandoff" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/clipboard": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "get": { + "operationId": "readClipboard", + "summary": "Read the bot clipboard (codex backend, manual)", + "x-required-scopes": [ + "sessions.read", + "desktop.view" + ], + "description": "Codex backend only. There is no clipboard sync or watcher: this is one explicit read. Route scope is sessions.read (GET); the adapter additionally requires desktop.view on the bot.", + "responses": { + "200": { + "description": "Clipboard text", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClipboardRead" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "writeClipboard", + "summary": "Write the bot clipboard (codex backend, lease holders only)", + "x-required-scopes": [ + "sessions.write", + "desktop.control" + ], + "description": "Codex backend only. Route scope is sessions.write (non-GET, non-desktop/handback sub-route); the adapter additionally requires desktop.control on the bot AND the caller own active control lease (403 otherwise), and rejects closed sessions (409 session_closed).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "text" + ], + "properties": { + "text": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Write receipt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClipboardWrite" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/project/diff": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "get": { + "operationId": "getProjectDiff", + "summary": "Read the working-tree diff (codex backend)", + "x-required-scopes": [ + "sessions.read" + ], + "description": "Codex backend only. Returns the runtime project diff for review. Route scope is sessions.read (GET).", + "responses": { + "200": { + "description": "Project diff", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/project/import": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "post": { + "operationId": "importProject", + "summary": "Import files into the project (codex backend)", + "x-required-scopes": [ + "sessions.write" + ], + "description": "Codex backend only. Requires an open idle session (409 session_busy when closed or a turn is running). Body files array up to 16 MiB. Returns the runtime import result.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "files" + ], + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Import result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/project/export": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "post": { + "operationId": "exportProject", + "summary": "Export the project as a download artifact (codex backend)", + "x-required-scopes": [ + "sessions.write" + ], + "description": "Codex backend only. Requires an open idle session (409 session_busy otherwise). Reserves storage quota (inventory bytes + 1 MiB, then the real size; 413 storage_quota when over cap), tars the project to artifacts/{id}.tgz and returns artifact with id, name, size, sha256 and download_url. Fetch the bytes via GET artifacts/{artifact_id}/content.", + "responses": { + "200": { + "description": "Export artifact descriptor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectExport" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/environment/backup": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "post": { + "operationId": "backupEnvironment", + "summary": "Back up source, edits, conversation and logs (codex backend)", + "x-required-scopes": [ + "sessions.write" + ], + "description": "Codex backend only. Requires an idle bot across all its sessions (409 turn_active otherwise). Tars the Harness home dir to mac-session.tgz and snapshots the API session to session.json (0600). Returns backup_id with preserved source, uncommitted_changes, conversation and logs, and processes_restored false: running processes are NOT restored, ever. No request body is read.", + "responses": { + "201": { + "description": "Backup receipt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnvironmentBackup" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/environment/recreate": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "post": { + "operationId": "recreateEnvironment", + "summary": "Request environment recreation; the shared backend refuses lifecycle management", + "description": "The published shared GrokNode runtime returns 409 managed_by_grok_node. Container creation, replacement and restart remain owned by GrokNode; this API does not recreate the box.", + "x-required-scopes": [ + "sessions.write" + ], + "responses": { + "200": { + "description": "Recreate receipt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnvironmentRecreate" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/environment/restore": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "post": { + "operationId": "restoreEnvironment", + "summary": "Restore the environment from a backup (codex backend)", + "x-required-scopes": [ + "sessions.write" + ], + "description": "Codex backend only. Requires no API-managed active turn in the shared project. Body backup_id selects a namespaced private backup. Checks archive identity and SHA-256, backs up current files, then overlays project entries without deleting extra files. Does not recreate the container or automatically replace the API state or Mac Harness profile from backup. Returns restored true and processes_restored false; preserve Mac state/runtime-state and explicitly resume the thread.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "backup_id" + ], + "properties": { + "backup_id": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Restore receipt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnvironmentRestore" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/artifacts/{artifact_id}/content": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + }, + { + "name": "artifact_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/Identifier" + } + } + ], + "get": { + "operationId": "downloadArtifact", + "summary": "Download a project export tgz (codex backend)", + "x-required-scopes": [ + "sessions.read" + ], + "description": "Codex backend only. Route scope is sessions.read (GET). Serves the stored tgz inside the service artifacts dir (path traversal rejected, 404 otherwise) as application/gzip with Content-Disposition attachment project.tgz. One authenticated GET; never put the key in the URL.", + "responses": { + "200": { + "description": "Gzip artifact bytes", + "content": { + "application/gzip": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/agents/sessions/{session_id}/applications": { + "parameters": [ + { + "$ref": "#/components/parameters/SessionId" + } + ], + "get": { + "operationId": "listApplications", + "summary": "List current application windows on the selected bot desktop", + "x-required-scopes": [ + "sessions.read", + "desktop.view" + ], + "description": "Requires sessions.read and desktop.view. Returns current windows only; no application or desktop is launched by enumeration.", + "responses": { + "200": { + "description": "Current application windows", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationList" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "launchApplication", + "summary": "Explicitly launch a terminal or browser on the bot's existing desktop", + "x-required-scopes": [ + "sessions.read", + "desktop.control" + ], + "description": "Requires this caller's active control lease. Does not create a desktop/container or change GrokNode software. 202 is launch acceptance; GET applications observes the window.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "terminal", + "browser" + ] + } + } + } + } + } + }, + "responses": { + "202": { + "description": "Application launch accepted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "launched", + "kind", + "display" + ], + "properties": { + "launched": { + "type": "boolean" + }, + "kind": { + "type": "string", + "enum": [ + "terminal", + "browser" + ] + }, + "display": { + "type": "integer" + } + } + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Owner or derived Node Agent API key; not a GrokNode gateway token" + }, + "viewerCookie": { + "type": "apiKey", + "in": "cookie", + "name": "nodeviewer", + "description": "Issued by consuming a single-use desktop ticket" + } + }, + "parameters": { + "ViewerId": { + "name": "viewer_id", + "in": "path", + "required": true, + "description": "Opaque application viewer id returned in the ticket redirect; it is not a bot, desktop or X11 window id.", + "schema": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" } + }, + "AgentId": { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/Identifier" + } + }, + "SessionId": { + "name": "session_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/Identifier" + } + }, + "Limit": { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + "After": { + "name": "after", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Exclusive resource cursor in the caller's filtered collection; unknown cursors return 400" + }, + "Order": { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "asc" + } + } + }, + "responses": { + "Error": { + "description": "JSON error: 400 invalid request; 401 invalid credentials/ticket/viewer; 403 permission, origin or writes disabled; 404 missing resource; 409 conflict/capacity; 413 body limit; 415 JSON required; 500 internal failure; 502 upstream failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + }, + "Session": { + "description": "New Harness thread attachment or reused legacy bot transcript attachment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + }, + "headers": { + "X-Request-ID": { + "description": "Service-generated request correlation ID", + "schema": { + "type": "string" + } + } + } + } + }, + "schemas": { + "Identifier": { + "type": "string", + "pattern": "^[\\p{L}\\p{N}][\\p{L}\\p{N}_-]{0,127}$" + }, + "Scope": { + "type": "string", + "enum": [ + "agents.read", + "agents.write", + "sessions.read", + "sessions.write", + "desktop.view", + "desktop.control", + "keys.manage" + ] + }, + "Health": { + "type": "object", + "required": [ + "service", + "version", + "backend", + "compatible_with_openai_sdk" + ], + "properties": { + "service": { + "type": "string", + "enum": [ + "node-agent-api" + ] + }, + "version": { + "type": "string", + "enum": [ + "0.3.0" + ] + }, + "backend": { + "type": "string", + "enum": [ + "grok_node", + "codex_harness" + ] + }, + "compatible_with_openai_sdk": { + "type": "boolean", + "enum": [ + false + ] + } + } + }, + "Agent": { + "type": "object", + "required": [ + "object", + "id", + "name", + "description", + "backend" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "node.agent" + ] + }, + "id": { + "$ref": "#/components/schemas/Identifier" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "backend": { + "type": "string", + "enum": [ + "grok_node", + "codex_harness" + ], + "description": "grok_node is the explicit legacy gateway backend; codex_harness is the default Mac Codex backend using bots discovered from the original GrokNode gateway." + } + } + }, + "CreateAgent": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "\\S" + }, + "description": { + "type": "string", + "maxLength": 4000 + } + } + }, + "Session": { + "type": "object", + "required": [ + "object", + "id", + "agent_id", + "status", + "created_at", + "context", + "metadata" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "node.agent.session" + ] + }, + "id": { + "$ref": "#/components/schemas/Identifier" + }, + "agent_id": { + "$ref": "#/components/schemas/Identifier" + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "accepted", + "rejected", + "unknown", + "closed" + ], + "description": "Input submission state, not task completion" + }, + "created_at": { + "type": "integer" + }, + "context": { + "type": "string", + "enum": [ + "grok_node_bot_transcript", + "codex_harness" + ], + "description": "grok_node_bot_transcript reuses the bot's existing transcript (legacy grok backend); codex_harness is a codex-backend Harness thread attachment" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "thread_id": { + "type": "string", + "description": "Harness thread id; present on codex-backend sessions after bindSession" + }, + "task_status": { + "type": "string", + "enum": [ + "idle", + "running", + "completed", + "failed", + "interrupted", + "cancelled", + "unknown" + ], + "description": "Authoritative turn outcome on the codex backend; input acceptance state on the legacy backend" + }, + "active_turn_id": { + "type": "string", + "nullable": true, + "description": "Running turn id, or null when idle" + } + } + }, + "CreateSession": { + "type": "object", + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "agent_id" + ] + }, + { + "required": [ + "agent" + ] + } + ], + "properties": { + "agent_id": { + "$ref": "#/components/schemas/Identifier" + }, + "agent": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/Identifier" + } + }, + "additionalProperties": false + }, + "metadata": { + "type": "object", + "maxProperties": 16, + "additionalProperties": { + "type": "string", + "maxLength": 512 + }, + "description": "String map; keys up to 64 UTF-16 code units, values up to 512. null clears metadata in requests.", + "nullable": true + } + } + }, + "Environment": { + "type": "object", + "required": [ + "object", + "id", + "agent_id", + "type", + "isolation", + "container", + "display" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "node.environment" + ] + }, + "id": { + "type": "string" + }, + "agent_id": { + "$ref": "#/components/schemas/Identifier" + }, + "type": { + "type": "string", + "enum": [ + "grok_node", + "grok_node_codex" + ], + "description": "grok_node_codex selects Mac Codex Harness with the original shared GrokNode box. grok_node is the explicit legacy gateway backend." + }, + "isolation": { + "type": "string", + "enum": [ + "shared_container_separate_display", + "per_bot_container" + ], + "description": "Different desktops with shared container/data. Display allocation and API bot grants do not isolate Linux files between bots." + }, + "container": { + "type": "string" + }, + "display": { + "type": "integer", + "minimum": 1, + "maximum": 999 + } + } + }, + "Event": { + "type": "object", + "required": [ + "id", + "type", + "session_id", + "created_at", + "data" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "node.session.created", + "node.session.input.accepted", + "node.session.input.rejected", + "node.session.input.unknown", + "node.desktop.authorization.created", + "node.session.items.changed", + "node.session.closed", + "node.harness.event", + "node.harness.disconnected", + "node.session.turn.unknown", + "node.thread.empty_recreated", + "node.turn.processes.stopped", + "node.action.resolved", + "node.environment.recreated", + "node.environment.restored" + ] + }, + "session_id": { + "$ref": "#/components/schemas/Identifier" + }, + "created_at": { + "type": "integer" + }, + "data": { + "type": "object", + "additionalProperties": true + } + } + }, + "SubmitMessage": { + "type": "object", + "additionalProperties": false, + "required": [ + "events" + ], + "properties": { + "events": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "text" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "message" + ] + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 16000, + "pattern": "\\S", + "description": "Upper bound is measured in JavaScript UTF-16 code units" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "input" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "agent.session.input.message" + ] + }, + "input": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "role", + "content" + ], + "properties": { + "role": { + "type": "string", + "enum": [ + "user" + ] + }, + "content": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "text" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "input_text" + ] + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 16000, + "pattern": "\\S", + "description": "Upper bound is measured in JavaScript UTF-16 code units" + } + } + } + } + } + } + } + } + } + ] + } + }, + "idempotency_key": { + "$ref": "#/components/schemas/Identifier" + } + } + }, + "SubmitResult": { + "oneOf": [ + { "$ref": "#/components/schemas/InputResult" }, + { "$ref": "#/components/schemas/CancelResult" } + ] + }, + "SubmitEvent": { + "oneOf": [ + { "$ref": "#/components/schemas/SubmitMessage" }, + { "$ref": "#/components/schemas/CancelInput" } + ] + }, + "CancelInput": { + "type": "object", + "additionalProperties": false, + "required": ["events"], + "properties": { + "events": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["type", "turn_id"], + "properties": { + "type": { "type": "string", "enum": ["agent.session.input.cancel"] }, + "turn_id": { "$ref": "#/components/schemas/Identifier" } + } + } + } + } + }, + "CancelResult": { + "type": "object", + "additionalProperties": false, + "required": ["accepted", "turn_id", "completed"], + "properties": { + "accepted": { "type": "boolean", "enum": [true] }, + "turn_id": { "$ref": "#/components/schemas/Identifier" }, + "completed": { "type": "boolean", "enum": [false] } + } + }, + "InputResult": { + "type": "object", + "required": [ + "request_id", + "status", + "completed" + ], + "properties": { + "request_id": { + "$ref": "#/components/schemas/Identifier" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "accepted", + "rejected", + "unknown" + ] + }, + "completed": { + "type": "boolean", + "enum": [ + false + ] + }, + "turn_id": { + "type": "string", + "description": "Harness turn id when the backend starts one synchronously (codex backend only)" + }, + "replayed": { + "type": "boolean", + "description": "Saved response returned; backend input was not reexecuted" + } + } + }, + "AuthorizeDesktop": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": [ + "view", + "control" + ], + "default": "view" + }, + "ttl_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 60 + }, + "target": { + "$ref": "#/components/schemas/DesktopTarget", + "description": "Defaults to the whole desktop. Applications must belong to the current bot desktop." + } + } + }, + "DesktopAuthorization": { + "type": "object", + "required": [ + "object", + "agent_id", + "mode", + "display", + "url", + "expires_at", + "single_use", + "server_enforced", + "pauses_agent" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "node.desktop.authorization" + ] + }, + "agent_id": { + "$ref": "#/components/schemas/Identifier" + }, + "mode": { + "type": "string", + "enum": [ + "view", + "control" + ] + }, + "display": { + "type": "integer" + }, + "url": { + "type": "string", + "description": "Sensitive single-use capability URL" + }, + "expires_at": { + "type": "integer", + "description": "Unix seconds; ticket opening deadline" + }, + "single_use": { + "type": "boolean", + "enum": [ + true + ] + }, + "server_enforced": { + "type": "boolean", + "enum": [ + true + ] + }, + "pauses_agent": { + "type": "boolean", + "enum": [ + false + ] + } + } + }, + "ViewerSettings": { + "type": "object", + "required": [ + "autoconnect", + "resize", + "reconnect", + "view_only", + "path" + ], + "properties": { + "autoconnect": { + "type": "boolean", + "enum": [ + true + ] + }, + "resize": { + "type": "string", + "enum": [ + "scale" + ] + }, + "reconnect": { + "type": "boolean", + "enum": [ + false + ] + }, + "view_only": { + "type": "boolean" + }, + "path": { + "type": "string", + "pattern": "^desktop/(lane/[A-Za-z0-9_-]+/)?socket$", + "description": "Server-selected whole-desktop or application viewer WebSocket path. Clients must use the returned setting." + } + } + }, + "IssueKey": { + "type": "object", + "additionalProperties": false, + "required": [ + "scopes", + "bot_ids" + ], + "properties": { + "user_id": { + "$ref": "#/components/schemas/Identifier", + "description": "Defaults to the caller's API user. Only the owner can issue keys for another active user." + }, + "scopes": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/Scope" + } + }, + "bot_ids": { + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Identifier" + }, + { + "type": "string", + "enum": [ + "*" + ] + } + ] + } + }, + "ttl_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400, + "default": 3600 + } + } + }, + "ApiKey": { + "type": "object", + "required": [ + "object", + "id", + "key", + "scopes", + "bot_ids", + "expires_at" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "node.api_key" + ] + }, + "id": { + "$ref": "#/components/schemas/Identifier" + }, + "key": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + } + }, + "bot_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "expires_at": { + "type": "integer" + } + } + }, + "AgentList": { + "type": "object", + "required": [ + "object", + "data", + "has_more", + "first_id", + "last_id" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Agent" + } + }, + "has_more": { + "type": "boolean" + }, + "first_id": { + "type": "string", + "nullable": true + }, + "last_id": { + "type": "string", + "nullable": true + } + } + }, + "SessionList": { + "type": "object", + "required": [ + "object", + "data", + "has_more", + "first_id", + "last_id" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session" + } + }, + "has_more": { + "type": "boolean" + }, + "first_id": { + "type": "string", + "nullable": true + }, + "last_id": { + "type": "string", + "nullable": true + } + } + }, + "EventList": { + "type": "object", + "required": [ + "object", + "data", + "has_more", + "first_id", + "last_id" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Event" + } + }, + "has_more": { + "type": "boolean" + }, + "first_id": { + "type": "string", + "nullable": true + }, + "last_id": { + "type": "string", + "nullable": true + } + } + }, + "ItemList": { + "type": "object", + "required": [ + "object", + "data", + "has_more", + "context", + "first_id", + "last_id" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": {} + }, + "has_more": { + "type": "boolean" + }, + "context": { + "type": "string", + "enum": [ + "grok_node_bot_transcript" + ] + }, + "first_id": { + "type": "string", + "nullable": true + }, + "last_id": { + "type": "string", + "nullable": true + } + } + }, + "Error": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "type", + "message" + ], + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + }, + "Metadata": { + "type": "object", + "maxProperties": 16, + "additionalProperties": { + "type": "string", + "maxLength": 512 + }, + "description": "String map; keys up to 64 UTF-16 code units, values up to 512. null clears metadata in requests." + }, + "Capabilities": { + "type": "object", + "required": [ + "object", + "version", + "backend", + "writes_enabled", + "sessions", + "events", + "environment", + "security", + "desktop", + "project", + "clipboard", + "service" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "node.api.capabilities" + ] + }, + "version": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "API implementation version, consistent with health and the CLI banner." + }, + "backend": { + "type": "string", + "enum": [ + "grok_node", + "codex_harness" + ] + }, + "writes_enabled": { + "type": "boolean" + }, + "sessions": { + "type": "object", + "properties": { + "context": { + "type": "string" + }, + "independent_conversations": { + "type": "boolean", + "description": "False on the legacy backend (one shared transcript per bot); true on the codex backend (independent Harness threads)" + }, + "metadata": { + "type": "boolean" + }, + "close": { + "type": "boolean" + } + } + }, + "events": { + "type": "object", + "properties": { + "sse": { + "type": "boolean" + }, + "resume": { + "type": "string" + }, + "turn_outcomes": { + "type": "boolean", + "description": "True on the codex backend (running/completed/failed/interrupted/cancelled/unknown); input acceptance state only on the legacy backend" + }, + "cancellation": { + "type": "boolean", + "description": "True on the codex backend (turn/interrupt); unavailable on the legacy backend" + }, + "required_actions": { + "type": "boolean", + "description": "True on the codex backend (pending approvals readable and answerable); explicitly unsupported on the legacy backend" + }, + "tool_results": { + "type": "boolean", + "enum": [ + false + ] + } + } + }, + "environment": { + "type": "object", + "properties": { + "provisioning": { + "type": "boolean", + "description": "False for the published shared GrokNode backend: creating a Harness thread does not provision a container. GrokNode owns box lifecycle management." + }, + "isolation": { + "type": "string" + } + } + }, + "security": { + "type": "object", + "properties": { + "loopback_only": { + "type": "boolean", + "enum": [ + true + ] + }, + "desktop_server_enforced": { + "type": "boolean", + "enum": [ + true + ] + }, + "legacy_vnc_protected": { + "type": "boolean", + "description": "False for both published shared-container backends. Authorization protects this API's viewer routes; original GrokNode VNC ports retain their existing access policy." + } + } + }, + "desktop": { + "type": "object", + "description": "Viewer issuance; control additionally needs a per-bot lease and desktop.control scope", + "properties": { + "view": { + "type": "boolean" + }, + "control": { + "type": "boolean" + }, + "applications": { + "type": "boolean" + } + } + }, + "project": { + "type": "object", + "description": "Codex-backend project operations; absent/false on the legacy backend", + "properties": { + "diff": { + "type": "boolean" + }, + "import": { + "type": "boolean" + }, + "export": { + "type": "boolean" + } + } + }, + "clipboard": { + "type": "object", + "description": "Codex-backend manual clipboard; reads need desktop.view, writes need desktop.control plus the control lease", + "properties": { + "read": { + "type": "boolean" + }, + "write": { + "type": "boolean" + } + } + }, + "service": { + "type": "object", + "description": "Governance quotas, audit journal and webhook outbox are always on", + "properties": { + "quotas": { + "type": "boolean" + }, + "audit": { + "type": "boolean" + }, + "webhooks": { + "type": "boolean" + } + } + } + } + }, + "Usage": { + "type": "object", + "properties": { + "requests": { + "type": "integer", + "description": "Requests counted in the current rate window" + }, + "streams": { + "type": "integer", + "description": "Live SSE and desktop streams held by this principal (limit 4)" + }, + "storageBytes": { + "type": "integer", + "description": "Logical storage reservation bytes, including session state and export artifacts" + }, + "limits": { + "type": "object", + "properties": { + "requests": { + "type": "integer" + }, + "windowMs": { + "type": "integer" + }, + "streams": { + "type": "integer" + }, + "storageBytes": { + "type": "integer" + } + } + } + }, + "required": [ + "requests", + "streams", + "storageBytes", + "limits" + ] + }, + "WebhookKey": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "secret": { + "type": "string", + "description": "Base64url signing secret; provision out of band" + } + }, + "required": [ + "id", + "secret" + ] + }, + "WebhookConfiguration": { + "type": "object", + "properties": { + "destination": { + "type": "string" + }, + "signingKey": { + "$ref": "#/components/schemas/WebhookKey" + } + }, + "required": [ + "destination", + "signingKey" + ] + }, + "WebhookDelivery": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "delivering", + "retry", + "delivered", + "failed" + ] + }, + "attempts": { + "type": "integer" + }, + "nextAttemptAt": { + "type": "integer" + }, + "createdAt": { + "type": "integer" + }, + "lastError": { + "type": "string", + "enum": [ + "none", + "interrupted", + "timeout", + "transport", + "http_status" + ] + }, + "lastStatus": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "id", + "status", + "attempts", + "nextAttemptAt", + "createdAt", + "lastError", + "lastStatus" + ] + }, + "Turn": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "node.turn" + ] + }, + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "running", + "completed", + "failed", + "interrupted", + "cancelled", + "unknown" + ], + "description": "unknown means the outcome was never observed; never read it as success" + }, + "started_at": { + "type": "integer" + }, + "ended_at": { + "type": [ + "integer", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "items": { + "type": "array", + "items": {} + }, + "usage": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "request_id": { + "$ref": "#/components/schemas/Identifier" + } + }, + "required": [ + "object", + "id", + "status", + "started_at" + ] + }, + "TurnList": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Turn" + } + }, + "has_more": { + "type": "boolean" + }, + "first_id": { + "type": [ + "string", + "null" + ] + }, + "last_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "object", + "data", + "has_more", + "first_id", + "last_id" + ] + }, + "TurnTrace": { + "type": "object", + "properties": { + "turn_id": { + "type": "string" + }, + "started_at": { + "type": "integer" + }, + "ended_at": { + "type": [ + "integer", + "null" + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "completed", + "failed", + "interrupted", + "cancelled", + "unknown" + ] + }, + "usage": { + "type": [ + "object", + "null" + ] + }, + "spans": { + "type": "array", + "items": {} + } + }, + "required": [ + "turn_id", + "started_at", + "status" + ] + }, + "TurnTraceList": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TurnTrace" + } + }, + "has_more": { + "type": "boolean" + } + }, + "required": [ + "object", + "data", + "has_more" + ] + }, + "RequiredAction": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "node.required_action" + ] + }, + "id": { + "type": "string" + }, + "request_id": { + "type": [ + "string", + "null" + ], + "description": "JSON-RPC request id used to answer this approval" + }, + "thread_id": { + "type": "string" + }, + "turn_id": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/tool/call" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending" + ] + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "created_at": { + "type": "integer" + } + }, + "required": [ + "object", + "id", + "type", + "status" + ] + }, + "RequiredActionList": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RequiredAction" + } + }, + "has_more": { + "type": "boolean" + }, + "first_id": { + "type": [ + "string", + "null" + ] + }, + "last_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "object", + "data", + "has_more", + "first_id", + "last_id" + ] + }, + "AnswerAction": { + "type": "object", + "additionalProperties": false, + "required": ["decision"], + "properties": { + "decision": { + "type": "string", + "enum": ["accept", "decline"] + } + } + }, + "SessionHandoff": { + "type": "object", + "properties": { + "thread_id": { + "type": "string" + }, + "bot_id": { + "type": "string" + }, + "target": { + "type": "string", + "enum": [ + "codex_cli" + ] + }, + "replayed": { + "type": "boolean", + "enum": [ + false + ] + } + }, + "required": [ + "thread_id", + "bot_id", + "target", + "replayed" + ] + }, + "ClipboardRead": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "ClipboardWrite": { + "type": "object", + "properties": { + "written": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "written" + ] + }, + "ProjectExport": { + "type": "object", + "properties": { + "artifact": { + "type": "object", + "required": [ + "id", + "name", + "size", + "sha256", + "download_url" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string", + "enum": [ + "project.tgz" + ] + }, + "size": { + "type": "integer" + }, + "sha256": { + "type": "string" + }, + "download_url": { + "type": "string" + } + } + } + }, + "required": [ + "artifact" + ] + }, + "EnvironmentBackup": { + "type": "object", + "properties": { + "backup_id": { + "type": "string" + }, + "preserved": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "source", + "uncommitted_changes", + "conversation", + "logs" + ] + } + }, + "processes_restored": { + "type": "boolean", + "enum": [ + false + ], + "description": "Running processes are never restored" + } + }, + "required": [ + "backup_id", + "preserved", + "processes_restored" + ] + }, + "EnvironmentRecreate": { + "type": "object", + "properties": { + "running": { + "type": "boolean" + }, + "preserved_files": { + "type": "boolean", + "enum": [ + true + ] + }, + "processes_restored": { + "type": "boolean", + "enum": [ + false + ] + } + }, + "required": [ + "running", + "preserved_files", + "processes_restored" + ] + }, + "EnvironmentRestore": { + "type": "object", + "properties": { + "restored": { + "type": "boolean", + "enum": [ + true + ] + }, + "processes_restored": { + "type": "boolean", + "enum": [ + false + ] + } + }, + "required": [ + "restored", + "processes_restored" + ] + }, + "User": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "node.user" + ] + }, + "id": { + "type": "string", + "description": "owner or nuser_<24 hex>" + }, + "name": { + "type": "string" + }, + "disabled": { + "type": "boolean" + }, + "created_at": { + "type": "integer" + }, + "disabled_at": { + "type": "integer" + } + }, + "required": [ + "object", + "id", + "name", + "disabled" + ] + }, + "UserList": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "has_more": { + "type": "boolean" + }, + "first_id": { + "type": [ + "string", + "null" + ] + }, + "last_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "object", + "data", + "has_more", + "first_id", + "last_id" + ] + }, + "DesktopTarget": { + "description": "Select the whole assigned desktop or one current X11 application window.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "desktop" + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "application_id" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "application" + ] + }, + "application_id": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{1,16}$" + } + } + } + ] + }, + "Application": { + "description": "A currently managed X11 window on the selected bot's GrokNode desktop. Its id can change when the application closes or the box restarts.", + "type": "object", + "required": [ + "object", + "id", + "name", + "running" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "node.application" + ] + }, + "id": { + "type": "string", + "pattern": "^0x[0-9a-f]{1,16}$", + "description": "Current X11 window id; not an authorization token." + }, + "name": { + "type": "string" + }, + "running": { + "type": "boolean" + } + } + }, + "ApplicationList": { + "type": "object", + "required": [ + "object", + "data", + "has_more" + ], + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Application" + } + }, + "has_more": { + "type": "boolean" + }, + "first_id": { + "type": [ + "string", + "null" + ] + }, + "last_id": { + "type": [ + "string", + "null" + ] + } + } + } + } + } +} diff --git a/tools/node-agent-api/pagination.mjs b/tools/node-agent-api/pagination.mjs new file mode 100644 index 0000000..19027bd --- /dev/null +++ b/tools/node-agent-api/pagination.mjs @@ -0,0 +1,13 @@ +import { ApiError, badRequest } from './errors.mjs'; + +export function page(rows, params) { + const limitText = params.get('limit'), limit = limitText === null ? 20 : Number(limitText); + const order = params.get('order') ?? 'asc'; + if (!Number.isInteger(limit) || limit < 1 || limit > 100 || (limitText !== null && !/^[0-9]+$/.test(limitText)) || !['asc', 'desc'].includes(order)) throw badRequest('limit must be 1..100 and order must be asc or desc'); + const ordered = order === 'desc' ? [...rows].reverse() : rows; + const after = params.get('after'); + const offset = after === null ? 0 : ordered.findIndex(row => row.id === after) + 1; + if (after !== null && offset === 0) throw new ApiError(400, 'invalid_cursor', 'Cursor not present in this authorized collection'); + const data = ordered.slice(offset, offset + limit); + return { object: 'list', data, has_more: offset + data.length < ordered.length, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null }; +} diff --git a/tools/node-agent-api/persistence.mjs b/tools/node-agent-api/persistence.mjs new file mode 100644 index 0000000..1bdf38f --- /dev/null +++ b/tools/node-agent-api/persistence.mjs @@ -0,0 +1,52 @@ +import { constants } from 'node:fs'; +import { open, lstat, rename, unlink } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export async function readPrivateJson(file, fallback) { + let handle; + try { + handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); + if (!(await handle.stat()).isFile()) throw new Error('State must be a regular file'); + return JSON.parse(await handle.readFile('utf8')); + } catch (error) { if (error.code === 'ENOENT') return fallback; throw error; } + finally { await handle?.close(); } +} + +export async function atomicJson(file, value) { + try { const info = await lstat(file); if (!info.isFile() || info.isSymbolicLink()) throw new Error('Unsafe state file'); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + const temporary = file + '.tmp-' + randomUUID(); + const handle = await open(temporary, 'wx', 0o600); + try { await handle.writeFile(JSON.stringify(value)); await handle.sync(); } + finally { await handle.close(); } + try { + await rename(temporary, file); + const directory = await open(dirname(file), constants.O_RDONLY); + try { await directory.sync(); } finally { await directory.close(); } + } finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); } +} + +export async function acquireStateLock(directory) { + const file = join(directory, 'service.lock'); + let handle; + try { handle = await open(file, 'wx', 0o600); } + catch (error) { + if (error.code !== 'EEXIST') throw error; + throw new Error('State directory is locked; stop its service or verify a stale service.lock before removing it'); + } + try { await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: Date.now() })); await handle.sync(); } + catch (error) { await handle.close(); await unlink(file); throw error; } + return async () => { await handle.close(); await unlink(file); }; +} + +export async function recoverServiceLocks(directory) { + for (const relative of ['service.lock', 'governance/service.lock', 'webhooks/service.lock']) { + const file = join(directory, relative), record = await readPrivateJson(file, null); + if (record === null) continue; + if (!Number.isInteger(record.pid) || record.pid < 1) throw new Error('Cannot verify stale service lock'); + try { process.kill(record.pid, 0); throw new Error('Service lock owner is still alive'); } + catch (error) { if (error.code !== 'ESRCH') throw error; } + await unlink(file); + } +} diff --git a/tools/node-agent-api/requirements.md b/tools/node-agent-api/requirements.md new file mode 100644 index 0000000..18a8bb8 --- /dev/null +++ b/tools/node-agent-api/requirements.md @@ -0,0 +1,50 @@ +# Node Agent API 当前需求 + +更新时间:2026-10-06。用户已明确修订:所有 bot,包括实验 A、B,沿用原始 GrokNode 的定义,桌面不同,容器和数据共用。本文件替代早期“每 bot 独立容器、数据卷与网络”的需求。 + +## 原程序不变 + +Node Agent API 是独立的外部服务,通过 GrokNode 已存在的接口读取 bot、桌面和运行时映射,再封装 API。不得修改原始 GrokNode 源码、Electron 包、登录与启动流程、bot 管理、桌面分配或盒子管理逻辑;不得注入补丁、替换 host/coordinator/daemon,或把 API 打进原 App。 + +API 不得改写原程序的设置、凭据和用户数据格式,也不得以 API 启动为由重启、重建或替换原容器。此前允许实验副本修改盒子管理和工具路由的约束已被本条取代。 + +允许继续维护独立 API、网页、CLI 包装与 Codex Harness 适配代码。原先已授权在 Linux 中安装的 Codex/exec-server 作为单独的附加进程管理,不能替换 GrokNode 的启动包、supervisor 或原有服务。 + +既有接口不足以支持某项能力时,记录限制并提供外部适配方案,不能通过改原程序补齐。已有实验副本源码改动保留为历史原型,不部署、不合入原程序;不能为了满足新约束直接丢弃旧修改。 + +## 目标行为 + +1. GrokNode 管理 bot 身份及桌面分配。各 bot 共用同一盒子容器和数据;bot 数量增加不应相应创建容器、卷或网络。 +2. 每个 bot 使用 GrokNode 分配的桌面。网页、CLI、GrokNode 指向同一 bot 的同一桌面;复用现有分配与归属校验,不另建一套桌面编号规则。 +3. 数据与项目按原始 GrokNode 语义共享。不强制建立每 bot 私有数据卷或私有项目目录,不承诺阻止 bot 通过 Linux 工具访问其他 bot 的共享文件。 +4. Codex Harness、模型调用、对话与模型凭据留在 Mac。共享 Linux 盒子承担 shell、文件修改、构建和测试;执行端不可用时工具失败,不回落 Mac。 +5. 网页和 CLI 支持真实项目的对话、改代码、测试、审查和导出;保留各自会话身份,不能把多个 bot 的对话混成一个 thread。 +6. 封装整桌面和单应用窗口的 VNC 观看与实时操作。窗口必须来自该 bot 的实际桌面,并沿用短期授权、明确接管与交还。桌面区分是交互归属,不是 Linux 安全隔离。 +7. 剪贴板只在用户明确操作时传递,默认无自动同步。 +8. 文件、未提交修改、会话与日志可备份和恢复。运行中的进程按中断或未知处理,用户决定重跑;不承诺恢复进程内存。 +9. 多用户、API 权限、密钥轮换、配额、审计、签名 webhook 和 trace/usage 保留。API 的 bot 范围限制控制接口访问,不代表共享 Linux 文件系统提供 bot 间安全隔离。 + +## 保留的安全边界 + +隔离对象是 Linux 执行端与 Mac。Mac 模型凭据、主配置和宿主文件不能因为 API 接入而暴露给 Linux 执行端。现有共享盒子的宿主凭据挂载需要单独核实;“数据共用”不能自动解释为允许复制 Mac 模型凭据。 + +取消某一回合应使用 Harness 的取消机制,不能停止共享容器来代替取消。重建或维护共享盒子会影响所有 bot,应报告影响范围并先保全数据。 + +## 验收 + +- 实验 A、B 使用同一容器与同一共享数据区;它们的桌面身份不同,并与 GrokNode 分配结果一致。 +- 从网页和 CLI 选择同一 bot,看到同一桌面及其真实应用窗口,执行代码落在共享 Linux 项目中。 +- 真实项目完成改代码、安装依赖、测试、差异审查与导出,未提交修改得到保全。 +- 观看、接管、交还、单应用窗口与手动剪贴板通过真实浏览器验收。 +- 断线、API 重启及盒子损坏后恢复文件、会话和日志,没有自动重放任务。 +- 执行端无法读取 Mac 模型凭据;远端失联后没有 Mac 工具回落。 +- API 用户、授权、轮换、配额和 webhook 通过接口与实际服务测试;未知结果保持未知。 +- README、OpenAPI、差距分析描述实际实现与证据,不把旧的双容器验收当作共享盒子的验收。 + +## 现有原型与迁移 + +当前默认 API 与 CLI 已使用原有共享盒子的外部适配。真实测试 bot A、B 共用 grok-node-local-vm 的数据,桌面由原 GrokNode 分配;单应用 VNC 与显式剪贴板已有验收记录。旧的每 bot 容器代码与备份保留为历史原型,不参与默认路径。 + +合并运行环境前,先保存 A、B 的共享项目候选内容、Git 历史、未提交修改、Mac thread 与日志。检查同名文件冲突,保留来源和备份,不能以覆盖文件或删除卷完成合并。 + +先用独立 API 对原程序已有接口完成适配和验证,再保全式迁移旧实验数据。整个接入过程保持原始 GrokNode 程序与管理机制不变。 diff --git a/tools/node-agent-api/rfb-filter.mjs b/tools/node-agent-api/rfb-filter.mjs new file mode 100644 index 0000000..e75f72c --- /dev/null +++ b/tools/node-agent-api/rfb-filter.mjs @@ -0,0 +1,48 @@ +import { ApiError } from './errors.mjs'; + +// Keyboard, pointer, clipboard, power, resize and extended keyboard input. +const MUTATING = new Set([4, 5, 6, 250, 251, 255]); +export function createRfbFilter(canControl) { + let state = 'version', pending = Buffer.alloc(0); + function length(buffer) { + switch (buffer[0]) { + case 0: return 20; + case 2: return buffer.length >= 4 ? 4 + 4 * buffer.readUInt16BE(2) : null; + case 3: return 10; + case 4: return 8; + case 5: return 6; + case 6: return buffer.length >= 8 ? 8 + Math.abs(buffer.readInt32BE(4)) : null; + case 150: return 10; + case 248: return buffer.length >= 9 ? 9 + buffer[8] : null; + case 250: return 4; + case 251: return buffer.length >= 8 ? 8 + 16 * buffer[6] : null; + case 255: if (buffer.length < 2) return null; if (buffer[1] !== 0) throw new ApiError(400, 'rfb_protocol', 'Unsupported extended message'); return 12; + default: throw new ApiError(400, 'rfb_protocol', 'Unsupported RFB message'); + } + } + return chunk => { + pending = Buffer.concat([pending, Buffer.from(chunk)]); + if (pending.length > 1024 * 1024) throw new ApiError(413, 'rfb_limit', 'RFB input exceeds limit'); + const result = []; + while (pending.length) { + let size; + switch (state) { + case 'version': size = 12; break; + case 'security': size = 1; break; + case 'init': size = 1; break; + case 'normal': size = length(pending); break; + default: throw new Error('Invalid RFB filter state'); + } + if (size == null || pending.length < size) break; + if (size > 1024 * 1024) throw new ApiError(413, 'rfb_limit', 'RFB message exceeds limit'); + const message = pending.subarray(0, size); pending = pending.subarray(size); + switch (state) { + case 'version': if (message.toString('ascii') !== 'RFB 003.008\n') throw new ApiError(400, 'rfb_protocol', 'RFB 3.8 required'); state = 'security'; result.push(message); break; + case 'security': if (message[0] !== 1) throw new ApiError(400, 'rfb_protocol', 'Unexpected upstream security type'); state = 'init'; result.push(message); break; + case 'init': state = 'normal'; result.push(message); break; + case 'normal': if (!MUTATING.has(message[0]) || canControl()) result.push(message); break; + } + } + return result; + }; +} diff --git a/tools/node-agent-api/runtime/applications.mjs b/tools/node-agent-api/runtime/applications.mjs new file mode 100644 index 0000000..e395396 --- /dev/null +++ b/tools/node-agent-api/runtime/applications.mjs @@ -0,0 +1,277 @@ +import { spawn as defaultSpawn, execFile as defaultExecFile } from 'node:child_process'; +import { createServer, createConnection as defaultCreateConnection } from 'node:net'; + +export const APPLICATION_ID_RE = /^0x[0-9a-f]{1,16}$/; + +export function normalizeApplicationId(value) { + if (typeof value !== 'string') return null; + const id = value.toLowerCase(); + return APPLICATION_ID_RE.test(id) ? id : null; +} + +export function parseClientList(output) { + const marker = output.indexOf('#'); + const source = marker >= 0 ? output.slice(marker + 1) : output; + return [...source.matchAll(/0x[0-9a-f]+/gi)].map(match => match[0].toLowerCase()); +} + +function unquoteXprop(value) { + if (!(value.startsWith('"') && value.endsWith('"'))) return value.trim(); + return value.slice(1, -1).replace(/\\([^0-7]|[0-7]{3})/g, (_, escape) => { + if (/[0-7]{3}/.test(escape)) return String.fromCodePoint(Number.parseInt(escape, 8)); + return { n: '\n', r: '\r', t: '\t', '\\': '\\', '"': '"', "'": "'", a: '\x07', b: '\b', s: ' ' }[escape] ?? ''; + }); +} + +export function parseWindowName(output) { + const lines = output.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!/^(?:_NET_WM_NAME|WM_NAME)\([^)]*\)\s*=/.test(line)) continue; + let value = line.split('=').slice(1).join('=').trim(); + while (value.startsWith('"') && !value.endsWith('"') && i + 1 < lines.length) value += lines[++i].trim(); + return unquoteXprop(value); + } + return ''; +} + +export function parseWmctrlList(output) { + const rows = new Map(); + for (const line of output.split(/\r?\n/)) { + const match = /^(0x[0-9a-f]+)\s+\S+\s+\S+\s+(.*)$/i.exec(line); + if (match) rows.set(match[1].toLowerCase(), match[2]); + } + return rows; +} + +export function sanitizeApplicationName(value, fallback = 'Application') { + const text = String(value ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim(); + const clipped = Array.from(text).slice(0, 180).join(''); + return clipped || fallback; +} + +export function parseWindowTree(output) { + const windows = new Map(); + for (const line of output.split(/\r?\n/)) { + const match = /^\s{5}(0x[0-9a-f]+) "(.*?)":.*?\s(\d+)x(\d+)[+-]/i.exec(line); + if (match && Number(match[3]) > 150 && Number(match[4]) > 100 && !/^(plank|picom)$/i.test(match[2])) windows.set(match[1].toLowerCase(), match[2]); + } + return windows; +} + +function exec(execFileImpl, file, args, options) { + return new Promise((resolve, reject) => { + execFileImpl(file, args, { encoding: 'utf8', maxBuffer: 1024 * 1024, ...options }, (error, stdout, stderr) => { + if (error) reject(Object.assign(error, { stdout, stderr })); + else resolve({ stdout, stderr }); + }); + }); +} + +export async function listApplications({ + display = ':1', + execFileImpl = defaultExecFile, + sanitize = false, +} = {}) { + const env = { ...process.env, DISPLAY: display }; + let ids = []; + try { + const root = await exec(execFileImpl, 'xprop', ['-root', '_NET_CLIENT_LIST'], { env }); + ids = parseClientList(root.stdout); + } catch {} + + let wmRows = new Map(); + if (!ids.length) { + try { + const wm = await exec(execFileImpl, 'wmctrl', ['-l'], { env }); + wmRows = parseWmctrlList(wm.stdout); + ids = [...wmRows.keys()]; + } catch {} + } + + if (!ids.length) { + try { const tree = await exec(execFileImpl, 'xwininfo', ['-root', '-tree'], { env }); wmRows = parseWindowTree(tree.stdout); ids = [...wmRows.keys()]; } catch {} + } + + const applications = []; + for (const applicationId of ids.map(normalizeApplicationId).filter(Boolean)) { + let name = wmRows.get(applicationId) ?? ''; + if (!name) { + try { + const props = await exec(execFileImpl, 'xprop', ['-id', applicationId, '_NET_WM_NAME', 'WM_NAME'], { env }); + name = parseWindowName(props.stdout); + } catch {} + } + applications.push({ object: 'desktop.application', application_id: applicationId, name: sanitize ? sanitizeApplicationName(name, `Application ${applicationId}`) : name }); + } + return applications; +} + +async function ephemeralPort() { + const server = createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + return port; +} + +function waitForPort(port, signal = {}) { + return new Promise((resolve, reject) => { + const deadline = Date.now() + 5000; + const timer = setInterval(() => { + if (signal.exited) { clearInterval(timer); reject(new Error('x11vnc exited before listening')); return; } + if (Date.now() > deadline) { clearInterval(timer); reject(new Error('x11vnc readiness timed out')); return; } + const socket = signal.createConnection(port, '127.0.0.1'); + socket.setTimeout(250); + socket.once('connect', () => { socket.destroy(); clearInterval(timer); resolve(); }); + socket.once('error', () => socket.destroy()); + socket.once('timeout', () => socket.destroy()); + }, 100); + }); +} + +export function createApplicationPool({ + display = ':1', + maxProcesses = 8, + idleTtlMs = 300_000, + absoluteTtlMs = 900_000, + spawnImpl = defaultSpawn, + execFileImpl = defaultExecFile, + createConnectionImpl = defaultCreateConnection, + portImpl = ephemeralPort, +} = {}) { + const entries = new Map(); + const connecting = new Map(); + + function remove(entry) { + clearTimeout(entry.absoluteTimer); + clearTimeout(entry.idleTimer); + if (entries.get(entry.key) === entry) entries.delete(entry.key); + } + + async function kill(entry, signal = 'SIGTERM') { + if (entry.killing) return entry.exitPromise; + entry.killing = true; + remove(entry); + entry.exitPromise = new Promise(resolve => { + const finish = () => { clearTimeout(forceTimer); resolve(); }; + const forceTimer = setTimeout(() => { + try { entry.child.kill('SIGKILL'); } catch {} + }, 2000); + forceTimer.unref(); + entry.child.once('exit', finish); + try { entry.child.kill(signal); } catch { finish(); } + }); + return entry.exitPromise; + } + + async function createLane(mode, applicationId, key) { + const current = await listApplications({ display, execFileImpl }); + if (!current.some(app => app.application_id === applicationId)) { + throw Object.assign(new Error('Application window is no longer available'), { status: 404, code: 'application_not_found' }); + } + const rfbport = await portImpl(); + const args = ['-display', display, '-id', applicationId, '-rfbport', String(rfbport), '-localhost', '-nopw', '-forever', '-shared', '-noxdamage']; + if (mode === 'view') args.push('-viewonly'); + args.push('-nosel', '-noclipboard', '-nosetclipboard', '-nosetprimary'); + const child = spawnImpl('x11vnc', args, { stdio: ['ignore', 'ignore', 'ignore'] }); + const entry = { + key, mode, applicationId, port: rfbport, child, clients: 0, + createdAt: Date.now(), lastUsed: Date.now(), killing: false, starting: true, + idleTimer: null, absoluteTimer: null, exitPromise: null, + }; + entries.set(key, entry); + let exited = false; + const markExited = () => { exited = true; remove(entry); }; + child.once('exit', markExited); + child.once('error', markExited); + entry.absoluteTimer = setTimeout(() => { void kill(entry); }, absoluteTtlMs); + entry.absoluteTimer.unref(); + try { + await waitForPort(rfbport, { get exited() { return exited; }, createConnection: createConnectionImpl }); + } catch (error) { + await kill(entry); + throw error; + } + return entry; + } + + async function evictIdle() { + const idle = [...entries.values()] + .filter(entry => entry.clients === 0 && !entry.killing && !entry.starting) + .sort((a, b) => a.lastUsed - b.lastUsed); + if (!idle.length) { + throw Object.assign(new Error('Application viewer capacity reached'), { status: 503, code: 'application_capacity' }); + } + await kill(idle[0]); + } + + function activeCount() { + return new Set([...entries.keys(), ...connecting.keys()]).size; + } + + function loadExcept(currentKey) { + return new Set([...entries.keys(), ...connecting.keys()].filter(key => key !== currentKey)).size; + } + + async function connect(applicationId, mode = 'view') { + const id = normalizeApplicationId(applicationId); + if (!id) throw Object.assign(new Error('Invalid application id'), { status: 400, code: 'application_invalid' }); + if (!['view', 'control'].includes(mode)) { + throw Object.assign(new Error('Invalid application mode'), { status: 400, code: 'application_mode_invalid' }); + } + const key = `${mode}:${id}`; + const pending = connecting.get(key); + if (pending) return finishConnect(await pending); + + const promise = (async () => { + const existing = entries.get(key); + if (existing && !existing.killing) { + const current = await listApplications({ display, execFileImpl }); + if (!current.some(app => app.application_id === id)) { + await kill(existing); + } else { + return existing; + } + } + while (loadExcept(key) >= maxProcesses) await evictIdle(); + const entry = await createLane(mode, id, key); + return entry; + })(); + + connecting.set(key, promise); + try { return finishConnect(await promise); } + finally { if (connecting.get(key) === promise) connecting.delete(key); } + } + + function finishConnect(entry) { + entry.starting = false; + entry.clients += 1; + entry.lastUsed = Date.now(); + clearTimeout(entry.idleTimer); + let released = false; + return { + port: entry.port, + mode: entry.mode, + release() { + if (released) return; + released = true; + entry.clients = Math.max(0, entry.clients - 1); + entry.lastUsed = Date.now(); + clearTimeout(entry.idleTimer); + if (!entry.killing && entry.clients === 0) { + entry.idleTimer = setTimeout(() => { void kill(entry); }, idleTtlMs); + entry.idleTimer.unref(); + } + }, + }; + } + + return { + connect, + list: options => listApplications({ display, execFileImpl, sanitize: true, ...options }), + get size() { return entries.size; }, + get pendingSize() { return connecting.size; }, + async close() { await Promise.allSettled([...entries.values()].map(entry => kill(entry))); }, + }; +} diff --git a/tools/node-agent-api/runtime/codex.mjs b/tools/node-agent-api/runtime/codex.mjs new file mode 100644 index 0000000..495286f --- /dev/null +++ b/tools/node-agent-api/runtime/codex.mjs @@ -0,0 +1,59 @@ +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { readFile, writeFile, lstat, symlink, readlink } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { privateDirectory, resourceNames } from './common.mjs'; + +const execute = promisify(execFile); +export async function prepareCodex({ namespace, botId, stateRoot, runtime, codexBinary = 'codex', authFile = join(homedir(), '.codex/auth.json'), model, baseUrl }) { + const n = resourceNames(namespace, botId); + const home = await privateDirectory(join(stateRoot, namespace, n.key, 'mac-codex')); + const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home, CODEX_HOME: home, TMPDIR: process.env.TMPDIR ?? '/tmp', TERM: process.env.TERM ?? 'xterm-256color', OCX_SHIM_BYPASS: '1', NO_PROXY: '127.0.0.1,localhost,::1', no_proxy: '127.0.0.1,localhost,::1' }; + const version = await execute(codexBinary, ['--version'], { env, encoding: 'utf8' }); + if (version.stdout.trim() !== 'codex-cli 0.160.0') throw new Error('Local Codex must be exactly 0.160.0'); + const status = await runtime.status(botId); + if (!status.running) throw new Error('Bot environment unavailable'); + const registry = await runtime.descriptor(botId); + const auth = resolve(authFile); + if (!(await lstat(auth)).isFile()) throw new Error('Mac auth file is missing'); + const link = join(home, 'auth.json'); + try { + if (!(await lstat(link)).isSymbolicLink() || await readlink(link) !== auth) throw new Error('Unexpected Mac auth link'); + } catch (error) { if (error.code !== 'ENOENT') throw error; await symlink(auth, link); } + const inferenceUrl = new URL(baseUrl ?? 'http://127.0.0.1:10100/v1').href; + const config = ['approval_policy = "on-request"', 'sandbox_mode = "danger-full-access"', 'suppress_unstable_features_warning = true', + `model = ${JSON.stringify(model ?? 'gpt-6.1-sol')}`, 'model_provider = "opencodex"', 'model_reasoning_effort = "medium"', + `openai_base_url = ${JSON.stringify(inferenceUrl)}`, `model_catalog_json = ${JSON.stringify(join(homedir(), '.codex/opencodex-catalog.json'))}`, + '[model_providers.opencodex]', 'name = "Existing local opencodex proxy"', `base_url = ${JSON.stringify(inferenceUrl)}`, 'wire_api = "responses"', 'requires_openai_auth = false', 'supports_websockets = false', + '[features]', 'apps = false', 'plugins = false', 'remote_plugin = false', 'shell_snapshot = false', 'enable_request_compression = false', 'respect_system_proxy = false', 'system_proxy_fallback = false', + '[shell_environment_policy]', 'inherit = "none"', 'include_only = []', registry.environments[0].display ? `set = { DISPLAY = ${JSON.stringify(':' + registry.environments[0].display)} }` : 'set = {}', '']; + const entry = registry.environments[0]; + const transport = entry.transport === 'stdio' + ? [`program = ${JSON.stringify(entry.program)}`, `args = ${JSON.stringify(entry.args)}`] + : [`url = ${JSON.stringify(status.execServerUrl)}`, `auth_bearer_token = ${JSON.stringify(entry.authToken)}`, 'connect_timeout_sec = 3']; + const environments = ['default = "bot"', 'include_local = false', '[[environments]]', 'id = "bot"', ...transport, 'initialize_timeout_sec = 20', '']; + for (const [file, lines] of [['config.toml', config], ['environments.toml', environments]]) { + const path = join(home, file); + try { if (!(await lstat(path)).isFile()) throw new Error('Unexpected Codex configuration file'); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + await writeFile(path, lines.join('\n'), { mode: 0o600 }); + } + return { binary: codexBinary, env, cwd: home, args: ['--strict-config', '--cd', runtime.workspaceRoot ?? '/workspace'] }; +} + +export async function launchCodex(options, prompt) { + await options.runtime.ensure(options.botId); + const command = await prepareCodex(options); + return new Promise((resolve, reject) => { + const subcommand = options.threadId + ? prompt === undefined ? ['resume', options.threadId] : ['exec', 'resume', '--skip-git-repo-check', options.threadId, prompt] + : prompt === undefined ? [] : ['exec', '--skip-git-repo-check', '--', prompt]; + // The CLI canonicalizes --cd on Mac before remote bootstrap. The shared + // stdio executor already starts at the selected Linux project directory. + const launchArgs = options.runtime.shared ? ['--strict-config'] : command.args; + const child = spawn(command.binary, [...launchArgs, ...subcommand], { env: command.env, cwd: command.cwd, stdio: 'inherit' }); + child.on('error', () => reject(new Error('Unable to launch local Codex'))); + child.on('exit', (code, signal) => resolve(signal ? 1 : code ?? 1)); + }); +} diff --git a/tools/node-agent-api/runtime/common.mjs b/tools/node-agent-api/runtime/common.mjs new file mode 100644 index 0000000..2eef89b --- /dev/null +++ b/tools/node-agent-api/runtime/common.mjs @@ -0,0 +1,27 @@ +import { createHash } from 'node:crypto'; +import { mkdir, chmod, lstat } from 'node:fs/promises'; +import { resolve, join, dirname } from 'node:path'; +import { homedir } from 'node:os'; + +export const OWNER = 'com.groknode.lab.namespace'; +export const BOT = 'com.groknode.lab.bot-id'; + +// These names identify private API state; no container management occurs here. +export function resourceNames(namespace, botId) { + if (!/^grok-node-lab-[a-z0-9][a-z0-9-]{0,30}$/.test(namespace)) throw new Error('Invalid API namespace'); + if (typeof botId !== 'string' || !botId.trim() || botId.length > 512 || /[\x00-\x1f\x7f]/.test(botId)) throw new Error('Invalid botId'); + const key = createHash('sha256').update(botId).digest('hex').slice(0, 32), name = `${namespace}-${key}`; + return { key, container: name, network: `${name}-net`, workspace: `${name}-workspace`, data: `${name}-data`, runtime: `${name}-runtime` }; +} + +export async function privateDirectory(directory) { + const absolute = resolve(directory); + for (let current = absolute; ; current = dirname(current)) { + try { if ((await lstat(current)).isSymbolicLink()) throw new Error('State paths cannot contain symlinks'); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + if (dirname(current) === current) break; + } + const home = homedir(), protectedPaths = ['.codex', '.codex-vm', '.grokbot', '.groknode'].map(p => join(home, p)); + if (absolute === '/' || absolute === home || protectedPaths.some(p => absolute === p || absolute.startsWith(p + '/') || p.startsWith(absolute + '/'))) throw new Error('State path overlaps production'); + await mkdir(absolute, { recursive: true, mode: 0o700 }); await chmod(absolute, 0o700); return absolute; +} diff --git a/tools/node-agent-api/runtime/desktop-proxy.mjs b/tools/node-agent-api/runtime/desktop-proxy.mjs new file mode 100644 index 0000000..ee18672 --- /dev/null +++ b/tools/node-agent-api/runtime/desktop-proxy.mjs @@ -0,0 +1,118 @@ +import http from 'node:http'; +import net from 'node:net'; +import { createHash, timingSafeEqual } from 'node:crypto'; +import { WebSocket, WebSocketServer } from 'ws'; +import { createApplicationPool, normalizeApplicationId } from './applications.mjs'; + +function constantTokenHash(value) { + return createHash('sha256').update(typeof value === 'string' ? value : '').digest(); +} + +function bearerHash(req) { + const value = req.headers.authorization; + return constantTokenHash(typeof value === 'string' && value.startsWith('Bearer ') ? value.slice(7) : ''); +} + +function authorized(req, expected) { + const actual = bearerHash(req); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function bridgeWebSocket(client, target, releaseTarget = () => {}) { + const upstream = net.connect(target, '127.0.0.1'); + let queued = [], queuedBytes = 0, ready = false, closed = false; + const close = () => { + if (closed) return; + closed = true; + client.terminate(); + upstream.destroy(); + releaseTarget(); + }; + upstream.on('connect', () => { + ready = true; + for (const bytes of queued) upstream.write(bytes); + queued = []; + queuedBytes = 0; + }); + client.on('message', bytes => { + if (ready && upstream.writableLength < 1024 * 1024) upstream.write(bytes); + else if (!ready && (queuedBytes += bytes.length) <= 1024 * 1024) queued.push(bytes); + else close(); + }); + upstream.on('data', bytes => { + if (client.readyState === WebSocket.OPEN && client.bufferedAmount < 4 * 1024 * 1024) client.send(bytes); + else close(); + }); + upstream.on('error', close); + upstream.on('close', close); + client.on('error', close); + client.on('close', close); +} + +export function startDesktopProxy({ + token, + port, + desktopTarget = 5900, + applicationPool = createApplicationPool(), + host = '0.0.0.0', + mode = 'view', +} = {}) { + if (!token || !Number.isInteger(port)) throw new Error('token and port are required'); + if (!['view', 'control'].includes(mode)) throw new Error('mode must be view or control'); + const expected = constantTokenHash(token); + const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 }); + const server = http.createServer(async (req, res) => { + if (!authorized(req, expected)) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { type: 'unauthorized', message: 'Desktop authorization required' } })); + return; + } + const url = new URL(req.url, 'http://localhost'); + if (req.method === 'GET' && url.pathname === '/applications') { + try { + const data = await applicationPool.list(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ object: 'desktop.applications', data })); + } catch { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { type: 'applications_unavailable', message: 'Window discovery failed' } })); + } + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { type: 'not_found', message: 'Route not found' } })); + }); + + server.on('upgrade', (req, socket, head) => { + const url = new URL(req.url, 'http://localhost'); + const reject = status => socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); + if (!authorized(req, expected)) return reject('401 Unauthorized'); + if (url.pathname === '/desktop') { + return wss.handleUpgrade(req, socket, head, client => bridgeWebSocket(client, desktopTarget)); + } + const match = /^\/application\/([^/]+)$/.exec(url.pathname); + const applicationId = match ? normalizeApplicationId(decodeURIComponent(match[1])) : null; + if (!applicationId) return reject('404 Not Found'); + applicationPool.connect(applicationId, mode).then(lane => { + wss.handleUpgrade(req, socket, head, client => bridgeWebSocket(client, lane.port, lane.release)); + }, error => { + const status = error?.status === 400 ? '400 Bad Request' + : error?.status === 503 ? '503 Unavailable' + : '404 Not Found'; + reject(status); + }); + }); + + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => resolve({ + port: server.address().port, + async close() { + await applicationPool.close?.(); + server.closeAllConnections?.(); + server.closeIdleConnections?.(); + await new Promise(resolve => server.close(resolve)); + }, + })); + }); +} diff --git a/tools/node-agent-api/runtime/harness.mjs b/tools/node-agent-api/runtime/harness.mjs new file mode 100644 index 0000000..2214e8b --- /dev/null +++ b/tools/node-agent-api/runtime/harness.mjs @@ -0,0 +1,132 @@ +import { spawn } from 'node:child_process'; +import { isAbsolute } from 'node:path'; +import { EventEmitter } from 'node:events'; +import { prepareCodex } from './codex.mjs'; + +export const RPC_METHODS = new Set(['environment/info', 'thread/start', 'thread/list', 'thread/read', 'thread/resume', 'thread/unsubscribe', 'thread/backgroundTerminals/terminate', 'turn/start', 'turn/interrupt']); +const environments = [{ environmentId: 'bot', cwd: '/workspace', runtimeWorkspaceRoots: ['/workspace'] }]; +const approvedShellTool = { type: 'function', name: 'node_approved_shell', description: 'Execute a shell command in this bot Linux workspace only after explicit user approval. A declined request performs no command.', inputSchema: { type: 'object', additionalProperties: false, required: ['command'], properties: { command: { type: 'string', minLength: 1, maxLength: 8192 } } }, deferLoading: false }; +export function validateBotId(id) { + if (typeof id !== 'string' || !/^[\p{L}\p{N}][\p{L}\p{N}_-]{0,127}$/u.test(id)) throw new Error('Invalid botId'); + return id; +} +export function rpcParams(method, params = {}, workspaceRoot = '/workspace') { + if (!RPC_METHODS.has(method)) throw new Error('RPC method unavailable'); + const fields = { + 'environment/info': [], 'thread/start': [], 'thread/list': ['cursor'], + 'thread/read': ['threadId'], 'thread/resume': ['threadId'], 'thread/unsubscribe': ['threadId'], + 'thread/backgroundTerminals/terminate': ['threadId', 'processId'], + 'turn/start': ['threadId', 'input'], 'turn/interrupt': ['threadId', 'turnId'], + }[method]; + if (!params || Array.isArray(params) || typeof params !== 'object' || Object.keys(params).some(k => !fields.includes(k))) throw new Error('Invalid RPC parameters'); + for (const key of fields.filter(k => k.endsWith('Id'))) if (typeof params[key] !== 'string' || !/^[\w-]{1,128}$/.test(params[key])) throw new Error('Invalid RPC identifier'); + if (params.cursor != null && (typeof params.cursor !== 'string' || params.cursor.length > 2048)) throw new Error('Invalid cursor'); + if (method === 'thread/backgroundTerminals/terminate' && !/^[0-9]{1,10}$/.test(params.processId)) throw new Error('Invalid process identifier'); + if (method === 'turn/start' && (!Array.isArray(params.input) || params.input.length !== 1 || params.input[0]?.type !== 'text' || typeof params.input[0].text !== 'string' || !params.input[0].text.trim() || params.input[0].text.length > 64000 || Object.keys(params.input[0]).some(k => !['type', 'text'].includes(k)))) throw new Error('Invalid text input'); + if (method === 'environment/info') return { environmentId: 'bot' }; + if (method === 'thread/start' || method === 'turn/start') return { ...params, environments: workspaceRoot === '/workspace' ? environments : [{ environmentId: 'bot', cwd: workspaceRoot, runtimeWorkspaceRoots: [workspaceRoot] }], ...(method === 'thread/start' ? { experimentalRawEvents: false, dynamicTools: [approvedShellTool] } : {}) }; + if (method === 'thread/read') return { ...params, includeTurns: true }; + if (method === 'thread/list') return { ...params, limit: 50 }; + return params; +} + +export function createHarness({ runtime, namespace, stateRoot, model, baseUrl, binary = 'codex', spawnProcess = spawn, timeoutMs = 60000 }) { + const events = new EventEmitter(); + const sessions = new Map(); + const secrets = new Set(); + function redact(value) { + if (typeof value === 'string') { + for (const secret of secrets) value = value.split(secret).join('[redacted]'); + return value.replace(/Bearer\s+[^\s"\\]+/gi, 'Bearer [redacted]'); + } + if (Array.isArray(value)) return value.map(redact); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k,v]) => [k, /^(?:authorization|authToken|accessToken|apiKey|secret|bearerToken)$/i.test(k) ? '[redacted]' : redact(v)])); + return value; + } + async function connect(botId) { + validateBotId(botId); + if (sessions.has(botId)) return sessions.get(botId); + const pending = start(botId); + sessions.set(botId, pending); + try { return await pending; } catch (error) { sessions.delete(botId); throw error; } + } + async function start(botId) { + await runtime.ensure(botId); + const registry = await runtime.descriptor(botId), status = await runtime.status(botId); + const entry = registry.environments[0]; + if (entry.transport === 'stdio') { + if (!isAbsolute(entry.program) || !Array.isArray(entry.args) || !entry.args.includes('exec-server') || !entry.args.includes('stdio') || !entry.args.includes('--no-new-privs')) throw new Error('Managed container stdio executor required'); + } else { + const url = new URL(status.execServerUrl); + if (url.protocol !== 'ws:' || url.hostname !== '127.0.0.1' || url.username || url.password) throw new Error('Owned loopback executor required'); + secrets.add(entry.authToken); + } + const command = await prepareCodex({ runtime, namespace, stateRoot, botId, codexBinary: binary, model, baseUrl }); + if (!isAbsolute(command.cwd)) throw new Error('Private Harness home unavailable'); + const child = spawnProcess(binary, ['app-server', '--strict-config', '-c', 'mcp_servers={}', '-c', 'features.apps=false', '-c', 'features.plugins=false'], { cwd: command.cwd, env: command.env, stdio: ['pipe','pipe','pipe'] }); + const calls = new Map(), approvals = new Map(); + let seq = 0, buffer = '', closed = false, diagnostic = ''; + const emit = message => events.emit('event', { botId, message: redact(message) }); + function stop() { + if (closed) return; + closed = true; + for (const call of calls.values()) { clearTimeout(call.timer); call.reject(new Error('Harness disconnected; outcome unknown; no replay. ' + diagnostic)); } + calls.clear(); approvals.clear(); sessions.delete(botId); + emit({ method: 'lab/status', params: { status: 'unknown', replayed: false } }); + } + child.on('error', stop); child.on('exit', stop); + child.stderr.on('data', bytes => { diagnostic = redact((diagnostic + bytes.toString()).slice(-4096)); }); + function send(message) { if (closed) throw new Error('Harness disconnected'); child.stdin.write(JSON.stringify(message) + '\n'); } + function request(method, params) { + return new Promise((resolve, reject) => { + const id = ++seq; + const timer = setTimeout(() => { calls.delete(id); reject(new Error('RPC timeout; outcome unknown; no replay')); }, timeoutMs); + calls.set(id, { resolve, reject, timer }); + try { send({ id, method, params }); } catch (error) { clearTimeout(timer); calls.delete(id); reject(error); } + }); + } + child.stdout.on('data', chunk => { + buffer += chunk.toString(); + if (buffer.length > 8 * 1024 * 1024) { child.kill(); stop(); return; } + let end; + while ((end = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + let m; + try { m = JSON.parse(line); } catch { child.kill(); stop(); return; } + if (m.method) { + if (m.id != null) approvals.set(m.id, m); + emit(m); + } else if (calls.has(m.id)) { + const call = calls.get(m.id); calls.delete(m.id); clearTimeout(call.timer); + if (m.error) call.reject(new Error(`RPC rejected (${m.error.code ?? 'unknown'}): ${redact(m.error.message ?? '')}`)); else call.resolve(redact(m.result)); + } + } + }); + try { + await request('initialize', { clientInfo: { name: 'grok-node-agent-api', version: '0.3.0' }, capabilities: { experimentalApi: true } }); + send({ method: 'initialized' }); + await request('environment/info', { environmentId: 'bot' }); + } catch (error) { child.kill(); stop(); throw error; } + return { request, approvals, send, child, codexHome: command.cwd }; + } + return { + redact, + subscribe(listener) { events.on('event', listener); return () => events.off('event', listener); }, + async request(botId, method, params) { const safe = rpcParams(method, params, runtime.workspaceRoot ?? '/workspace'); return (await connect(botId)).request(method, safe); }, + async answer(botId, id, result) { + const s = await connect(botId), approval = s.approvals.get(id); + if (!approval) throw new Error('Approval no longer pending'); + const method = approval.method; + if (!['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/tool/call'].includes(method)) throw new Error('Approval type unavailable'); + if (method === 'item/tool/call') { if (!Array.isArray(result?.contentItems) || typeof result.success !== 'boolean') throw new Error('Invalid tool response'); } + else if (!result || Object.keys(result).length !== 1 || !['accept','decline','cancel'].includes(result.decision)) throw new Error('Invalid approval answer'); + s.send({ id, result }); s.approvals.delete(id); + events.emit('event', { botId, message: { method: 'lab/approvalAnswered', params: { id, result } } }); + return { answered: true }; + }, + async pending(botId) { const s = await connect(botId); return [...s.approvals.values()].map(redact); }, + async home(botId) { return (await connect(botId)).codexHome; }, + async disconnect(botId) { const current = sessions.get(botId); if (current) { const value = await current; const exited = new Promise(resolve => value.child.once('exit', resolve)); value.child.kill(); await exited; if (sessions.get(botId) === current) sessions.delete(botId); } }, + async close() { for (const p of sessions.values()) { try { (await p).child.kill(); } catch {} } sessions.clear(); }, + }; +} diff --git a/tools/node-agent-api/runtime/project.mjs b/tools/node-agent-api/runtime/project.mjs new file mode 100644 index 0000000..072b7c8 --- /dev/null +++ b/tools/node-agent-api/runtime/project.mjs @@ -0,0 +1,11 @@ +// This fixed program runs in the selected Linux container; paths never refer to Mac files. +export const projectProgram = String.raw` +const fs=require('node:fs'),path=require('node:path'),{spawnSync}=require('node:child_process'); +const action=process.argv[1],input=JSON.parse(process.argv[2]||'{}'),root=process.argv[3]||'/workspace'; +if(!/^\/workspace(?:\/[A-Za-z0-9_.-]+)*$/.test(root)||root.split('/').includes('..')||fs.lstatSync(root).isSymbolicLink())throw Error('Invalid Linux project root'); +function safe(relative){if(typeof relative!=='string'||!relative||relative.length>512||relative.startsWith('/')||relative.split('/').some(p=>!p||p==='.'||p==='..'||p==='.git'))throw Error('Invalid project path');const target=path.join(root,relative);let current=root;for(const part of relative.split('/')){current=path.join(current,part);try{if(fs.lstatSync(current).isSymbolicLink())throw Error('Project symlinks cannot be traversed')}catch(error){if(error.code!=='ENOENT')throw error}}return target} +function git(args,allowed=[0]){const result=spawnSync('git',['-C',root,...args],{encoding:'utf8',maxBuffer:16*1024*1024});if(result.error||!allowed.includes(result.status))throw Error('Git operation failed');return result.stdout} +if(action==='import'){if(!Array.isArray(input.files)||input.files.length>200)throw Error('Invalid file set');let total=0;const files=input.files.map(file=>{const target=safe(file.path);if(typeof file.content!=='string'||(total+=Buffer.byteLength(file.content))>16*1024*1024)throw Error('Import exceeds limit');if(fs.existsSync(target))throw Error('Import refuses overwrite');return{target,content:file.content}});for(const file of files){fs.mkdirSync(path.dirname(file.target),{recursive:true});fs.writeFileSync(file.target,file.content,{flag:'wx',mode:0o644})}if(!fs.existsSync(path.join(root,'.git')))git(['init']);console.log(JSON.stringify({imported:files.length}));} +else if(action==='diff'){if(!fs.existsSync(path.join(root,'.git')))git(['init']);let diff=git(['diff','--no-ext-diff','--']);diff+=git(['diff','--cached','--no-ext-diff','--']);for(const file of git(['ls-files','--others','--exclude-standard','-z']).split('\0').filter(Boolean)){const target=safe(file);if(fs.lstatSync(target).isFile()){const result=spawnSync('git',['diff','--no-index','--no-ext-diff','--','/dev/null',target],{encoding:'utf8',maxBuffer:16*1024*1024});if(![0,1].includes(result.status))throw Error('Untracked diff failed');diff+=result.stdout}}console.log(JSON.stringify({diff}));} +else if(action==='inventory'){let bytes=0,files=0;function scan(directory){for(const name of fs.readdirSync(directory)){const target=path.join(directory,name),info=fs.lstatSync(target);if(info.isSymbolicLink())continue;if(info.isDirectory())scan(target);else if(info.isFile()){bytes+=info.size;files++;if(bytes>200*1024*1024||files>10000)throw Error('Project export exceeds limit')}}}scan(root);console.log(JSON.stringify({bytes,files}));} +else throw Error('Unsupported project operation');`; diff --git a/tools/node-agent-api/server.mjs b/tools/node-agent-api/server.mjs new file mode 100644 index 0000000..5390e63 --- /dev/null +++ b/tools/node-agent-api/server.mjs @@ -0,0 +1,231 @@ +import { createServer as httpServer } from 'node:http'; +import { createHash, randomUUID } from 'node:crypto'; +import { createAuth } from './auth.mjs'; +import { createStore } from './store.mjs'; +import { createDesktop } from './desktop.mjs'; +import { ApiError, badRequest, forbidden, identifier, object } from './errors.mjs'; +import { privateDirectory } from './runtime/common.mjs'; +import { acquireStateLock } from './persistence.mjs'; +import { page } from './pagination.mjs'; +import { createInputHandler, metadata } from './input.mjs'; +import { createGovernance } from './governance.mjs'; +import { createWebhookOutbox } from './webhooks.mjs'; +import { readFile, appendFile } from 'node:fs/promises'; +import path from 'node:path'; + +const publicSession = value => ({ object: value.object, id: value.id, agent_id: value.agent_id, status: value.status, created_at: value.created_at, context: value.context, metadata: value.metadata, ...(value.thread_id ? { thread_id: value.thread_id, task_status: value.task_status, active_turn_id: value.active_turn_id ?? null } : {}) }); +export async function startNodeAgentApi(options) { + await privateDirectory(options.stateDirectory); + const release = await acquireStateLock(options.stateDirectory); + try { + const service = await startUnlocked(options); + let closing; + return { ...service, close() { closing ??= service.close().finally(release); return closing; } }; + } catch (error) { await release(); throw error; } +} + +async function startUnlocked({ adapter, stateDirectory, port = 0, pollIntervalMs = 2000, governanceOptions = {} }) { + const auth = await createAuth(stateDirectory), store = await createStore(stateDirectory); + const governance = await createGovernance(stateDirectory, governanceOptions); + let webhooks; try { webhooks = await createWebhookOutbox(stateDirectory); } catch (error) { await governance.close(); throw error; } + const desktop = createDesktop({ adapter, auth, store, governance, stateDirectory }); + store.setWriteGuard(async values => { + const groups = new Map(); + for (const value of values) { const subject = value.quota_user_id ?? 'owner'; groups.set(subject, (groups.get(subject) ?? 0) + Buffer.byteLength(JSON.stringify(value))); } + for (const [id, bytes] of groups) await governance.reserveStorage({ id }, 'session-state-' + id, bytes); + }); + try { await adapter.attachStore?.(store); } catch (error) { await webhooks.close(); await governance.close(); throw error; } + let webhooksEnabled = false; + const unsubscribeAll = store.subscribeAll(event => { + const method = event.data?.method; + if (!webhooksEnabled || (event.type === 'node.harness.event' && !['turn/started', 'turn/completed', 'item/tool/call', 'item/commandExecution/requestApproval', 'item/fileChange/requestApproval'].includes(method))) return; + const session = store.get(event.session_id); + void webhooks.enqueue({ id: session.quota_user_id ?? 'owner' }, { idempotencyKey: event.id, event: { id: event.id, type: event.type, session_id: event.session_id, created_at: event.created_at } }).catch(() => { webhooksEnabled = false; }); + }); + const webhookTimer = setInterval(() => { if (webhooksEnabled) void webhooks.dispatchDue().catch(() => { webhooksEnabled = false; }); }, 1000); webhookTimer.unref(); + const submit = createInputHandler({ adapter, store }); + const streams = new Set(); + let origin; + const respond = (res, status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); }; + async function body(req, maxBytes = 65536) { + if (!req.headers['content-type']?.startsWith('application/json')) throw new ApiError(415, 'unsupported_media_type', 'JSON body required'); + let size = 0; const chunks = []; + for await (const data of req) { size += data.length; if (size > maxBytes) throw new ApiError(413, 'body_limit', 'Request exceeds size limit'); chunks.push(data); } + try { return JSON.parse(Buffer.concat(chunks).toString()); } catch { throw badRequest('Invalid JSON'); } + } + function authorizeSession(principal, id, scope) { const session = store.get(identifier(id)); auth.requireScope(principal, scope, session.agent_id); return session; } + const server = httpServer(async (req, res) => { + res.setHeader('X-Request-ID', 'nhttp_' + randomUUID()); + res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Content-Security-Policy', "default-src 'self'; connect-src 'self' ws://127.0.0.1:*; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"); + try { + if (req.headers.host !== new URL(origin).host || (req.headers.origin && req.headers.origin !== origin) || req.headers['sec-fetch-site'] === 'cross-site') throw new ApiError(403, 'origin_rejected', 'Host or Origin rejected'); + const url = new URL(req.url, origin); + let parts; try { parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent); } catch { throw badRequest('Invalid URL encoding'); } + if (req.method === 'GET' && url.pathname === '/health') return respond(res, 200, { service: 'node-agent-api', version: '0.3.0', backend: adapter.backend ?? 'grok_node', compatible_with_openai_sdk: false }); + if (req.method === 'GET' && ['/', '/ui/', '/ui/index.html', '/ui/app.js', '/ui/client.js', '/ui/style.css'].includes(url.pathname)) { + if (url.pathname === '/') { res.writeHead(303, { Location: '/ui/' }); res.end(); return; } + const file = ['/', '/ui/', '/ui/index.html'].includes(url.pathname) ? 'index.html' : path.basename(url.pathname); + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'self'"); + res.writeHead(200, { 'Content-Type': file.endsWith('.css') ? 'text/css' : file.endsWith('.js') ? 'text/javascript' : 'text/html' }); res.end(await readFile(path.join(import.meta.dirname, 'web', file))); return; + } + if (url.pathname.startsWith('/desktop/')) return await desktop.handle(req, res, url); + const principal = auth.authenticate(req.headers.authorization); + const quotaPrincipal = { id: principal.user_id ?? principal.id }; + await governance.consumeRequest(quotaPrincipal); + res.on('finish', () => { void governance.recordAudit({ principal: quotaPrincipal, action: 'request', outcome: res.statusCode < 400 ? 'succeeded' : 'failed', requestId: res.getHeader('X-Request-ID'), status: res.statusCode }).catch(() => {}); }); + if (parts[0] !== 'v1') throw new ApiError(404, 'not_found', 'Route not found'); + if (url.pathname === '/v1/capabilities' && req.method === 'GET') return respond(res, 200, { + object: 'node.api.capabilities', version: '0.3.0', backend: adapter.backend ?? 'grok_node', writes_enabled: adapter.writesEnabled, + sessions: { context: adapter.independentSessions ? 'codex_harness' : 'grok_node_bot_transcript', independent_conversations: !!adapter.independentSessions, metadata: true, close: true }, + events: { sse: true, resume: 'persisted_event_id', turn_outcomes: !!adapter.cancel, cancellation: !!adapter.cancel, required_actions: !!adapter.answer, tool_results: false }, + environment: { provisioning: !!adapter.bindSession && !adapter.externalAdapter, isolation: adapter.isolation ?? (adapter.independentSessions ? 'per_bot_container' : 'shared_container_separate_display') }, + security: { loopback_only: true, desktop_server_enforced: true, legacy_vnc_protected: adapter.rawDesktopProtected ?? !!adapter.independentSessions }, + desktop: { view: true, control: true, applications: !!adapter.applications }, project: { diff: !!adapter.project, import: !!adapter.project, export: !!adapter.exportProject }, clipboard: { read: !!adapter.clipboard, write: !!adapter.clipboard }, + service: { quotas: true, audit: true, webhooks: true }, + }); + if (url.pathname === '/v1/usage' && req.method === 'GET') return respond(res, 200, await governance.usage(quotaPrincipal)); + if (parts[1] === 'webhooks') { + if (principal.id !== 'owner') throw forbidden(); + if (parts.length === 2 && req.method === 'POST') { const configuration = await webhooks.configure(object(await body(req), ['destination'])); webhooksEnabled = true; return respond(res, 201, configuration); } + if (parts.length === 3 && parts[2] === 'rotate' && req.method === 'POST') return respond(res, 200, await webhooks.rotateKey()); + if (parts.length === 3 && parts[2] === 'dispatch' && req.method === 'POST') return respond(res, 200, { data: await webhooks.dispatchDue() }); + if (parts.length === 3 && req.method === 'GET') return respond(res, 200, await webhooks.status(quotaPrincipal, parts[2])); + } + if (parts[1] === 'keys') { + if (req.method === 'POST' && parts.length === 2) return respond(res, 201, await auth.issue(principal, object(await body(req), ['scopes','bot_ids','ttl_seconds','user_id']))); + if (req.method === 'POST' && parts.length === 4 && parts[3] === 'rotate') { const result = await auth.rotateKey(principal, identifier(parts[2])); desktop.revokeKey(parts[2]); return respond(res, 201, result); } + if (req.method === 'DELETE' && parts.length === 3) { const result = await auth.revoke(principal, identifier(parts[2])); desktop.revokeKey(parts[2]); return respond(res, 200, result); } + } + if (parts[1] === 'users') { + if (parts.length === 2 && req.method === 'POST') return respond(res, 201, await auth.createUser(principal, object(await body(req), ['name']))); + if (parts.length === 2 && req.method === 'GET') return respond(res, 200, page(auth.listUsers(principal), url.searchParams)); + if (parts.length === 4 && parts[3] === 'disable' && req.method === 'POST') { const result = await auth.disableUser(principal, identifier(parts[2])); desktop.revokeKey('disabled-user'); return respond(res, 200, result); } + } + if (parts[1] === 'agents' && parts.length === 2) { + auth.requireScope(principal, req.method === 'GET' ? 'agents.read' : 'agents.write'); + if (req.method === 'GET') return respond(res, 200, page((await adapter.agents()).filter(a => principal.botIds.includes('*') || principal.botIds.includes(a.id)), url.searchParams)); + if (req.method === 'POST') { if (!principal.botIds.includes('*')) throw forbidden(); const input = object(await body(req), ['name','description']); if (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 120 || (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 4000))) throw badRequest('Invalid bot profile'); return respond(res, 201, await adapter.createAgent(input)); } + } + if (parts[1] === 'agents' && parts[2] !== 'sessions' && parts.length === 3 && req.method === 'GET') { auth.requireScope(principal, 'agents.read', identifier(parts[2])); return respond(res, 200, await adapter.requireAgent(parts[2])); } + if (parts[1] === 'agents' && parts[2] === 'sessions') { + if (parts.length === 3 && req.method === 'GET') { auth.requireScope(principal, 'sessions.read'); return respond(res, 200, page(store.list().filter(s => (principal.botIds.includes('*') || principal.botIds.includes(s.agent_id)) && (!url.searchParams.has('agent_id') || s.agent_id === url.searchParams.get('agent_id'))).map(publicSession), url.searchParams)); } + if (parts.length === 3 && req.method === 'POST') { + // Mirrors the Agents API request shape: a reusable agent_id, or an + // inline agent override that selects an existing GrokNode bot. + const input = object(await body(req), ['agent_id', 'agent', 'metadata']); + const inline = input.agent === undefined ? undefined : object(input.agent, ['id']); + const agentId = typeof input.agent_id === 'string' ? input.agent_id : inline?.id; + if (typeof agentId !== 'string') throw badRequest('agent_id or agent.id is required'); + identifier(agentId); auth.requireScope(principal, 'sessions.write', agentId); await adapter.requireAgent(agentId); + const result = await store.create(agentId, input.metadata === undefined ? {} : metadata(input.metadata), { reuse: !adapter.independentSessions, quota_user_id: quotaPrincipal.id }); + if (result.created) await adapter.bindSession?.(result.session); + return respond(res, result.created ? 201 : 200, publicSession(result.session)); + } + if (parts.length >= 4) { + const scope = req.method === 'GET' || ['desktop','handback','applications'].includes(parts[4]) ? 'sessions.read' : 'sessions.write'; + const session = authorizeSession(principal, parts[3], scope); + if (parts[4] === 'artifacts' && parts.length === 7 && parts[6] === 'content' && req.method === 'GET' && adapter.artifact) { const result = await adapter.artifact(session, identifier(parts[5])); res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Disposition': 'attachment; filename="project.tgz"' }); res.end(result.bytes); return; } + if (parts[4] === 'actions' && parts.length === 6 && req.method === 'POST' && adapter.answer) { const input = object(await body(req), ['decision']); return respond(res, 200, await adapter.answer(session, identifier(parts[5]), input.decision)); } + if (parts[4] === 'project' && parts.length === 6) { + if (!adapter.project) throw new ApiError(409, 'unsupported', 'Project operations unavailable'); + if (req.method === 'GET' && parts[5] === 'diff') return respond(res, 200, await adapter.project(session, 'diff')); + if (session.status === 'closed' || session.task_status === 'running') throw new ApiError(409, 'session_busy', 'Project writes require an open idle session'); + if (req.method === 'POST' && parts[5] === 'import') return respond(res, 200, await adapter.project(session, 'import', object(await body(req, 16 * 1024 * 1024), ['files']))); + if (req.method === 'POST' && parts[5] === 'export') return respond(res, 200, await adapter.exportProject(session, (id, bytes) => governance.reserveStorage(quotaPrincipal, id, bytes))); + } + if (parts[4] === 'environment' && parts.length === 6 && req.method === 'POST') { + if (parts[5] === 'backup' && adapter.backup) return respond(res, 201, await adapter.backup(session)); + if (parts[5] === 'recreate' && adapter.recreate) return respond(res, 200, await adapter.recreate(session)); + if (parts[5] === 'restore' && adapter.restore) return respond(res, 200, await adapter.restore(session, object(await body(req), ['backup_id']))); + } + if (parts.length === 4 && req.method === 'GET') return respond(res, 200, publicSession(session)); + if (parts.length === 4 && req.method === 'PATCH') { const input = object(await body(req), ['metadata']); if (!Object.hasOwn(input, 'metadata')) throw badRequest('metadata is required'); await store.update(session.id, { metadata: metadata(input.metadata) }); return respond(res, 200, publicSession(session)); } + if (parts.length !== 5) throw new ApiError(404, 'not_found', 'Route not found'); + if (parts[4] === 'close' && req.method === 'POST') { + if (session.status === 'pending' || session.task_status === 'running') throw new ApiError(409, 'input_pending', 'Wait for or interrupt active work before closing'); + if (session.status !== 'closed') { await store.update(session.id, { status: 'closed' }); await store.append(session.id, 'node.session.closed', { cancels_agent: false }); desktop.closeSession(session.id); } + return respond(res, 200, publicSession(session)); + } + if (parts[4] === 'environment' && req.method === 'GET') { const { websocketUrl, websocketHeaders, ...environment } = await adapter.desktop(session.agent_id); return respond(res, 200, environment); } + if (parts[4] === 'applications' && req.method === 'GET') { + if (!adapter.applications) throw new ApiError(409, 'unsupported', 'Application windows are unavailable on this backend'); + auth.requireScope(principal, 'desktop.view', session.agent_id); + const rows = await adapter.applications(session); + const data = rows.filter(app => app.running !== false).map(app => ({ + object: 'node.application', + id: app.application_id, + name: app.name, + running: true, + })); + return respond(res, 200, { + object: 'list', data, has_more: false, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + }); + } + if (parts[4] === 'applications' && req.method === 'POST' && adapter.launchApplication) { + auth.requireScope(principal, 'desktop.control', session.agent_id); + if (session.status === 'closed' || !desktop.hasControl(principal, session)) throw forbidden(); + const input = object(await body(req), ['kind']); + const result = await adapter.launchApplication(session, input.kind); + await store.append(session.id, 'node.application.launched', { kind: input.kind, display: result.display }); + return respond(res, 202, result); + } + if (parts[4] === 'items' && req.method === 'GET') { + const items = await store.refreshItems(session.id, () => adapter.itemsSession ? adapter.itemsSession(session) : adapter.items(session.agent_id)); + const rows = items.map((item, index) => ({ ...item, id: typeof item.id === 'string' ? item.id : 'nitem_' + createHash('sha256').update(index + ':' + JSON.stringify(item)).digest('hex') })); + return respond(res, 200, { ...page(rows, url.searchParams), context: session.context }); + } + if (parts[4] === 'actions' && req.method === 'GET') return respond(res, 200, page(adapter.actions ? await adapter.actions(session) : [], url.searchParams)); + if (parts[4] === 'resume' && req.method === 'POST' && adapter.resume) { await adapter.resume(session); return respond(res, 200, publicSession(session)); } + if (parts[4] === 'handoff' && req.method === 'POST' && adapter.handoff) return respond(res, 200, await adapter.handoff(session)); + if (parts[4] === 'turns' && req.method === 'GET') { await adapter.flush?.(); return respond(res, 200, page(session.turns ?? [], url.searchParams)); } + if (parts[4] === 'traces' && req.method === 'GET') { await adapter.flush?.(); return respond(res, 200, { object: 'list', data: (session.turns ?? []).map(turn => ({ turn_id: turn.id, started_at: turn.started_at, ended_at: turn.ended_at ?? null, status: turn.status, usage: turn.usage ?? null, spans: turn.items })), has_more: false }); } + if (parts[4] === 'clipboard' && adapter.clipboard) { + auth.requireScope(principal, req.method === 'GET' ? 'desktop.view' : 'desktop.control', session.agent_id); + if (req.method === 'GET') return respond(res, 200, await adapter.clipboard(session)); + if (req.method === 'POST') { if (session.status === 'closed') throw new ApiError(409, 'session_closed', 'Session is closed'); if (!desktop.hasControl(principal, session)) throw forbidden(); const input = object(await body(req), ['text']); return respond(res, 200, await adapter.clipboard(session, input.text)); } + } + if (parts[4] === 'desktop' && req.method === 'POST') { if (session.status === 'closed') throw new ApiError(409, 'session_closed', 'Session attachment is closed'); return respond(res, 201, await desktop.issue(principal, session, object(await body(req), ['mode','ttl_seconds','target']), origin)); } + if (parts[4] === 'handback' && req.method === 'POST') return respond(res, 200, desktop.handback(principal, session)); + if (parts[4] === 'events' && req.method === 'GET') { + if (req.headers.accept?.includes('text/event-stream')) { + const after = req.headers['last-event-id'] ?? url.searchParams.get('after'); + const start = after ? session.events.findIndex(e => e.id === after) + 1 : 0; + if (after && start === 0) throw new ApiError(400, 'invalid_cursor', 'Persisted event cursor not found'); + const releaseStream = await governance.acquireStream(quotaPrincipal); + res.writeHead(200, { 'Content-Type': 'text/event-stream', Connection: 'keep-alive' }); + res.flushHeaders(); + const send = event => { if (res.writableLength > 1024 * 1024) res.destroy(); else res.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); }; + for (const event of session.events.slice(start)) send(event); + const unsubscribe = store.subscribe(session.id, event => { if (!auth.active(principal)) res.end(); else send(event); }); + let polling = false, closed = false; + const updates = setInterval(async () => { + if (polling || closed || !auth.active(principal)) return; + polling = true; + try { if (!adapter.independentSessions) await store.refreshItems(session.id, () => adapter.items(session.agent_id)); } + catch { if (!closed) res.write('event: node.upstream.unavailable\ndata: {"replayed":false}\n\n'); } + finally { polling = false; } + }, pollIntervalMs); updates.unref(); + const heartbeat = setInterval(() => { if (!auth.active(principal)) res.end(); else res.write(':heartbeat\n\n'); }, 15000); heartbeat.unref(); streams.add(res); + res.on('close', () => { closed = true; releaseStream(); unsubscribe(); clearInterval(heartbeat); clearInterval(updates); streams.delete(res); }); return; + } + return respond(res, 200, page(session.events, url.searchParams)); + } + if (parts[4] === 'events' && req.method === 'POST') { + const input = await body(req); + if (input.events?.[0]?.type === 'agent.session.input.cancel' && input.events.length === 1 && adapter.cancel) { object(input, ['events']); const cancel = object(input.events[0], ['type', 'turn_id']); return respond(res, 202, await adapter.cancel(session, cancel.turn_id)); } + return respond(res, 202, await submit(session, input, req.headers['idempotency-key'])); + } + } + } + throw new ApiError(404, 'not_found', 'Route not found'); + } catch (error) { if (!(error instanceof ApiError) && adapter.diagnostic) await appendFile(path.join(stateDirectory, 'diagnostics.log'), JSON.stringify({ at: Date.now(), message: adapter.diagnostic(error) }) + '\n', { mode: 0o600 }); if (res.headersSent) res.destroy(); else respond(res, error.status ?? 500, { error: { type: error instanceof ApiError ? error.code : 'internal_error', message: error instanceof ApiError ? error.message : 'Operation failed' } }); } + }); + server.on('upgrade', (req, socket, head) => { void desktop.upgrade(req, socket, head, origin); }); + try { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); }); } + catch (error) { clearInterval(webhookTimer); unsubscribeAll(); desktop.close(); await adapter.close?.(); await webhooks.close(); await governance.close(); throw error; } + origin = `http://127.0.0.1:${server.address().port}`; + return { origin, keyFile: auth.keyFile, async close() { clearInterval(webhookTimer); unsubscribeAll(); desktop.close(); for (const res of streams) res.end(); await new Promise(resolve => server.close(resolve)); let failure; for (const cleanup of [() => adapter.close?.(), () => store.flush(), () => auth.flush(), () => webhooks.close(), () => governance.close()]) { try { await cleanup(); } catch (error) { failure ??= error; } } if (failure) throw failure; } }; +} diff --git a/tools/node-agent-api/shared-runtime.mjs b/tools/node-agent-api/shared-runtime.mjs new file mode 100644 index 0000000..7363257 --- /dev/null +++ b/tools/node-agent-api/shared-runtime.mjs @@ -0,0 +1,136 @@ +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { createServer } from 'node:http'; +import { randomBytes, createHash } from 'node:crypto'; +import { readFile, writeFile, chmod, lstat, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { WebSocket, WebSocketServer } from 'ws'; +import { ApiError } from './errors.mjs'; +import { createGrokNodeAdapter } from './grok-node.mjs'; +import { privateDirectory, resourceNames } from './runtime/common.mjs'; +import { projectProgram } from './runtime/project.mjs'; +import { listApplications, normalizeApplicationId } from './runtime/applications.mjs'; +import { applicationStreamProgram } from './application-stream.mjs'; +import { clipboardProgram } from './clipboard-x11.mjs'; + +const executeDefault = promisify(execFile); +const preparation = String.raw`const fs=require('fs'),path=require('path');const p=process.argv[1];for(const dir of ['/home/box/sand-data/node-agent-api','/home/box/sand-data/node-agent-api/executor-home',p]){let cur='/';for(const part of dir.split('/').filter(Boolean)){cur=path.join(cur,part);try{if(fs.lstatSync(cur).isSymbolicLink())throw Error('Unsafe directory')}catch(e){if(e.code!=='ENOENT')throw e;fs.mkdirSync(cur)}}}if(fs.existsSync('/home/box/sand-data/node-agent-api/executor-home/auth.json'))throw Error('Executor credentials must be absent');try{fs.readFileSync('/root/.codex/auth.json');throw Error('Mac credentials exposed')}catch(e){if(!['EACCES','ENOENT'].includes(e.code))throw e}console.log('executor_private_home_ready');`; + +export async function createSharedRuntime({ container = 'grok-node-local-vm', stateRoot, namespace = 'grok-node-lab-shared', workspaceRoot = '/workspace/codex-projects/node-agent-api', docker = '/usr/local/bin/docker', execute = executeDefault, spawnProcess = spawn } = {}) { + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(container) || !/^\/workspace\/[A-Za-z0-9_./-]+$/.test(workspaceRoot) || workspaceRoot.split('/').includes('..')) throw new Error('Invalid shared runtime configuration'); + await privateDirectory(stateRoot); + const bridge = createGrokNodeAdapter({ container, execute }); + const codePath = '/home/box/sand-data/codex-vm/bin/codex', executorHome = '/home/box/sand-data/node-agent-api/executor-home'; + const viewerToken = randomBytes(32).toString('hex'), children = new Set(), sockets = new Set(), clipboardOwners = new Map(); + let closed = false; + async function info() { + try { const row = JSON.parse((await execute(docker, ['inspect', container], { timeout: 10000, encoding: 'utf8' })).stdout)[0]; if (row.Name !== '/' + container || !row.State?.Running) throw new Error('Container unavailable'); return row; } + catch { throw new ApiError(503, 'execution_unavailable', 'Open GrokNode and its existing box; the API will not start or replace it'); } + } + const lowPrivilege = (id, args, interactive = false) => ['exec', ...(interactive ? ['-i'] : []), id, 'setpriv', '--reuid=box', '--regid=box', '--init-groups', '--bounding-set=-all', '--no-new-privs', '/usr/bin/env', '-i', 'HOME=' + executorHome, 'CODEX_HOME=' + executorHome, 'PATH=/usr/local/bin:/usr/bin:/bin', ...args]; + async function box(args, options = {}) { const row = await info(); try { return await execute(docker, lowPrivilege(row.Id, args), { encoding: 'utf8', timeout: 30000, maxBuffer: 16 * 1024 * 1024, ...options }); } catch { throw new ApiError(502, 'execution_failed', 'Shared Linux operation failed'); } } + async function seat(botId, ensure = false) { + await bridge.requireAgent(botId); + if (ensure) await bridge.gateway('ensureForeverBox', { id: botId }); + const environment = await bridge.desktop(botId); + const probe = 'const fs=require("fs"),s=JSON.parse(fs.readFileSync("/home/box/.sand-window-assignments.json","utf8"));const [id,d]=process.argv.slice(1);if(s.assignments[id]!==Number(d))process.exit(2);if(s.tokens?.[id]&&fs.readFileSync("/tmp/sand-window-tokens.d/"+d,"utf8").trim()!==s.tokens[id])process.exit(3);console.log("owned");'; + await box(['node', '-e', probe, botId, String(environment.display)]); + return environment; + } + async function applications(botId) { + const environment = await seat(botId); + const row = await info(); + return listApplications({ display: ':' + environment.display, sanitize: true, execFileImpl: (file, args, _options, callback) => { execFile(docker, lowPrivilege(row.Id, ['env', 'DISPLAY=:' + environment.display, file, ...args]), { encoding: 'utf8', timeout: 10000 }, callback); } }); + } + const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 }); + const server = createServer((_req, res) => { res.writeHead(401); res.end(); }); + server.on('upgrade', (req, socket, head) => { void (async () => { + try { + if (closed || req.headers.authorization !== 'Bearer ' + viewerToken) throw new Error('Unauthorized'); + const parts = new URL(req.url, 'http://localhost').pathname.split('/'); + if (parts.length !== 6 || parts[1] !== 'application' || !['view', 'control'].includes(parts[5])) throw new Error('Invalid lane'); + const botId = decodeURIComponent(parts[2]), display = Number(parts[3]), id = normalizeApplicationId(parts[4]); + const current = await seat(botId); if (!id || display !== current.display || !(await applications(botId)).some(app => app.application_id === id)) throw new Error('Stale lane'); + const row = await info(); + wss.handleUpgrade(req, socket, head, client => { + sockets.add(client); + const child = spawnProcess(docker, lowPrivilege(row.Id, ['node', '-e', applicationStreamProgram, '--', botId, String(display), id, parts[5]], true), { stdio: ['pipe', 'pipe', 'ignore'] }); children.add(child); + let ended = false; + const end = () => { if (ended) return; ended = true; sockets.delete(client); children.delete(child); child.stdin.end(); child.kill('SIGTERM'); client.terminate(); }; + client.on('message', data => { if (child.stdin.writableLength > 1024 * 1024) end(); else child.stdin.write(data); }); + child.stdout.on('data', data => { if (client.readyState === WebSocket.OPEN && client.bufferedAmount < 4 * 1024 * 1024) client.send(data); else end(); }); + child.on('exit', end); child.on('error', end); child.stdin.on('error', end); client.on('close', end); client.on('error', end); + }); + } catch { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); } + })(); }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + const origin = 'ws://127.0.0.1:' + server.address().port; + const runtime = { + shared: true, workspaceRoot, roster: bridge, applications, + async launchApplication(botId, kind) { + if (!['terminal', 'browser'].includes(kind)) throw new ApiError(400, 'application_kind', 'Choose terminal or browser'); + const environment = await seat(botId); + const program = 'const fs=require("fs"),{spawn}=require("child_process");const [id,d,kind,cwd,home]=process.argv.slice(1),s=JSON.parse(fs.readFileSync("/home/box/.sand-window-assignments.json","utf8"));if(s.assignments[id]!==Number(d))process.exit(2);const child=spawn(kind==="terminal"?"xfce4-terminal":"google-chrome",kind==="terminal"?["--disable-server","--working-directory",cwd,"--title","Node Agent Linux project"]:["--no-sandbox","--disable-dev-shm-usage","--no-first-run","--class=box-chrome","--user-data-dir="+home+"/chrome-"+d,"--new-window","about:blank"],{cwd,env:{...process.env,DISPLAY:":"+d},stdio:"ignore",detached:true});child.on("error",()=>process.exit(3));child.unref();console.log(JSON.stringify({launched:true,kind,display:Number(d)}));'; + return JSON.parse((await box(['node', '-e', program, '--', botId, String(environment.display), kind, workspaceRoot, executorHome])).stdout); + }, + async status(botId) { const row = await info(), environment = await seat(botId); return { running: true, exists: true, containerName: container, containerId: row.Id, display: environment.display, workspaceRoot, transport: 'stdio' }; }, + async ensure(botId) { + await info(); await seat(botId, true); + const version = await box([codePath, '--version']); + if (version.stdout.trim() !== 'codex-cli 0.160.0') throw new ApiError(409, 'codex_version', 'Install the already-authorized Codex 0.160.0 execution package; automatic upgrades are disabled'); + await box(['node', '-e', preparation, workspaceRoot]); + return this.status(botId); + }, + async descriptor(botId) { + const row = await info(), environment = await seat(botId); + const args = ['exec', '--workdir', workspaceRoot, '-i', row.Id, 'setpriv', '--reuid=box', '--regid=box', '--init-groups', '--bounding-set=-all', '--no-new-privs', '/usr/bin/env', '-i', 'HOME=' + executorHome, 'CODEX_HOME=' + executorHome, 'DISPLAY=:' + environment.display, 'PATH=/usr/local/bin:/usr/bin:/bin', codePath, 'exec-server', '--listen', 'stdio']; + return { version: 1, namespace, environments: [{ botId, containerName: container, containerId: row.Id, display: environment.display, workspaceRoot, program: docker, args, transport: 'stdio' }] }; + }, + async desktop(botId, target = { type: 'desktop' }, mode = 'view') { + const row = await info(), environment = await seat(botId, true); let applicationId = null; + if (target.type === 'application') { applicationId = normalizeApplicationId(target.application_id); if (!applicationId || !(await applications(botId)).some(app => app.application_id === applicationId)) throw new ApiError(404, 'application_not_found', 'Application is no longer on this bot desktop'); } + const websocketUrl = applicationId ? `${origin}/application/${encodeURIComponent(botId)}/${environment.display}/${applicationId}/${mode}` : environment.websocketUrl; + return { ...environment, type: 'grok_node_codex', target: { type: target.type, application_id: applicationId }, applicationId, containerGenerationImmutableId: row.Id, workspaceRoot, websocketUrl, ...(applicationId ? { websocketHeaders: { authorization: 'Bearer ' + viewerToken } } : {}) }; + }, + async project(botId, action, input = {}) { await bridge.requireAgent(botId); return JSON.parse((await box(['node', '-e', projectProgram, '--', action, JSON.stringify(input), workspaceRoot])).stdout); }, + async approvedShell(botId, command) { await bridge.requireAgent(botId); if (typeof command !== 'string' || !command.trim() || command.length > 8192) throw new ApiError(400, 'invalid_command', 'Invalid command'); const program = 'const r=require("child_process").spawnSync("/bin/bash",["-lc",process.argv[1]],{cwd:process.argv[2],encoding:"utf8",timeout:15000,maxBuffer:1048576,env:{PATH:"/usr/local/bin:/usr/bin:/bin",HOME:process.argv[3],DISPLAY:process.argv[4]}});console.log(JSON.stringify({exit_code:r.status,stdout:r.stdout??"",stderr:r.stderr??"",timed_out:r.error?.code==="ETIMEDOUT"}));'; const row = await info(), environment = await seat(botId); const args = ['exec', row.Id, 'setpriv', '--reuid=box', '--regid=box', '--init-groups', '--bounding-set=-all', '--no-new-privs', 'node', '-e', program, '--', command, workspaceRoot, executorHome, ':' + environment.display]; try { return JSON.parse((await execute(docker, args, { encoding: 'utf8', timeout: 20000 })).stdout); } catch { throw new ApiError(502, 'execution_failed', 'Approved Linux command failed'); } }, + clipboard: { + async read(botId) { const environment = await seat(botId); return JSON.parse((await box(['python3', '-c', clipboardProgram, ':' + environment.display, 'read'])).stdout).text; }, + async write(botId, text) { + if (typeof text !== 'string' || Buffer.byteLength(text) > 65536) throw new ApiError(400, 'clipboard_limit', 'Invalid clipboard text'); + const environment = await seat(botId), row = await info(), previous = clipboardOwners.get(botId); + if (previous) { previous.stdin.end(); previous.kill('SIGTERM'); } + const child = spawnProcess(docker, lowPrivilege(row.Id, ['python3', '-c', clipboardProgram, ':' + environment.display, 'write', text], true), { stdio: ['pipe','pipe','ignore'] }); + children.add(child); clipboardOwners.set(botId, child); child.once('exit', () => { children.delete(child); if (clipboardOwners.get(botId) === child) clipboardOwners.delete(botId); }); child.stdin.on('error', () => {}); + await new Promise((resolve, reject) => { let output=''; const timer=setTimeout(()=>{child.stdin.end();child.kill();reject(new ApiError(502,'clipboard_failed','Clipboard writer timed out'));},5000); child.stdout.on('data', bytes=>{output+=bytes.toString();if(output.includes('"written": true')){clearTimeout(timer);resolve();}});child.once('error',()=>{clearTimeout(timer);reject(new ApiError(502,'clipboard_failed','Clipboard writer unavailable'));});child.once('exit',()=>{if(!output.includes('"written": true')){clearTimeout(timer);reject(new ApiError(502,'clipboard_failed','Clipboard writer failed'));}}); }); + }, + }, + async exportProject(botId, destination) { + await this.project(botId, 'inventory'); const row = await info(), archive = '/tmp/node-agent-export-' + randomBytes(12).toString('hex') + '.tgz'; + await box(['bash', '-c', 'umask 077; exec tar -czf "$1" -C "$2" .', 'node-agent-export', archive, workspaceRoot]); + try { await execute(docker, ['cp', row.Id + ':' + archive, destination], { timeout: 30000 }); await chmod(destination, 0o600); } finally { await box(['rm', '--', archive]); } + return { path: destination, size: (await lstat(destination)).size }; + }, + async backup(botId) { + const n = resourceNames(namespace, botId), destination = await privateDirectory(path.join(stateRoot, namespace, n.key, 'backups', Date.now() + '-' + randomBytes(4).toString('hex'))); + await this.exportProject(botId, path.join(destination, 'workspace.tgz')); + await writeFile(path.join(destination, 'manifest.json'), JSON.stringify({ container, workspaceRoot, sha256: createHash('sha256').update(await readFile(path.join(destination, 'workspace.tgz'))).digest('hex') }), { mode: 0o600 }); + return { path: destination, consistency: 'shared project live files; GrokNode processes are not paused' }; + }, + async restore(botId, backupPath) { + const n = resourceNames(namespace, botId), directory = await realpath(backupPath), prefix = path.resolve(stateRoot, namespace, n.key, 'backups') + path.sep; + if (!directory.startsWith(prefix)) throw new ApiError(403, 'backup_owner', 'Backup is outside this session runtime'); + const manifest = JSON.parse(await readFile(path.join(directory, 'manifest.json'), 'utf8')), archive = path.join(directory, 'workspace.tgz'); + if (manifest.container !== container || manifest.workspaceRoot !== workspaceRoot || createHash('sha256').update(await readFile(archive)).digest('hex') !== manifest.sha256) throw new ApiError(409, 'backup_invalid', 'Backup identity or integrity mismatch'); + await this.backup(botId); const row = await info(), remote = '/tmp/node-agent-restore-' + randomBytes(12).toString('hex') + '.tgz'; + await execute(docker, ['cp', archive, row.Id + ':' + remote]); + await execute(docker, ['exec', row.Id, 'chown', 'box:box', remote]); + await execute(docker, ['exec', row.Id, 'chmod', '600', remote]); + try { await box(['tar', '-xzf', remote, '-C', workspaceRoot]); } finally { await box(['rm', '--', remote]); } + return { restored: true, processes_restored: false }; + }, + async recreate() { throw new ApiError(409, 'managed_by_grok_node', 'Box lifecycle belongs to GrokNode; the API preserves its original program and services'); }, + async close() { closed = true; for (const client of sockets) client.terminate(); for (const child of children) { child.stdin.end(); child.kill('SIGTERM'); } wss.close(); await new Promise(resolve => server.close(resolve)); }, + }; + return runtime; +} diff --git a/tools/node-agent-api/store.mjs b/tools/node-agent-api/store.mjs new file mode 100644 index 0000000..7552d98 --- /dev/null +++ b/tools/node-agent-api/store.mjs @@ -0,0 +1,82 @@ +import { randomUUID, createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { EventEmitter } from 'node:events'; +import { privateDirectory } from './runtime/common.mjs'; +import { ApiError } from './errors.mjs'; +import { atomicJson, readPrivateJson } from './persistence.mjs'; + +export async function createStore(directory) { + await privateDirectory(directory); + const file = join(directory, 'sessions.json'), emitter = new EventEmitter(); + const data = await readPrivateJson(file, []); + if (!Array.isArray(data) || data.some(s => !s || typeof s.id !== 'string' || typeof s.agent_id !== 'string' || !Array.isArray(s.events))) throw new Error('Invalid session store'); + const sessions = new Map(data.map(s => [s.id, s])); + let recovered = false; + for (const session of sessions.values()) { + session.metadata ??= {}; + session.requests ??= {}; + const pending = Object.entries(session.requests).filter(([, request]) => request.status === 'pending'); + for (const [requestId, request] of pending) { + request.status = 'unknown'; + session.events.push({ id: 'nevt_' + randomUUID(), type: 'node.session.input.unknown', session_id: session.id, created_at: Math.floor(Date.now() / 1000), data: { request_id: requestId, reason: 'service_restart', replayed: false } }); + recovered = true; + } + if (session.status === 'pending') { session.status = 'unknown'; recovered = true; } + } + let saving = Promise.resolve(), fault; + let writeGuard = async () => {}; + function transaction(action) { + const task = saving.then(async () => { if (fault) throw fault; return action(); }); + saving = task.catch(() => {}); return task; + } + async function persist(values) { + const snapshot = JSON.parse(JSON.stringify(values)); + await writeGuard(snapshot); + try { await atomicJson(file, snapshot); } catch (error) { fault = error; throw error; } + } + function get(id) { const value = sessions.get(id); if (!value) throw new ApiError(404, 'not_found', 'Session not found'); return value; } + async function append(id, type, payload) { + return transaction(async () => { + const value = get(id), event = { id: 'nevt_' + randomUUID(), type, session_id: id, created_at: Math.floor(Date.now() / 1000), data: payload }; + if (value.events.length >= 10000) throw new ApiError(409, 'event_limit', 'Session event limit reached'); + const next = { ...value, events: [...value.events, event] }; + await persist([...sessions.values()].map(row => row.id === id ? next : row)); + value.events = next.events; emitter.emit(id, event); emitter.emit('*', event); return event; + }); + } + if (recovered) await persist([...sessions.values()]); + const refreshes = new Map(); + return { + get, list: () => [...sessions.values()], append, + async create(agentId, metadata = {}, options = {}) { + return transaction(async () => { + // GrokNode has one transcript per bot. API sessions are explicit attachments. + const existing = [...sessions.values()].find(s => s.agent_id === agentId && s.status !== 'closed'); + if (existing && options.reuse !== false) return { session: existing, created: false }; + const session = { object: 'node.agent.session', id: 'nsess_' + randomUUID(), agent_id: agentId, status: 'idle', created_at: Math.floor(Date.now() / 1000), context: 'grok_node_bot_transcript', quota_user_id: options.quota_user_id ?? 'owner', metadata, events: [], requests: {} }; + const event = { id: 'nevt_' + randomUUID(), type: 'node.session.created', session_id: session.id, created_at: session.created_at, data: { agent_id: agentId } }; + session.events.push(event); await persist([...sessions.values(), session]); sessions.set(session.id, session); emitter.emit('*', event); return { session, created: true }; + }); + }, + async refreshItems(id, load) { + if (refreshes.has(id)) return refreshes.get(id); + const task = (async () => { + const items = await load(), session = get(id); + const fingerprint = createHash('sha256').update(JSON.stringify(items)).digest('hex'); + if (session.items_fingerprint !== fingerprint) { + await append(id, 'node.session.items.changed', { fingerprint, item_count: items.length, persisted_by: 'grok_node' }); + await this.update(id, { items_fingerprint: fingerprint }); + } + return items; + })(); + refreshes.set(id, task); + try { return await task; } finally { refreshes.delete(id); } + }, + subscribe(id, listener) { get(id); emitter.on(id, listener); return () => emitter.off(id, listener); }, + subscribeAll(listener) { emitter.on('*', listener); return () => emitter.off('*', listener); }, + async update(id, changes) { const copy = structuredClone(changes); return transaction(async () => { const value = get(id), next = { ...value, ...copy }; await persist([...sessions.values()].map(row => row.id === id ? next : row)); Object.assign(value, copy); return value; }); }, + async flush() { await Promise.allSettled([...refreshes.values()]); await saving; if (fault) throw fault; }, + snapshot() { return JSON.stringify([...sessions.values()]); }, + setWriteGuard(guard) { writeGuard = guard; }, + }; +} diff --git a/tools/node-agent-api/web/README.md b/tools/node-agent-api/web/README.md new file mode 100644 index 0000000..aa58bd5 --- /dev/null +++ b/tools/node-agent-api/web/README.md @@ -0,0 +1,67 @@ +# 中文薄客户端接入契约 + +入口为 `index.html`,相邻静态资源为 `app.js`、`client.js`、`style.css`。主任务需在同源服务中提供这些静态文件(无需 Bearer,页面本身不含数据);支持根路径或带结尾斜杠的子路径。API 固定为同源 `/v1/`。建议 CSP:`default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'self'`,桌面查看器另按既有规则提供资源。没有内联脚本、第三方资源或新增依赖。 + +密钥只能手动输入,存于客户端闭包内存,输入框随即清空。刷新、离开页面或“断开并清除”都会清除;不会从 URL、文件或持久存储读取。所有 API 请求用 `Authorization: Bearer …`,禁止跨来源及重定向。服务器仍须执行 scope、bot 归属和租约检查。 + +## 能力响应 + +`GET /v1/capabilities` 的以下字段必须显式为 `true` 才启用;缺失、false、未知值均禁用。能力不是授权,HTTP 错误原样展示。 + +```json +{ + "writes_enabled": true, + "sessions": { "independent_conversations": false }, + "events": { "sse": true, "cancellation": true, "required_actions": true }, + "desktop": { "view": true, "control": true }, + "project": { "diff": true, "import": true, "export": true }, + "clipboard": { "read": true, "write": true } +} +``` + +审批也接受顶层 `required_actions: true`。旧 0.2 的能力只会启用它明确声明的聊天/SSE;`security.desktop_server_enforced` 不当成桌面能力。关联既有机器人不等于创建机器人,不依赖 `writes_enabled`(它原本只约束后端输入/创建 bot)。关闭会话仍可读取记录,但禁用发送、取消、桌面授权、导入和剪贴板写入。 + +## 请求与响应 + +所有路径以 `/v1/agents/sessions/{id}` 为前缀,下表另注明绝对路径。 + +| 场景 | 请求 | 响应要求 | +| --- | --- | --- | +| 机器人 | `GET /v1/agents` | `{data:[{id,name}],has_more,last_id}` | +| 会话列表 | `GET /v1/agents/sessions?agent_id=…` | 同上列表,行包含 `id,agent_id,status,metadata` | +| 关联/复用 | `POST /v1/agents/sessions {agent_id}` | `{id,agent_id,status}`;随后 GET 会话恢复 | +| 恢复会话 | `GET /v1/agents/sessions/{id}` | `{id,agent_id,status,active_turn_id?}`;状态仅代表输入接收,不判定成功 | +| 记录 | `GET …/items` | 标准列表,保留原始字段;text 可直接显示,其余安全显示 JSON | +| 消息 | `POST …/events {events:[{type:"message",text}],idempotency_key}` | 现有 `202 {request_id,status:"accepted",completed:false}`;无自动重试 | +| 取消 | `POST …/events {events:[{type:"agent.session.input.cancel",turn_id}]}` | JSON 响应,只显示请求结果;等权威终态事件 | +| 审批 | `GET …/actions` | 标准列表,仅返回待审批事项;每项必须有 `id`,其余字段完整显示 | +| 审批决策 | `POST …/actions/{id} {decision:"accept"或"decline"}` | JSON 或 204;重新 GET actions 验证待办列表 | +| 差异 | `GET …/project/diff` | `{diff:string}`;只有空字符串才显示空差异 | +| 导入 | `POST …/project/import {files:[{path,content}]}` | JSON 结果原样显示;服务端负责路径/覆盖/权限策略 | +| 导出 | `POST …/project/export {}` | `{files:[{path,content}],…}`,下载完整 JSON | +| 桌面 | `POST …/desktop {mode:"view"或"control"}` | `{url,pauses_agent?}`,URL 必须同源 `/desktop/…`,装入 iframe | +| 交还 | `POST …/handback {}` | JSON 或 204,成功后移除查看器 | +| 手动剪贴板 | `GET …/clipboard` / `POST …/clipboard {text}` | 读取 `{text:string}`;写入 JSON 或 204。仅按钮触发,无系统剪贴板 API | + +列表消费 `limit=100` 和 `after=last_id` 直到 `has_more=false`,不会静默截断。JSON 错误格式沿用 `{error:{type,message}}`。 + +## SSE 与工具事件 + +通过 fetch GET `…/events`,带 Bearer 和 `Accept: text/event-stream`。按 SSE `id` 缓存最近游标于内存;点击恢复发送 `Last-Event-ID`,重读 items 与 actions。断流不会重发任务。无游标的上游错误不会覆盖最近游标。无效游标保留显式错误,用户可断开重连以重新读取历史。 + +既有 envelope `{id,type,session_id,data}` 继续支持:`node.session.items.changed` 重读记录;`node.session.input.accepted/rejected/unknown` 只更新接收状态;`node.upstream.unavailable` 明示未知。新增 Harness envelope 的 `data` 可为 `{method,params}`: + +- `turn/started`:`params.turn.id`;`turn/completed`:`params.turn.{id,status}`,状态不省略、不推断成功。 +- `item/agentMessage/delta` / `item/commandExecution/outputDelta` 等:`params.{itemId,turnId,delta}`,按 item 累积字符串,原始工具事件保留在日志。 +- `turn/diff/updated`:`params.diff`;含 action/approval 的事件触发重新读取待审批。 +- 同时接受类型以 `turn.started/running/completed/failed/cancelled/interrupted/unknown` 结尾的事件,数据中的 `turn_id` 或 `turn.id` 关联回合。 + +未知事件保留原始 JSON,不伪造完成状态。日志显示区保留最近约 90–100k 字符;持久历史仍属于 API。UI 不实现 Harness、运行时、审批执行或桌面协议。 + +## 验证 + +```sh +node --test tests/node-agent-api-web-ui-codex-contract.test.mjs +``` + +同一测试的真实 Chromium 场景可通过 `NODE_AGENT_UI_BROWSER=1` 开启,并提供 `NODE_AGENT_UI_BROWSER_LOADER`(现有 omo browser skill 的 `scripts/omowright.mjs`)、`NODE_AGENT_UI_EVIDENCE`(证据目录)、可选 `NODE_AGENT_UI_CHROME`。该场景运行独立回环测试服务及临时浏览器 profile,记录实际请求与截图;不调用真实 bot,不证明后端 Harness/桌面/RFB 运行成功。页面中的所有演示数据仅来自测试服务,产品代码没有测试数据或回退数据。 diff --git a/tools/node-agent-api/web/app.js b/tools/node-agent-api/web/app.js new file mode 100644 index 0000000..bb63dc6 --- /dev/null +++ b/tools/node-agent-api/web/app.js @@ -0,0 +1,347 @@ +import { capabilities, createClient, desktopURL, eventView, projectDownload, readSSE, sessionPath } from './client.js'; + +const $ = id => document.getElementById(id); +const text = (id, value) => { $(id).textContent = value; }; +const pretty = value => JSON.stringify(value, null, 2); +const stateName = value => ({ idle: '待开始', pending: '提交中', accepted: '已接收', running: '正在执行', completed: '已完成', failed: '未完成', cancelled: '已取消', interrupted: '已中断', unknown: '状态待确认', closed: '已归档' }[value] ?? '状态待确认'); +let client, flags = capabilities(), botId = '', session, turnId = '', epoch = 0; +let controller = new AbortController(), streamController, sending = false; +const cursors = new Map(), liveItems = new Map(), busy = new Map(); +const status = value => text('status', value); + +function controls() { + const attached = Boolean(client && session), writable = attached && session.status !== 'closed'; + const enabled = { + bots: Boolean(client), refresh: Boolean(client), disconnect: Boolean(client), + history: Boolean(client && botId), attach: Boolean(client && botId), + resume: attached, prompt: writable && flags.send, send: writable && flags.send && !sending, + cancel: writable && flags.cancel && Boolean(turnId), + 'actions-refresh': attached && flags.actions, 'diff-refresh': attached && flags.diff, + import: writable && flags.import, 'import-file': writable && flags.import, export: attached && flags.export, + view: writable && flags.view, control: writable && flags.control, handback: attached && flags.control, + clipboard: attached && (flags.clipboardRead || flags.clipboardWrite), + 'clipboard-read': attached && flags.clipboardRead, 'clipboard-write': writable && flags.clipboardWrite, + applications: attached && flags.applications, 'applications-refresh': attached && flags.applications, + 'application-view': writable && flags.applications && Boolean($('applications').value), 'application-control': writable && flags.applications && flags.control && Boolean($('applications').value), + 'launch-terminal': writable && flags.applications && flags.control, 'launch-browser': writable && flags.applications && flags.control, + backup: writable && flags.export, 'backup-id': writable && flags.import, 'restore-backup': writable && flags.import && Boolean($('backup-id').value), + }; + for (const [id, allowed] of Object.entries(enabled)) $(id).disabled = !allowed || busy.has(id); + $('connect').disabled = busy.has('auth'); +} + +async function run(id, action) { + if (busy.has(id)) return; + const generation = epoch, operation = Symbol(id); + busy.set(id, operation); controls(); + try { await action(); } + catch (error) { if (generation === epoch && error.name !== 'AbortError') status(error.message); } + finally { if (busy.get(id) === operation) busy.delete(id); controls(); } +} +function bind(id, action) { $(id).addEventListener('click', () => run(id, action)); } +function resetSession() { + ++epoch; controller.abort(); controller = new AbortController(); streamController?.abort(); + busy.clear(); + session = undefined; turnId = ''; sending = false; liveItems.clear(); + $('transcript').replaceChildren(); $('approvals').replaceChildren(); $('desktop').replaceChildren(); + $('clipboard').value = ''; $('prompt').value = ''; $('import-file').value = ''; + $('applications').replaceChildren(new Option('点击刷新应用', '')); $('backup-id').value = ''; + text('identity', '未选择会话'); text('thread', '未选择会话'); text('turn-status', '回合结果未知'); + text('diff', '尚未读取'); text('logs', '尚未收到事件'); text('stream-status', '事件流未连接'); + text('desktop-status', flags.view ? '可点击观看;接管需明确操作。' : '服务未声明桌面观看能力。'); + controls(); +} +function disconnect() { + resetSession(); client = undefined; flags = capabilities(); botId = ''; cursors.clear(); + $('key').value = ''; $('bots').replaceChildren(new Option('请先连接', '')); + $('sessions').replaceChildren(); text('capabilities', '尚未查询'); text('context', '会话语义尚未查询。'); + text('title', '选择机器人与会话'); text('desktop-status', '未连接'); + text('workspace-status', '能力未声明的操作保持禁用。'); status('已断开,访问密钥已从本页清除。'); controls(); +} +const options = extra => ({ signal: controller.signal, ...extra }); +async function scopedJSON(path, extra) { + const generation = epoch; + const value = await client.json(path, options(extra)); + if (generation !== epoch) throw new DOMException('Aborted', 'AbortError'); + return value; +} +const post = (path, body) => scopedJSON(path, { method: 'POST', body }); + +async function loadBots() { + const rows = await client.list('/v1/agents', controller.signal); + resetSession(); botId = ''; + $('bots').replaceChildren(new Option(rows.length ? '选择机器人' : '没有可访问的机器人', '')); + for (const bot of rows) $('bots').add(new Option(bot.name || bot.id, bot.id)); + $('sessions').replaceChildren(); text('title', '选择机器人与会话'); + status(rows.length ? '已连接。请选择机器人。' : '服务返回空机器人列表。'); controls(); +} + +async function loadSessions() { + const generation = epoch, selectedBot = botId; + const rows = await client.list(`/v1/agents/sessions?agent_id=${encodeURIComponent(selectedBot)}`, controller.signal); + if (generation !== epoch) return; + $('sessions').replaceChildren(); + for (const row of rows) { + const button = document.createElement('button'); + button.textContent = `${row.metadata?.title || '会话 ' + (rows.indexOf(row) + 1)} · ${stateName(row.task_status ?? row.status)}`; + button.title = row.id; + button.setAttribute('aria-current', String(row.id === session?.id)); + button.addEventListener('click', () => run('select-session', () => selectSession(row.id))); + $('sessions').append(button); + } + if (!rows.length) text('sessions', '此机器人没有已关联会话。'); +} + +function message(parent, label, value) { + const article = document.createElement('article'); article.className = 'message'; + const heading = document.createElement('strong'); heading.textContent = label; + const body = document.createElement('pre'); body.textContent = value; + article.append(heading, body); parent.append(article); return body; +} +async function loadItems() { + const generation = epoch; + const rows = await client.list(`${sessionPath(session.id)}/items`, controller.signal); + if (generation !== epoch) return; + const fragment = document.createDocumentFragment(); + const roles = { user: '用户', assistant: '助手', tool: '工具', system: '系统' }; + for (const item of rows) { + const label = roles[item.role] ?? ({ userMessage: '你', agentMessage: '助手', commandExecution: '终端', fileChange: '文件修改', reasoning: '进度', dynamicToolCall: '审批操作' }[item.type]) ?? '记录'; + const value = item.text ?? (item.type === 'userMessage' ? (item.content ?? []).map(part => part.text ?? '').join('\n') : item.type === 'commandExecution' ? [item.command, item.aggregatedOutput, item.exitCode === null || item.exitCode === undefined ? '' : '退出码:' + item.exitCode].filter(Boolean).join('\n') : pretty(item)); + message(fragment, label, value); + } + $('transcript').replaceChildren(fragment); + if (!rows.length) text('transcript', '当前会话没有返回对话记录。'); +} +async function loadActions() { + if (!flags.actions) { text('approvals', '服务未声明审批能力。'); return; } + const generation = epoch, path = `${sessionPath(session.id)}/actions`; + const rows = await client.list(path, controller.signal); + if (generation !== epoch) return; + $('approvals').replaceChildren(); + for (const action of rows) { + const box = document.createElement('div'); box.className = 'approval'; + message(box, `待审批 · ${action.id}`, pretty(action)); + for (const [decision, label] of [['accept', '允许'], ['decline', '拒绝']]) { + const button = document.createElement('button'); button.textContent = label; + button.addEventListener('click', () => run(`action-${action.id}`, async () => { + for (const child of box.querySelectorAll('button')) child.disabled = true; + try { + const result = await post(`${path}/${encodeURIComponent(action.id)}`, { decision }); + if (generation !== epoch) return; + status(`审批响应:${pretty(result)}`); await loadActions(); + } finally { for (const child of box.querySelectorAll('button')) child.disabled = false; } + })); + box.append(button); + } + $('approvals').append(box); + } + if (!rows.length) text('approvals', '没有待审批操作。'); +} + +async function selectSession(id) { + resetSession(); + const generation = epoch; + try { + const value = await client.json(sessionPath(id), options()); + if (generation !== epoch) return; + if (value.agent_id !== botId) throw new Error('会话所属机器人不匹配'); + session = value; + turnId = value.active_turn_id ?? ''; + text('identity', flags.shared ? '机器人共用 Linux 项目与数据,桌面按机器人区分' : '当前机器人使用 Linux 项目环境'); text('thread', '当前会话'); + status(`会话已恢复 · ${stateName(value.task_status ?? value.status)}`); + text('turn-status', turnId ? `活动回合:${turnId} · 结果未知` : '尚未观察到权威回合结果'); + controls(); + await Promise.all([loadItems(), loadActions(), loadSessions()]); + if (generation === epoch) startStream(); + } catch (error) { if (generation === epoch && error.name !== 'AbortError') status(error.message); } +} + +function receive(event) { + if (!event.data || typeof event.data !== 'object') throw new Error('事件载荷不是对象'); + const envelope = event.data; + if (envelope.session_id && envelope.session_id !== session.id) return; + if (event.id) cursors.set(session.id, event.id); + const view = eventView(event); + text('logs', ($('logs').textContent === '尚未收到事件' ? '' : $('logs').textContent + '\n') + pretty(envelope)); + if ($('logs').textContent.length > 100000) text('logs', '较早事件已从显示区截断。\n' + $('logs').textContent.slice(-90000)); + if (view.turnStatus) { + const labels = { running: '运行中', completed: '已完成', failed: '失败', cancelled: '已取消', interrupted: '已中断', unknown: '结果未知' }; + text('turn-status', `${view.turnId ?? '未提供回合 ID'} · ${labels[view.turnStatus] ?? view.turnStatus}`); + if (view.turnStatus === 'running') turnId = view.turnId ?? ''; + else if (!view.turnId || view.turnId === turnId) turnId = ''; + } + if (view.delta !== undefined && view.itemId) { + let node = liveItems.get(view.itemId); + if (!node) { + node = message($('transcript'), `流式输出 · ${view.method || view.type} · ${view.itemId}`, ''); + liveItems.set(view.itemId, node); + } + node.textContent += view.delta; + } + if (/input\.(accepted|rejected|unknown)$/.test(view.type)) status(`输入状态:${view.type.split('.').at(-1)};请以回合事件判断结果。`); + if (view.type === 'node.upstream.unavailable') status('上游不可用,任务结果未知;不会自动重发输入。'); + if (view.type === 'node.session.closed') { session.status = 'closed'; $('desktop').replaceChildren(); } + if (view.type.endsWith('items.changed')) void run('items-update', async () => { await loadItems(); liveItems.clear(); }); + if (view.method === 'turn/diff/updated' && typeof view.params.diff === 'string') text('diff', view.params.diff || '服务返回空差异。'); + if (/action|approval/i.test(view.type + view.method) && flags.actions) void run('actions-update', loadActions); + controls(); +} + +function startStream() { + streamController?.abort(); + if (!flags.sse) { text('stream-status', '服务未声明 SSE 能力,请手动刷新记录。'); return; } + const generation = epoch, selectedSession = session.id, selectedClient = client; + const stream = new AbortController(); streamController = stream; + text('stream-status', '正在连接事件流…'); + void (async () => { + try { + const cursor = cursors.get(selectedSession); + const response = await selectedClient.request(`${sessionPath(selectedSession)}/events`, { + signal: stream.signal, headers: { Accept: 'text/event-stream', ...(cursor ? { 'Last-Event-ID': cursor } : {}) }, + }); + if (generation !== epoch || stream.signal.aborted) return; + text('stream-status', '事件流已连接'); + await readSSE(response, event => { if (generation === epoch && !stream.signal.aborted) receive(event); }); + if (generation === epoch && !stream.signal.aborted) text('stream-status', '事件流已断开;点击恢复。回合结果可能未知。'); + } catch (error) { + if (generation === epoch && !stream.signal.aborted) text('stream-status', `事件流不可用:${error.message}。可点击恢复;不会重发消息。`); + } + })(); +} + +$('auth').addEventListener('submit', event => { + event.preventDefault(); + const key = $('key').value.trim(); + if (!key) return; + disconnect(); + void run('auth', async () => { + client = createClient(key, location.origin); + try { + const value = await client.json('/v1/capabilities', options()); + flags = capabilities(value); text('capabilities', pretty(value)); + text('context', value.sessions?.independent_conversations === false ? '同一机器人共享既有对话;关联会话不会创建独立上下文。' : '恢复已有会话不会自动重跑任务。'); + text('workspace-status', `差异:${flags.diff ? '支持' : '未声明支持'} · 导入:${flags.import ? '支持' : '未声明支持'} · 导出:${flags.export ? '支持' : '未声明支持'}`); + await loadBots(); + } catch (error) { if (error.name !== 'AbortError') { disconnect(); status(error.message); } } + }); +}); +bind('disconnect', disconnect); +bind('refresh', loadBots); +$('bots').addEventListener('change', () => { + resetSession(); botId = $('bots').value; $('sessions').replaceChildren(); + text('title', botId ? $('bots').selectedOptions[0].textContent : '选择机器人与会话'); + status(botId ? '请选择已有会话,或关联当前机器人。' : '请选择机器人。'); controls(); + if (botId) void run('history', loadSessions); +}); +bind('history', loadSessions); +bind('attach', async () => { + const value = await post('/v1/agents/sessions', { agent_id: botId }); + await selectSession(value.id); +}); +bind('resume', async () => { await Promise.all([loadItems(), loadActions()]); liveItems.clear(); startStream(); }); +bind('actions-refresh', loadActions); +$('composer').addEventListener('submit', event => { + event.preventDefault(); + if (!session || !flags.send || sending || session.status === 'closed') return; + const prompt = $('prompt').value; + if (!prompt.trim()) return; + const generation = epoch; + void run('send', async () => { + sending = true; + try { + const value = await post(`${sessionPath(session.id)}/events`, { + events: [{ type: 'message', text: prompt }], idempotency_key: `nui_${crypto.randomUUID()}`, + }); + if (generation !== epoch) return; + status(`提交响应:${value.status ?? 'unknown'} · ${value.request_id ?? '未提供请求 ID'}。接收不代表任务完成。`); + if (value.status === 'accepted') $('prompt').value = ''; + } catch (error) { + if (generation === epoch && error.name !== 'AbortError') status(`提交未确认,结果可能未知;请先检查记录,勿盲目重发。${error.message}`); + } finally { if (generation === epoch) sending = false; } + }); +}); +bind('cancel', async () => { + const value = await post(`${sessionPath(session.id)}/events`, { events: [{ type: 'agent.session.input.cancel', turn_id: turnId }] }); + status(`取消请求响应:${pretty(value)};等待权威回合结果。`); +}); +async function desktop(mode) { + const value = await post(`${sessionPath(session.id)}/desktop`, { mode }); + const iframe = document.createElement('iframe'); + iframe.title = mode === 'control' ? '机器人桌面 · 接管' : '机器人桌面 · 观看'; + iframe.referrerPolicy = 'no-referrer'; iframe.src = desktopURL(value.url, location.origin); + $('desktop').replaceChildren(iframe); + text('desktop-status', `${mode === 'control' ? '已签发接管授权' : '已签发观看授权'};画面连接由查看器显示。${value.pauses_agent === false ? '接管不会暂停机器人。' : '机器人暂停状态未确认。'}`); +} +bind('view', () => desktop('view')); bind('control', () => desktop('control')); +bind('applications-refresh', async () => { + const rows = await client.list(`${sessionPath(session.id)}/applications`, controller.signal); + $('applications').replaceChildren(new Option(rows.length ? '选择应用窗口' : '此桌面没有运行的应用', '')); + for (const row of rows) $('applications').append(new Option(row.name || '应用窗口', row.id)); + controls(); +}); +$('applications').addEventListener('change', controls); +for (const [id, kind] of [['launch-terminal', 'terminal'], ['launch-browser', 'browser']]) bind(id, async () => { + await post(`${sessionPath(session.id)}/applications`, { kind }); status('应用启动请求已接收。请刷新应用列表;需要先接管此机器人的桌面。'); +}); +for (const [id, mode] of [['application-view', 'view'], ['application-control', 'control']]) { + $(id).addEventListener('click', () => { + if (!session || !$('applications').value) return; + const selected = $('applications').value, popup = window.open('', '_blank'); + if (!popup) { status('浏览器阻止了新窗口,请允许此页面打开窗口后再点击。'); return; } + popup.opener = null; + void run(id, async () => { + try { const value = await post(`${sessionPath(session.id)}/desktop`, { mode, target: { type: 'application', application_id: selected } }); popup.location.href = desktopURL(value.url, location.origin); status(mode === 'control' ? '应用接管窗口已打开。' : '应用观看窗口已打开。'); } + catch (error) { popup.close(); throw error; } + }); + }); +} +bind('handback', async () => { + const value = await post(`${sessionPath(session.id)}/handback`, {}); + $('desktop').replaceChildren(); text('desktop-status', `交还响应:${pretty(value)}。可重新点击观看。`); +}); +bind('clipboard-read', async () => { + const value = await scopedJSON(`${sessionPath(session.id)}/clipboard`); + if (typeof value.text !== 'string') throw new Error('剪贴板响应缺少 text'); + $('clipboard').value = value.text; status('机器人剪贴板已读取到文本框,可手动选择复制。'); +}); +bind('clipboard-write', async () => { + const value = await post(`${sessionPath(session.id)}/clipboard`, { text: $('clipboard').value }); + status(`剪贴板写入响应:${pretty(value)}`); +}); +bind('diff-refresh', async () => { + const value = await scopedJSON(`${sessionPath(session.id)}/project/diff`); + if (typeof value.diff !== 'string') throw new Error('差异响应缺少 diff,不能判断是否有修改'); + text('diff', value.diff || '服务返回空差异。'); +}); +$('backup-id').addEventListener('input', controls); +bind('backup', async () => { + const value = await post(`${sessionPath(session.id)}/environment/backup`, {}); + $('backup-id').value = value.backup_id; status('备份已保存。请保留备份编号:' + value.backup_id); controls(); +}); +bind('restore-backup', async () => { + await post(`${sessionPath(session.id)}/environment/restore`, { backup_id: $('backup-id').value.trim() }); + await Promise.all([loadItems(), loadActions()]); status('项目备份已恢复,当前文件也已先备份;需要的运行进程请手动重跑。'); +}); +bind('import', async () => { + const file = $('import-file').files[0]; + if (!file) throw new Error('请先选择项目 JSON 文件'); + const generation = epoch; + if (file.size > 4 * 1024 * 1024) throw new Error('文件超过 4 MiB,请拆分后导入'); + const value = JSON.parse(await file.text()); + if (generation !== epoch) return; + if (!Array.isArray(value.files) || !value.files.length || value.files.some(row => typeof row.path !== 'string' || !row.path || typeof row.content !== 'string')) throw new Error('导入格式须为 {files:[{path,content}]}'); + const result = await post(`${sessionPath(session.id)}/project/import`, { files: value.files.map(({ path, content }) => ({ path, content })) }); + text('workspace-status', `导入响应:${pretty(result)}`); +}); +bind('export', async () => { + const selected = session.id, generation = epoch; + const value = await post(`${sessionPath(selected)}/project/export`, {}); + const download = await projectDownload(client, value, selected, controller.signal); + if (generation !== epoch) return; + const url = URL.createObjectURL(download.blob); + const link = document.createElement('a'); link.href = url; link.download = download.name; link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + text('workspace-status', download.message); +}); +window.addEventListener('pagehide', disconnect); +controls(); diff --git a/tools/node-agent-api/web/client.js b/tools/node-agent-api/web/client.js new file mode 100644 index 0000000..639245e --- /dev/null +++ b/tools/node-agent-api/web/client.js @@ -0,0 +1,132 @@ +export const sessionPath = id => `/v1/agents/sessions/${encodeURIComponent(id)}`; + +export function capabilities(value = {}) { + return { + send: value.writes_enabled === true, + sse: value.events?.sse === true, + cancel: value.events?.cancellation === true, + actions: value.required_actions === true || value.events?.required_actions === true, + diff: value.project?.diff === true, + import: value.project?.import === true, + export: value.project?.export === true, + view: value.desktop?.view === true, + control: value.desktop?.control === true, + applications: value.desktop?.applications === true, + shared: value.environment?.isolation === 'shared_container_separate_display', + clipboardRead: value.clipboard?.read === true, + clipboardWrite: value.clipboard?.write === true, + }; +} + +export function createClient(key, origin, fetcher = fetch) { + async function request(path, { method = 'GET', body, signal, headers = {} } = {}) { + const url = new URL(path, origin); + if (url.origin !== origin || !url.pathname.startsWith('/v1/')) throw new Error('拒绝向其他来源发送密钥'); + const response = await fetcher(url.href, { + method, signal, redirect: 'error', cache: 'no-store', credentials: 'same-origin', + headers: { ...headers, Authorization: `Bearer ${key}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (!response.ok) { + const value = await response.json().catch(() => ({})); + throw new Error(`HTTP ${response.status} · ${value.error?.type ?? 'request_failed'} · ${value.error?.message ?? '请求失败;操作结果未知'}`); + } + return response; + } + async function json(path, options) { + const response = await request(path, options); + if (response.status === 204) return {}; + return response.json(); + } + async function list(path, signal) { + const rows = [], seen = new Set(); + let after = ''; + do { + const url = new URL(path, origin); + url.searchParams.set('limit', '100'); + if (after) url.searchParams.set('after', after); + const page = await json(url.href, { signal }); + if (!Array.isArray(page.data)) throw new Error('列表响应缺少 data 数组'); + rows.push(...page.data); + if (!page.has_more) return rows; + after = page.last_id; + if (typeof after !== 'string' || !after || seen.has(after)) throw new Error('分页游标无效,列表可能不完整'); + seen.add(after); + } while (!signal?.aborted); + throw new DOMException('Aborted', 'AbortError'); + } + return { request, json, list }; +} + +export async function projectDownload(client, value, sessionId, signal) { + if (Array.isArray(value.files)) return { + blob: new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }), + name: `${sessionId}-project.json`, + message: `已收到 ${value.files.length} 个文件,已触发浏览器下载。`, + }; + const artifact = value.artifact; + if (!artifact || typeof artifact.download_url !== 'string' || !artifact.download_url) throw new Error('导出响应缺少 files 或 artifact.download_url,未生成下载'); + const response = await client.request(artifact.download_url, { signal }); + const blob = await response.blob(); + const name = typeof artifact.name === 'string' && artifact.name.trim() + ? artifact.name.replace(/[\\/\u0000-\u001f\u007f]/g, '_') : `${sessionId}-project.tar.gz`; + return { blob, name, message: `已收到导出文件 ${name}(${blob.size} 字节),已触发浏览器下载。` }; +} + +export async function readSSE(response, receive) { + if (!response.headers.get('content-type')?.includes('text/event-stream') || !response.body) throw new Error('服务没有返回 SSE 事件流'); + const reader = response.body.getReader(), decoder = new TextDecoder(); + let buffer = '', data = [], type = '', id = ''; + const line = text => { + if (!text) { + if (data.length) receive({ type: type || 'message', id, data: JSON.parse(data.join('\n')) }); + data = []; type = ''; id = ''; + return; + } + if (text.startsWith(':')) return; + const colon = text.indexOf(':'); + const field = colon < 0 ? text : text.slice(0, colon); + const value = colon < 0 ? '' : text.slice(colon + 1).replace(/^ /, ''); + if (field === 'data') data.push(value); + if (field === 'event') type = value; + if (field === 'id' && !value.includes('\0')) id = value; + }; + try { + for (;;) { + const { value, done } = await reader.read(); + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + let match; + while ((match = /\r\n|\r|\n/.exec(buffer))) { + if (!done && match[0] === '\r' && match.index === buffer.length - 1) break; + line(buffer.slice(0, match.index)); + buffer = buffer.slice(match.index + match[0].length); + } + if (buffer.length > 1024 * 1024 || data.join('\n').length > 1024 * 1024) throw new Error('事件超出客户端大小限制'); + if (done) break; + } + } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } +} + +export function desktopURL(value, origin) { + if (typeof value !== 'string' || !value) throw new Error('桌面响应缺少 URL'); + const url = new URL(value, origin); + if (url.origin !== origin || !url.pathname.startsWith('/desktop/') || url.username || url.password) throw new Error('拒绝非同源桌面 URL'); + return url.href; +} + +export function eventView(event) { + const envelope = event.data; + const type = envelope.type ?? event.type; + const payload = envelope.data ?? envelope; + const method = payload.method ?? envelope.method ?? ''; + const params = payload.params ?? envelope.params ?? payload; + const turn = params.turn ?? {}; + const turnId = params.turn_id ?? params.turnId ?? turn.id; + let turnStatus; + if (method === 'turn/started' || /(?:^|\.)turn\.(?:started|running)$/.test(type)) turnStatus = 'running'; + if (method === 'turn/completed') turnStatus = turn.status ?? 'unknown'; + if (/(?:^|\.)turn\.(completed|failed|cancelled|interrupted|unknown)$/.test(type)) turnStatus = type.split('.').at(-1); + const itemId = params.item_id ?? params.itemId ?? params.item?.id; + const delta = typeof params.delta === 'string' ? params.delta : undefined; + return { type, method, params, turnId, turnStatus, itemId, delta }; +} diff --git a/tools/node-agent-api/web/index.html b/tools/node-agent-api/web/index.html new file mode 100644 index 0000000..9ee26c6 --- /dev/null +++ b/tools/node-agent-api/web/index.html @@ -0,0 +1,79 @@ + + + + + + + + GrokNode · 会话工作台 + + + + + +
+
+

选择机器人与会话

+

未选择会话

+

请输入当前服务的访问密钥。

+

回合结果未知

+
事件流未连接
+
+

连接后显示该机器人的真实对话与工具记录。

+
+
+ + +
未选择会话
+
+
+ + + diff --git a/tools/node-agent-api/web/style.css b/tools/node-agent-api/web/style.css new file mode 100644 index 0000000..39da2b5 --- /dev/null +++ b/tools/node-agent-api/web/style.css @@ -0,0 +1,2 @@ +:root{color-scheme:dark;--editor:#181818;--chrome:#141414;--ink:#f0f0f0;--accent:#599ce7;--added:#70b489;--surface:color-mix(in srgb,var(--ink) 6%,transparent);--hover:color-mix(in srgb,var(--ink) 14%,transparent);--line:color-mix(in srgb,var(--ink) 12%,transparent);--muted:color-mix(in srgb,var(--ink) 70%,transparent);--radius:8px;--s1:4px;--s2:8px;--s3:12px;--s4:16px;--s6:24px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--editor)}*{box-sizing:border-box}body{margin:0;height:100dvh;display:grid;grid-template-columns:240px minmax(280px,1fr) minmax(320px,40%)}aside,main,section{min-width:0;min-height:0}aside{background:var(--chrome);padding:var(--s4);overflow:auto;border-right:1px solid var(--line)}h1{font-size:18px;margin:0 0 var(--s6)}h1 small{display:block;font-size:12px;font-weight:400;letter-spacing:.08em;color:var(--muted)}h2{font-size:14px;margin:var(--s4) 0 var(--s2)}main{display:flex;flex-direction:column}header{padding:var(--s4) var(--s6);border-bottom:1px solid var(--line)}header h2{margin:0}p{margin:var(--s2) 0;color:var(--muted)}label{display:block;margin:var(--s3) 0 var(--s1)}button,select,input,textarea{font:inherit;color:inherit}button,select,textarea,input[type=file]{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:var(--s2) var(--s3)}button{cursor:pointer;transition:background-color 120ms;min-height:36px}button:hover{background:var(--hover)}button:disabled{opacity:.45;cursor:not-allowed}button:focus-visible,select:focus-visible,textarea:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}select,textarea,input[type=file]{width:100%}textarea{resize:vertical}aside>button{width:100%;margin-top:var(--s2)}#sessions button{display:block;width:100%;text-align:left;margin-top:var(--s2);overflow-wrap:anywhere}#transcript{flex:1;overflow:auto;padding:var(--s6)}.empty{max-width:32em;margin-top:var(--s6)}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.6 ui-monospace,SFMono-Regular,monospace;margin:var(--s2) 0;max-height:360px;overflow:auto}.message{padding:var(--s3) 0;border-bottom:1px solid var(--line)}.message pre{font-size:14px;max-height:none}#composer{padding:var(--s4) var(--s6);border-top:1px solid var(--line)}.toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s2);margin:var(--s2) 0}.toolbar span{margin-right:auto;color:var(--muted);font-size:12px;overflow-wrap:anywhere}#send{background:var(--ink);color:var(--editor)}.details{border-left:1px solid var(--line);border-right:0}#desktop{aspect-ratio:16/10;background:var(--editor);overflow:hidden;margin-top:var(--s3);border-radius:var(--radius)}#desktop-status,#identity,#status,#workspace-status{font-size:12px;overflow-wrap:anywhere}#approvals{max-height:240px;overflow:auto;padding:0 var(--s6)}.approval{background:var(--surface);border-radius:var(--radius);padding:var(--s3);margin-bottom:var(--s2)}details{margin-top:var(--s4)}summary{cursor:pointer}section+section{border-top:1px solid var(--line);margin-top:var(--s6)}@media(max-width:1000px){body{height:auto;min-height:100dvh;grid-template-columns:220px minmax(0,1fr)}main{min-height:85dvh}.details{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;gap:var(--s6)}.details section+section{border:0;margin:0}}@media(max-width:640px){body{display:block}aside{border-bottom:1px solid var(--line)}main{min-height:80dvh}.details{display:block}header,#composer,#transcript{padding:var(--s4)}}@media(prefers-reduced-motion:reduce){button{transition:none}} +:root{--small:12px;--body:14px;--heading:18px}input[type=password]{width:100%;background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:var(--s2) var(--s3)}.hint{font-size:var(--small)}button{transition:none}button:disabled:hover{background:var(--surface)}#desktop iframe{width:100%;height:100%;border:0}#sessions button[aria-current=true]{background:var(--hover)}#turn-status,#stream-status,#context{font-size:var(--small);overflow-wrap:anywhere}#import{margin-top:var(--s2)}#approvals{font-size:var(--small)}.approval button{margin:var(--s1)}#auth{padding-bottom:var(--s4);border-bottom:1px solid var(--line)}#key:focus-visible{outline:2px solid var(--accent);outline-offset:2px}#sessions:empty::after{content:'选择机器人后加载真实会话。';color:var(--muted)}@media(max-width:640px){.details section+section{margin-top:var(--s6)}#desktop{min-height:180px}button{min-height:44px}} diff --git a/tools/node-agent-api/webhooks.mjs b/tools/node-agent-api/webhooks.mjs new file mode 100644 index 0000000..30b1137 --- /dev/null +++ b/tools/node-agent-api/webhooks.mjs @@ -0,0 +1,206 @@ +import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { chmod } from 'node:fs/promises'; +import { join } from 'node:path'; +import { privateDirectory } from './runtime/common.mjs'; +import { ApiError, badRequest, identifier } from './errors.mjs'; +import { acquireStateLock, atomicJson, readPrivateJson } from './persistence.mjs'; + +const digest = value => createHash('sha256').update(value).digest('hex'); +const natural = value => Number.isSafeInteger(value) && value >= 0; +const newKey = () => ({ id: 'whkey_' + randomUUID(), secret: randomBytes(32).toString('base64url') }); +const validKey = key => key && /^whkey_[a-f0-9-]{36}$/.test(key.id) && /^[A-Za-z0-9_-]{43}$/.test(key.secret); + +/** Numeric canonical loopback only: no DNS, userinfo, query secrets, fragments or URL normalization tricks. */ +export function validateWebhookDestination(value) { + if (typeof value !== 'string' || value.length > 2048 || !/^https?:\/\/(127\.0\.0\.1|\[::1\])(?::[1-9][0-9]{0,4})?(?:\/[^\s?#\\]*)?$/.test(value)) throw badRequest('Webhook destination must be a literal loopback HTTP URL'); + let url; + try { url = new URL(value); } catch { throw badRequest('Invalid webhook destination'); } + if (!['127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.search || url.hash || url.port === '0') throw badRequest('Invalid webhook destination'); + return url.href; +} + +/** Wire signature v1 = HMAC-SHA256(secret, timestamp + '.' + deliveryId + '.' + exact UTF-8 body). + * Receivers must ALSO durably deduplicate delivery IDs after verifying the signature and timestamp. + */ +export function signWebhook({ key, timestamp, deliveryId, body }) { + if (!validKey(key) || !natural(timestamp) || typeof body !== 'string') throw badRequest('Invalid webhook signing input'); + identifier(deliveryId); + return 'v1=' + createHmac('sha256', Buffer.from(key.secret, 'base64url')).update(`${timestamp}.${deliveryId}.${body}`).digest('hex'); +} + +export function verifyWebhookSignature({ keys, keyId, timestamp, deliveryId, body, signature, now = Date.now(), toleranceMs = 300000 }) { + if (!Array.isArray(keys) || !natural(now) || !natural(toleranceMs) || !natural(timestamp) || Math.abs(now - timestamp * 1000) > toleranceMs || !/^v1=[a-f0-9]{64}$/.test(signature ?? '')) return false; + const key = keys.find(k => k?.id === keyId); + try { return timingSafeEqual(Buffer.from(signWebhook({ key, timestamp, deliveryId, body })), Buffer.from(signature)); } + catch { return false; } +} + +/** No fetch/global proxy settings, DNS or redirect following. Timeout covers response-body draining too. */ +export function loopbackWebhookTransport({ url, headers, body, signal }) { + const destination = new URL(validateWebhookDestination(url)); + return new Promise((resolve, reject) => { + const request = (destination.protocol === 'https:' ? httpsRequest : httpRequest)(destination, { method: 'POST', headers, signal, agent: false }, response => { + response.on('error', reject); + response.resume(); + response.on('end', () => resolve({ status: response.statusCode })); + }); + request.on('error', reject); + request.end(body); + }); +} + +/** Standalone trusted-host API; no timers start automatically. configure() explicitly enables each + * instance; reopening is disabled even with saved configuration. Call dispatchDue() from a scheduler. + * Delivery is at-least-once: a crash after HTTP success can replay the SAME delivery ID. + * Rotation affects the next attempt, including queued retries. Provision returned keys out of band; + * receivers retain the previous key during overlap. Injected transports must obey AbortSignal and + * the no-redirect/no-proxy contract; an arbitrary transport cannot be forcibly cancelled by JS. + */ +export async function createWebhookOutbox(directory, options = {}) { + const clock = options.clock ?? Date.now, transport = options.transport ?? loopbackWebhookTransport; + const policy = { timeoutMs: 5000, baseDelayMs: 1000, maxDelayMs: 60000, maxAttempts: 5, maxEntries: 10000, maxBodyBytes: 65536, ...options.policy }; + if (typeof transport !== 'function' || Object.values(policy).some(v => !natural(v) || v < 1) || policy.timeoutMs > 2147483647 || policy.maxDelayMs < policy.baseDelayMs || policy.maxAttempts > 100) throw badRequest('Invalid webhook policy'); + const root = await privateDirectory(join(directory, 'webhooks')); + const unlock = await acquireStateLock(root), file = join(root, 'outbox.json'); + let state; + try { + state = await readPrivateJson(file, { version: 1, destination: null, key: null, deliveries: [] }); + if (state.version !== 1 || !Array.isArray(state.deliveries) || (state.key !== null && !validKey(state.key)) || (state.destination !== null && validateWebhookDestination(state.destination) !== state.destination)) throw new Error('Invalid webhook state'); + const ids = new Set(), dedup = new Set(); + for (const d of state.deliveries) { + identifier(d.id); + if (ids.has(d.id) || dedup.has(d.dedup) || !/^[a-f0-9]{64}$/.test(d.dedup) || !/^[a-f0-9]{64}$/.test(d.principal) || !['pending', 'delivering', 'retry', 'delivered', 'failed'].includes(d.status) || typeof d.body !== 'string' || Buffer.byteLength(d.body) > policy.maxBodyBytes || !natural(d.attempts) || !natural(d.nextAttemptAt) || !natural(d.createdAt) || !['none', 'interrupted', 'timeout', 'transport', 'http_status'].includes(d.lastError) || !(d.lastStatus === null || (Number.isInteger(d.lastStatus) && d.lastStatus >= 100 && d.lastStatus <= 599))) throw new Error('Invalid webhook delivery'); + validateWebhookDestination(d.destination); + const envelope = JSON.parse(d.body); + if (envelope.delivery_id !== d.id) throw new Error('Invalid webhook envelope'); + ids.add(d.id); dedup.add(d.dedup); + if (d.status === 'delivering') { + d.status = d.attempts >= policy.maxAttempts ? 'failed' : 'retry'; + d.nextAttemptAt = clock(); d.lastError = 'interrupted'; + } + } + if (state.deliveries.length && (!state.key || !state.destination)) throw new Error('Missing webhook configuration'); + await atomicJson(file, state); + await chmod(file, 0o600); + } catch (error) { await unlock(); throw error; } + let tail = Promise.resolve(), enabled = false, closed = false, worker, closeTask, fault, configurationEpoch = 0; + function check() { + if (closed || fault) throw fault ?? new ApiError(503, 'webhooks_closed', 'Webhooks are closed'); + } + function transact(operation) { + const task = tail.then(async () => { + if (fault) throw fault; + const next = structuredClone(state), result = operation(next); + try { await atomicJson(file, next); } + catch { fault = new ApiError(503, 'webhook_persistence_failed', 'Reopen webhooks after checking state'); throw fault; } + state = next; + return result; + }); + tail = task.catch(() => {}); + return task; + } + function ready() { + check(); + if (!enabled) throw new ApiError(503, 'webhooks_disabled', 'Explicit webhook configuration required'); + } + function summary(d) { + return { id: d.id, status: d.status, attempts: d.attempts, nextAttemptAt: d.nextAttemptAt, createdAt: d.createdAt, lastError: d.lastError, lastStatus: d.lastStatus }; + } + async function attempt(id) { + const claim = await transact(next => { + const d = next.deliveries.find(d => d.id === id); + if (closed || !enabled || !['pending', 'retry'].includes(d.status) || d.nextAttemptAt > clock()) return null; + if (d.attempts >= policy.maxAttempts) { d.status = 'failed'; return null; } + d.status = 'delivering'; d.attempts++; + return { delivery: structuredClone(d), key: { ...next.key } }; + }); + if (!claim) return; + const { delivery: d, key } = claim, timestamp = Math.floor(clock() / 1000); + const headers = { 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(d.body)), 'x-node-webhook-id': d.id, 'x-node-webhook-timestamp': String(timestamp), 'x-node-webhook-key-id': key.id, 'x-node-webhook-signature': signWebhook({ key, timestamp, deliveryId: d.id, body: d.body }) }; + const controller = new AbortController(); + let timer, lastError = 'none', status = null; + try { + const timeout = new Promise((_, reject) => { timer = setTimeout(() => { lastError = 'timeout'; controller.abort(); reject(new Error('Webhook timeout')); }, policy.timeoutMs); }); + const result = await Promise.race([Promise.resolve().then(() => transport({ url: validateWebhookDestination(d.destination), headers, body: d.body, signal: controller.signal })), timeout]); + if (!Number.isInteger(result?.status) || result.status < 100 || result.status > 599) throw new Error('Invalid transport response'); + status = result.status; + if (status < 200 || status >= 300) lastError = 'http_status'; + } catch { if (lastError !== 'timeout') lastError = 'transport'; } + finally { clearTimeout(timer); } + return transact(next => { + const current = next.deliveries.find(value => value.id === id); + const success = status !== null && status >= 200 && status < 300; + const retryable = status === null || status === 408 || status === 429 || status >= 500; + current.status = success ? 'delivered' : retryable && current.attempts < policy.maxAttempts ? 'retry' : 'failed'; + current.lastError = lastError; current.lastStatus = status; + current.nextAttemptAt = current.status === 'retry' ? clock() + Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (current.attempts - 1)) : 0; + return summary(current); + }); + } + return { + stateFile: file, + async configure({ destination }) { + check(); const url = validateWebhookDestination(destination); + const epoch = ++configurationEpoch; + const configuration = await transact(next => { + next.destination = url; next.key ??= newKey(); + return { destination: url, signingKey: { ...next.key } }; + }); + if (!closed && epoch === configurationEpoch) enabled = true; + return configuration; + }, + disable() { check(); configurationEpoch++; enabled = false; }, + rotateKey() { + ready(); + return transact(next => { next.key = newKey(); return { ...next.key }; }); + }, + enqueue(principal, { idempotencyKey, event }) { + ready(); + const owner = digest(identifier(principal?.id)), key = identifier(idempotencyKey); + let eventBody; + try { eventBody = JSON.stringify(event); } catch { throw badRequest('Webhook event must be JSON'); } + if (!event || typeof event !== 'object' || Array.isArray(event) || !eventBody || Buffer.byteLength(eventBody) > policy.maxBodyBytes) throw badRequest('Invalid webhook event'); + const snapshot = JSON.parse(eventBody), dedup = digest(owner + '.' + key); + return transact(next => { + const existing = next.deliveries.find(d => d.dedup === dedup); + if (existing) { + if (JSON.stringify(JSON.parse(existing.body).event) !== eventBody) throw new ApiError(409, 'webhook_idempotency_conflict', 'Webhook key already used for a different event'); + return summary(existing); + } + if (next.deliveries.length >= policy.maxEntries) throw new ApiError(429, 'webhook_capacity', 'Webhook outbox capacity reached'); + const id = 'whd_' + randomUUID(), createdAt = clock(), body = JSON.stringify({ delivery_id: id, event: snapshot }); + if (Buffer.byteLength(body) > policy.maxBodyBytes) throw badRequest('Webhook event too large'); + const d = { id, principal: owner, dedup, body, destination: next.destination, createdAt, nextAttemptAt: createdAt, attempts: 0, status: 'pending', lastError: 'none', lastStatus: null }; + next.deliveries.push(d); return summary(d); + }); + }, + async status(principal, id) { + check(); identifier(id); const owner = digest(identifier(principal?.id)); await tail; + if (fault) throw fault; + const d = state.deliveries.find(d => d.id === id && d.principal === owner); + if (!d) throw new ApiError(404, 'not_found', 'Webhook delivery not found'); + return summary(d); + }, + dispatchDue() { + ready(); + if (!worker) { + worker = (async () => { + await tail; + const due = state.deliveries.filter(d => ['pending', 'retry'].includes(d.status) && d.nextAttemptAt <= clock()).map(d => d.id); + const results = []; + for (const id of due) { if (closed || !enabled) break; const result = await attempt(id); if (result) results.push(result); } + return results; + })().finally(() => { worker = undefined; }); + } + return worker; + }, + async flush() { await worker; await tail; if (fault) throw fault; }, + close() { + closed = true; enabled = false; + closeTask ??= (async () => { try { await worker; } finally { await tail; await unlock(); } })(); + return closeTask; + }, + }; +}